diff --git a/cli/src/main/java/com/okta/cli/OktaCli.java b/cli/src/main/java/com/okta/cli/OktaCli.java index dd4c2c29..b39e539c 100644 --- a/cli/src/main/java/com/okta/cli/OktaCli.java +++ b/cli/src/main/java/com/okta/cli/OktaCli.java @@ -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; @@ -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 }; } } } diff --git a/cli/src/main/java/com/okta/cli/commands/BaseCommand.java b/cli/src/main/java/com/okta/cli/commands/BaseCommand.java index 9dbd62b6..e1639a07 100644 --- a/cli/src/main/java/com/okta/cli/commands/BaseCommand.java +++ b/cli/src/main/java/com/okta/cli/commands/BaseCommand.java @@ -17,20 +17,44 @@ 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 { @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; } @@ -38,7 +62,17 @@ public BaseCommand(OktaCli.StandardOptions standardOptions) { @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> 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() { @@ -56,4 +90,45 @@ protected Prompter getPrompter() { protected Environment getEnvironment() { return standardOptions.getEnvironment(); } + + private Future> asyncVersionInfo(Semver currentVersion) { + + Callable> 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> 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(); + } + } + } } diff --git a/cli/src/test/groovy/com/okta/cli/commands/BaseCommandTest.groovy b/cli/src/test/groovy/com/okta/cli/commands/BaseCommandTest.groovy new file mode 100644 index 00000000..3a0fe34d --- /dev/null +++ b/cli/src/test/groovy/com/okta/cli/commands/BaseCommandTest.groovy @@ -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() { + @Override + VersionInfo answer(InvocationOnMock invocation) throws Throwable { + Thread.sleep(delay.toMillis()) + return new VersionInfo() + .setLatestVersion(version) + .setLatestReleaseUrl("https://example.com/release/${version}") + } + }) + return restClient + } + } +} \ No newline at end of file diff --git a/common/src/main/java/com/okta/cli/common/model/Semver.java b/common/src/main/java/com/okta/cli/common/model/Semver.java new file mode 100644 index 00000000..08062e2c --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/model/Semver.java @@ -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 { + + 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; + } + } +} diff --git a/common/src/test/groovy/com/okta/cli/common/model/SemverTest.groovy b/common/src/test/groovy/com/okta/cli/common/model/SemverTest.groovy new file mode 100644 index 00000000..28dee598 --- /dev/null +++ b/common/src/test/groovy/com/okta/cli/common/model/SemverTest.groovy @@ -0,0 +1,80 @@ +/* + * 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 org.testng.annotations.DataProvider +import org.testng.annotations.Test + +import static org.hamcrest.MatcherAssert.assertThat +import static org.hamcrest.Matchers.equalTo + +class SemverTest { + + @Test + void parseTest() { + // equals method generated by lombok + assertThat Semver.parse("1"), equalTo(new Semver("1", 1, null, null, null, true)) + assertThat Semver.parse("1.2"), equalTo(new Semver("1.2", 1, 2, null, null, true)) + assertThat Semver.parse("1.0.3"), equalTo(new Semver("1.0.3", 1, 0, 3, null, true)) + assertThat Semver.parse("1.0.3-SNAPSHOT"), equalTo(new Semver("1.0.3-SNAPSHOT", 1, 0, 3, "SNAPSHOT", false)) + assertThat Semver.parse("1.0.3-SNAPSHOT-foo"), equalTo(new Semver("1.0.3-SNAPSHOT-foo", 1, 0, 3, "SNAPSHOT-foo", false)) + assertThat Semver.parse("0.10.14-qualifier-goes-here"), equalTo(new Semver("0.10.14-qualifier-goes-here", 0, 10, 14, "qualifier-goes-here", true)) + + assertThat Semver.parse("a.b.c"), equalTo(new Semver("a.b.c", null, null, null, null, true)) + } + + @Test(dataProvider = "greaterThan") + void greaterThanTest(String versionA, String versionB) { + assertGreaterThan versionA, versionB + } + + @Test(dataProvider = "lessThanOrEqual") + void lessThanOrEqualDataTest(String versionA, String versionB) { + assertLessThanOrEqual versionA, versionB + } + + private void assertLessThanOrEqual(String a, String b) { + assertThat "Expected version `${a}` to be less than or equal toversion '${b}'", !Semver.parse(a).isGreaterThan(b) + } + + private void assertGreaterThan(String a, String b) { + assertThat "Expected version `${a}` to be greater than version '${b}'", Semver.parse(a).isGreaterThan(b) + } + + @DataProvider(name = "greaterThan") + Object[][] greaterThanData() { + return [ + ["1.2.3", "1.2.2"], + ["1.2.3", "1.2.3-SNAPSHOT"], + ["1.2.3", "0.2.2"], + ["2", "1.0"], + ["2.0", "1.0.0"], + ["10.0", "1.0.0"], + ["1.2.3", "0.2.2"], + ] + } + + @DataProvider(name = "lessThanOrEqual") + Object[][] lessThanOrEqualData() { + return [ + ["1.2.2", "1.2.3"], + ["1.0.0", "2"], + ["1.10.0", "2.2"], + ["1.2.3-SNAPSHOT", "1.2.3"], + ["0.2.2", "1.2.3"], + ] + } +}