Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add version check to base command #47

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cli/src/main/java/com/okta/cli/OktaCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
AutoComplete.GenerateCompletion.class})
public class OktaCli implements Runnable {

public static final String VERSION = ApplicationInfo.get().get("okta-cli");

@Spec
private CommandSpec spec;

Expand Down Expand Up @@ -150,8 +152,7 @@ public static class VersionProvider implements CommandLine.IVersionProvider {

@Override
public String[] getVersion() throws Exception {
String version = ApplicationInfo.get().get("okta-cli");
return new String[] {version };
return new String[] { VERSION };
}
}
}
79 changes: 77 additions & 2 deletions cli/src/main/java/com/okta/cli/commands/BaseCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,62 @@

import com.okta.cli.Environment;
import com.okta.cli.OktaCli;
import com.okta.cli.common.model.Semver;
import com.okta.cli.common.model.VersionInfo;
import com.okta.cli.common.service.DefaultStartRestClient;
import com.okta.cli.common.service.StartRestClient;
import com.okta.cli.console.ConsoleOutput;
import com.okta.cli.console.Prompter;
import picocli.CommandLine;

import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public abstract class BaseCommand implements Callable<Integer> {

@CommandLine.Mixin
private OktaCli.StandardOptions standardOptions;

public BaseCommand() {}
private final StartRestClient restClient;

public BaseCommand() {
this(new DefaultStartRestClient());
}

BaseCommand(StartRestClient restClient) {
this.restClient = restClient;
}

public BaseCommand(OktaCli.StandardOptions standardOptions) {
this();
this.standardOptions = standardOptions;
}

BaseCommand(StartRestClient restClient, OktaCli.StandardOptions standardOptions) {
this(restClient);
this.standardOptions = standardOptions;
}

protected abstract int runCommand() throws Exception;

@Override
public Integer call() throws Exception {
return runCommand();

// Before running the command, kick off a thread to get the latest version
Semver currentVersion = getCurrentVersion();
Future<Optional<VersionInfo>> future = asyncVersionInfo(currentVersion);

// run the actual command
int exitCode = runCommand();

// After the command finishes alert the user if needed
handleVersionInfo(future, currentVersion);
return exitCode;
}

protected OktaCli.StandardOptions getStandardOptions() {
Expand All @@ -56,4 +90,45 @@ protected Prompter getPrompter() {
protected Environment getEnvironment() {
return standardOptions.getEnvironment();
}

private Future<Optional<VersionInfo>> asyncVersionInfo(Semver currentVersion) {

Callable<Optional<VersionInfo>> versionInfoCallable = () -> {
// if the shell is NOT interactive skip the version check, it's being used in a script
if (currentVersion.isReleaseBuild() && standardOptions.getEnvironment().isInteractive()) {
return Optional.of(restClient.getVersionInfo());
} else {
return Optional.empty();
}
};
return Executors.newSingleThreadExecutor().submit(versionInfoCallable);
}

// protected to allow for testing
Semver getCurrentVersion() {
return Semver.parse(OktaCli.VERSION);
}

private void handleVersionInfo(Future<Optional<VersionInfo>> future, Semver currentVersion) {

try {
future.get(2, TimeUnit.SECONDS)
.ifPresent(info -> {
if (Semver.parse(info.getLatestVersion()).isGreaterThan(currentVersion)) {
ConsoleOutput out = getConsoleOutput();
out.writeLine("");
out.bold("A new version of the Okta CLI is available: " + info.getLatestVersion());
out.writeLine("");
getConsoleOutput().bold("See what's new: " + info.getLatestReleaseUrl());
out.writeLine("");
}
});
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// by default do NOT show any errors from fetching the version
if (standardOptions.isVerbose()) {
getConsoleOutput().writeError("Failed to fetch latest CLI Version:");
e.printStackTrace();
}
}
}
}
117 changes: 117 additions & 0 deletions cli/src/test/groovy/com/okta/cli/commands/BaseCommandTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2020-Present Okta, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.okta.cli.commands

import com.okta.cli.OktaCli
import com.okta.cli.common.model.Semver
import com.okta.cli.common.model.VersionInfo
import com.okta.cli.common.service.StartRestClient
import com.okta.cli.console.ConsoleOutput
import org.mockito.invocation.InvocationOnMock
import org.mockito.stubbing.Answer
import org.testng.annotations.Test

import java.time.Duration

import static org.hamcrest.MatcherAssert.assertThat
import static org.hamcrest.Matchers.containsString
import static org.hamcrest.Matchers.emptyString
import static org.mockito.Mockito.mock
import static org.mockito.Mockito.when

class BaseCommandTest {

@Test
void notifyNewVersionTest() {

def baos = new ByteArrayOutputStream()

def command = new StubCommand(baos)
command.call()

def output = baos.toString()
assertThat output, containsString("A new version of the Okta CLI is available: 1.2.3")
assertThat output, containsString("See what's new: https://example.com/release/1.2.3")
}

@Test
void failVersionFetch() {

def baos = new ByteArrayOutputStream()
def restClient = mock(StartRestClient)
when(restClient.getVersionInfo()).thenThrow(new RuntimeException("expected test exception"))

def command = new StubCommand(baos, restClient)
command.call()

def output = baos.toString()
assertThat output, emptyString()
}

@Test(timeOut = 4000l)
void versionTimeout() {

def baos = new ByteArrayOutputStream()
def restClient = mock(StartRestClient)
when(restClient.getVersionInfo()).thenThrow(new RuntimeException("expected test exception"))

def command = new StubCommand(baos, StubCommand.mockRestClient("1.2.3", Duration.ofSeconds(3)))
command.call()

def output = baos.toString()
assertThat "Expected version thread to timeout, the result is no version info is displayed to the user", output, emptyString()
}

static class StubCommand extends BaseCommand {

private final int exitCode
private final String currentVersion

StubCommand(ByteArrayOutputStream baos, StartRestClient restClient = mockRestClient("1.2.3", Duration.ofMillis(0)), OktaCli.StandardOptions standardOptions = new OktaCli.StandardOptions(), int exitCode = 0, String currentVersion = "1.0.1") {
super(restClient, standardOptions)
this.exitCode = exitCode
this.currentVersion = currentVersion

PrintStream printStream = new PrintStream(baos)
ConsoleOutput out = new ConsoleOutput.AnsiConsoleOutput(printStream, false)
getEnvironment().consoleOutput = out
}

@Override
protected int runCommand() throws Exception {
return exitCode
}

@Override
Semver getCurrentVersion() {
return Semver.parse(currentVersion)
}

static StartRestClient mockRestClient(String version, Duration delay) {
def restClient = mock(StartRestClient)
when(restClient.getVersionInfo()).thenAnswer(new Answer<VersionInfo>() {
@Override
VersionInfo answer(InvocationOnMock invocation) throws Throwable {
Thread.sleep(delay.toMillis())
return new VersionInfo()
.setLatestVersion(version)
.setLatestReleaseUrl("https://example.com/release/${version}")
}
})
return restClient
}
}
}
115 changes: 115 additions & 0 deletions common/src/main/java/com/okta/cli/common/model/Semver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright 2020-Present Okta, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.okta.cli.common.model;

import lombok.Data;

import java.util.Comparator;

/**
* A basic Semver class that represents a typical Maven style version {@code MAJOR.MINOR.PATCH[-SNAPSHOT][-QUALIFIER]} that is
* mapped to a Semver, where the optional {@code [-SNAPSHOT][-QUALIFIER]} is considered buildMetadata.
*
* This distinction is subtle, but _true_ semver is {@code MAJOR.MINOR.PATCH[-pre-release][+buildMetadata]}, the differences is the '{@code +}'.
*/
@Data
public class Semver implements Comparable<Semver> {

private final String version;

private final Integer major;
private final Integer minor;
private final Integer patch;
private final String buildMetadata;
private final boolean releaseBuild;

private Semver(String version, Integer major, Integer minor, Integer patch, String buildMetadata, boolean releaseBuild) {
this.version = version;
this.major = major;
this.minor = minor;
this.patch = patch;
this.buildMetadata = buildMetadata;
this.releaseBuild = releaseBuild;
}

@Override
public int compareTo(Semver other) {
return Comparator.comparing(Semver::getMajor, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Semver::getMinor, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Semver::getPatch, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Semver::isReleaseBuild, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Semver::getVersion, Comparator.nullsLast(Comparator.naturalOrder()))
.compare(this, other);
}

public boolean isGreaterThan(String otherVersion) {
return this.isGreaterThan(Semver.parse(otherVersion));
}

public boolean isGreaterThan(Semver other) {
return this.compareTo(other) > 0;
}

public static Semver parse(String version) {

// overly simple semver parser, split on '.', then grab the optional '-qualifier' from the last segment
// Using the "official" semver regex works, but it has a risk of a REDOS attack, i.e. it's really slow
String[] parts = version.split("\\.", 3);
String major = null;
String minor = null;
String patch = null;
boolean release = true;
String meta = null;

if (parts.length > 0) {
// split on the qualifier i.e. 1.2.[3-gitSha], or 1.2.3-SNAPSHOT-gitSha
String[] lastAndQual = parts[parts.length - 1].split("-", 2);

// qualifier
if (lastAndQual.length == 2) {
meta = lastAndQual[1];
}

// update the last part to just the simple value without the qualifier
parts[parts.length - 1] = lastAndQual[0];

major = parts[0];
if (parts.length > 1) {
minor = parts[1];
}

if (parts.length > 2) {
patch = parts[2];
}

if (version.contains("-SNAPSHOT")) {
release = false;
}
}
return new Semver(version, toInt(major), toInt(minor), toInt(patch), meta, release);
}

private static Integer toInt(String value) {
if (value == null) {
return null;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return null;
}
}
}
Loading