.NET & python SDKs parity for bad pulumi versions (#8297)

* .NET & python SDKs parity for bad pulumi versions

They handle invalid Pulumi CLI version gracefully.

* Make python version property lazy

* Clarify .NET logic

* Add python test for validate_pulumi_version

* Add tests for invalid versions

* Fix python test

* Fix typo

* Fix tests

* Have _validate_pulumi_version handle parsing

* Modify python and .NET to parseAndValidate

* Modify typescript and go to parseAndValidate

* fix name
This commit is contained in:
Ian Wahbe 2021-10-27 20:54:23 -07:00 committed by GitHub
parent 9a78ca1ca4
commit 83e24765f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 191 additions and 136 deletions

View file

@ -1508,17 +1508,21 @@ namespace Pulumi.Automation.Tests
[InlineData("2.21.1-alpha.1234", true, false)]
[InlineData("2.20.0", false, true)]
[InlineData("2.22.0", false, true)]
// Invalid version check
[InlineData("invalid", false, true)]
[InlineData("invalid", true, false)]
public void ValidVersionTheory(string currentVersion, bool errorExpected, bool optOut)
{
var testMinVersion = SemVersion.Parse("2.21.1");
if (errorExpected)
{
void ValidatePulumiVersion() => LocalWorkspace.ValidatePulumiVersion(testMinVersion, currentVersion, optOut);
void ValidatePulumiVersion() => LocalWorkspace.ParseAndValidatePulumiVersion(testMinVersion, currentVersion, optOut);
Assert.Throws<InvalidOperationException>(ValidatePulumiVersion);
}
else
{
LocalWorkspace.ValidatePulumiVersion(testMinVersion, currentVersion, optOut);
LocalWorkspace.ParseAndValidatePulumiVersion(testMinVersion, currentVersion, optOut);
}
}

View file

@ -264,6 +264,8 @@ namespace Pulumi.Automation
public static Task<WorkspaceStack> CreateOrSelectStackAsync(LocalProgramArgs args, CancellationToken cancellationToken)
=> CreateStackHelperAsync(args, WorkspaceStack.CreateOrSelectAsync, cancellationToken);
private static string SkipVersionCheckVar = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK";
private static async Task<WorkspaceStack> CreateStackHelperAsync(
InlineProgramArgs args,
Func<string, Workspace, CancellationToken, Task<WorkspaceStack>> initFunc,
@ -374,31 +376,35 @@ namespace Pulumi.Automation
var result = await this.RunCommandAsync(new[] { "version" }, cancellationToken).ConfigureAwait(false);
var versionString = result.StandardOutput.Trim();
versionString = versionString.TrimStart('v');
if (!SemVersion.TryParse(versionString, out var version))
{
throw new InvalidOperationException("Failed to get Pulumi version.");
}
var skipVersionCheckVar = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK";
var hasSkipEnvVar = this.EnvironmentVariables?.ContainsKey(skipVersionCheckVar) ?? false;
var optOut = hasSkipEnvVar || Environment.GetEnvironmentVariable(skipVersionCheckVar) != null;
ValidatePulumiVersion(_minimumVersion, version, optOut);
this._pulumiVersion = version;
var hasSkipEnvVar = this.EnvironmentVariables?.ContainsKey(SkipVersionCheckVar) ?? false;
var optOut = hasSkipEnvVar || Environment.GetEnvironmentVariable(SkipVersionCheckVar) != null;
this._pulumiVersion = ParseAndValidatePulumiVersion(_minimumVersion, versionString, optOut);
}
internal static void ValidatePulumiVersion(SemVersion minVersion, SemVersion currentVersion, bool optOut)
internal static SemVersion? ParseAndValidatePulumiVersion(SemVersion minVersion, string currentVersion, bool optOut)
{
if (!SemVersion.TryParse(currentVersion, out var version))
{
version = null;
}
if (optOut)
{
return;
return version;
}
if (minVersion.Major < currentVersion.Major)
if (version == null)
{
throw new InvalidOperationException($"Major version mismatch. You are using Pulumi CLI version {currentVersion} with Automation SDK v{minVersion.Major}. Please update the SDK.");
throw new InvalidOperationException("Failed to get Pulumi version. This is probably a pulumi error. You can override by version checking by setting {SkipVersionCheckVar}=true.");
}
if (minVersion > currentVersion)
if (minVersion.Major < version.Major)
{
throw new InvalidOperationException($"Minimum version requirement failed. The minimum CLI version requirement is {minVersion}, your current CLI version is {currentVersion}. Please update the Pulumi CLI.");
throw new InvalidOperationException($"Major version mismatch. You are using Pulumi CLI version {version} with Automation SDK v{minVersion.Major}. Please update the SDK.");
}
if (minVersion > version)
{
throw new InvalidOperationException($"Minimum version requirement failed. The minimum CLI version requirement is {minVersion}, your current CLI version is {version}. Please update the Pulumi CLI.");
}
return version;
}
/// <inheritdoc/>

View file

@ -506,30 +506,30 @@ func (l *LocalWorkspace) StackOutputs(ctx context.Context, stackName string) (Ou
return res, nil
}
func (l *LocalWorkspace) getPulumiVersion(ctx context.Context) (semver.Version, error) {
func (l *LocalWorkspace) getPulumiVersion(ctx context.Context) (string, error) {
stdout, stderr, errCode, err := l.runPulumiCmdSync(ctx, "version")
if err != nil {
return semver.Version{}, newAutoError(errors.Wrap(err, "could not determine pulumi version"), stdout, stderr, errCode)
return "", newAutoError(errors.Wrap(err, "could not determine pulumi version"), stdout, stderr, errCode)
}
version, err := semver.ParseTolerant(stdout)
if err != nil {
return semver.Version{}, newAutoError(errors.Wrap(err, "could not determine pulumi version"), stdout, stderr, errCode)
}
return version, nil
return stdout, nil
}
//nolint:lll
func validatePulumiVersion(minVersion semver.Version, currentVersion semver.Version, optOut bool) error {
func parseAndValidatePulumiVersion(minVersion semver.Version, currentVersion string, optOut bool) (semver.Version, error) {
version, err := semver.ParseTolerant(currentVersion)
if err != nil && !optOut {
return semver.Version{}, errors.Wrapf(err, "Unable to parse Pulumi CLI version (skip with %s=true)", skipVersionCheckVar)
}
if optOut {
return nil
return version, nil
}
if minVersion.Major < currentVersion.Major {
return errors.Errorf("Major version mismatch. You are using Pulumi CLI version %s with Automation SDK v%v. Please update the SDK.", currentVersion, minVersion.Major)
if minVersion.Major < version.Major {
return semver.Version{}, errors.Errorf("Major version mismatch. You are using Pulumi CLI version %s with Automation SDK v%v. Please update the SDK.", currentVersion, minVersion.Major)
}
if minVersion.GT(currentVersion) {
return errors.Errorf("Minimum version requirement failed. The minimum CLI version requirement is %s, your current CLI version is %s. Please update the Pulumi CLI.", minimumVersion, currentVersion)
if minVersion.GT(version) {
return semver.Version{}, errors.Errorf("Minimum version requirement failed. The minimum CLI version requirement is %s, your current CLI version is %s. Please update the Pulumi CLI.", minimumVersion, currentVersion)
}
return nil
return version, nil
}
func (l *LocalWorkspace) runPulumiCmdSync(
@ -597,19 +597,12 @@ func NewLocalWorkspace(ctx context.Context, opts ...LocalWorkspaceOption) (Works
if val, ok := lwOpts.EnvVars[skipVersionCheckVar]; ok {
optOut = optOut || cmdutil.IsTruthy(val)
}
v, err := l.getPulumiVersion(ctx)
if err == nil {
l.pulumiVersion = v
currentVersion, err := l.getPulumiVersion(ctx)
if err != nil {
return nil, err
}
if !optOut {
if err != nil {
return nil, errors.Wrapf(err,
"failed to create workspace, unable to get pulumi version (skip with %s=true)", skipVersionCheckVar)
}
if err = validatePulumiVersion(minimumVersion, l.pulumiVersion, optOut); err != nil {
return nil, err
}
if l.pulumiVersion, err = parseAndValidatePulumiVersion(minimumVersion, currentVersion, optOut); err != nil {
return nil, err
}
if lwOpts.Project != nil {

View file

@ -1,4 +1,4 @@
// Copyright 2016-2020, Pulumi Corporation.
// Copyright 2016-2021, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -1507,72 +1507,87 @@ func TestPulumiVersion(t *testing.T) {
assert.Regexp(t, `(\d+\.)(\d+\.)(\d+)(-.*)?`, version)
}
const PARSE = `Unable to parse`
const MAJOR = `Major version mismatch.`
const MINIMUM = `Minimum version requirement failed.`
var minVersionTests = []struct {
name string
currentVersion semver.Version
expectError bool
currentVersion string
expectedError string
optOut bool
}{
{
"higher_major",
semver.Version{Major: 100, Minor: 0, Patch: 0},
true,
"100.0.0",
MAJOR,
false,
},
{
"lower_major",
semver.Version{Major: 1, Minor: 0, Patch: 0},
true,
"1.0.0",
MINIMUM,
false,
},
{
"higher_minor",
semver.Version{Major: 2, Minor: 22, Patch: 0},
false,
"2.2.0",
MINIMUM,
false,
},
{
"lower_minor",
semver.Version{Major: 2, Minor: 1, Patch: 0},
true,
"2.1.0",
MINIMUM,
false,
},
{
"equal_minor_higher_patch",
semver.Version{Major: 2, Minor: 21, Patch: 2},
false,
"2.2.2",
MINIMUM,
false,
},
{
"equal_minor_equal_patch",
semver.Version{Major: 2, Minor: 21, Patch: 1},
false,
"2.2.1",
MINIMUM,
false,
},
{
"equal_minor_lower_patch",
semver.Version{Major: 2, Minor: 21, Patch: 0},
true,
"2.2.0",
MINIMUM,
false,
},
{
"equal_minor_equal_patch_prerelease",
// Note that prerelease < release so this case will error
semver.Version{Major: 2, Minor: 21, Patch: 1,
Pre: []semver.PRVersion{{VersionStr: "alpha"}, {VersionNum: 1234, IsNum: true}}},
true,
"2.21.1-alpha.1234",
MINIMUM,
false,
},
{
"opt_out_of_check_would_fail_otherwise",
semver.Version{Major: 2, Minor: 20, Patch: 0},
false,
"2.2.0",
"",
true,
},
{
"opt_out_of_check_would_succeed_otherwise",
semver.Version{Major: 2, Minor: 22, Patch: 0},
"2.2.0",
"",
true,
},
{
"unparsable_version",
"invalid",
PARSE,
false,
},
{
"opt_out_unparsable_version",
"invalid",
"",
true,
},
}
@ -1582,15 +1597,11 @@ func TestMinimumVersion(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
minVersion := semver.Version{Major: 2, Minor: 21, Patch: 1}
err := validatePulumiVersion(minVersion, tt.currentVersion, tt.optOut)
_, err := parseAndValidatePulumiVersion(minVersion, tt.currentVersion, tt.optOut)
if tt.expectError {
if tt.expectedError != "" {
assert.Error(t, err)
if minVersion.Major < tt.currentVersion.Major {
assert.Regexp(t, `Major version mismatch.`, err.Error())
} else {
assert.Regexp(t, `Minimum version requirement failed.`, err.Error())
}
assert.Regexp(t, tt.expectedError, err.Error())
} else {
assert.Nil(t, err)
}

View file

@ -592,9 +592,8 @@ export class LocalWorkspace implements Workspace {
}
private async getPulumiVersion(minVersion: semver.SemVer) {
const result = await this.runPulumiCmd(["version"]);
const version = semver.parse(result.stdout.trim());
const optOut = !!this.envVars[SKIP_VERSION_CHECK_VAR] || !!process.env[SKIP_VERSION_CHECK_VAR];
validatePulumiVersion(minVersion, version, optOut);
const version = parseAndValidatePulumiVersion(minVersion, result.stdout.trim(), optOut);
if (version != null) {
this._pulumiVersion = version;
}
@ -732,17 +731,19 @@ function loadProjectSettings(workDir: string) {
* @param currentVersion The currently known version. `null` indicates that the current version is unknown.
* @paramoptOut If the user has opted out of the version check.
*/
export function validatePulumiVersion(minVersion: semver.SemVer, currentVersion: semver.SemVer | null, optOut: boolean) {
export function parseAndValidatePulumiVersion(minVersion: semver.SemVer, currentVersion: string, optOut: boolean): semver.SemVer | null {
const version = semver.parse(currentVersion);
if (optOut) {
return;
return version;
}
if (currentVersion == null) {
if (version == null) {
throw new Error(`Failed to parse Pulumi CLI version. This is probably an internal error. You can override this by setting "${SKIP_VERSION_CHECK_VAR}" to "true".`);
}
if (minVersion.major < currentVersion.major) {
if (minVersion.major < version.major) {
throw new Error(`Major version mismatch. You are using Pulumi CLI version ${currentVersion.toString()} with Automation SDK v${minVersion.major}. Please update the SDK.`);
}
if (minVersion.compare(currentVersion) === 1) {
if (minVersion.compare(version) === 1) {
throw new Error(`Minimum version requirement failed. The minimum CLI version requirement is ${minVersion.toString()}, your current CLI version is ${currentVersion.toString()}. Please update the Pulumi CLI.`);
}
return version;
}

View file

@ -1,4 +1,4 @@
// Copyright 2016-2020, Pulumi Corporation.
// Copyright 2016-2021, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -24,7 +24,7 @@ import {
OutputMap,
ProjectSettings,
Stack,
validatePulumiVersion,
parseAndValidatePulumiVersion,
} from "../../automation";
import { Config, output } from "../../index";
import { asyncTest } from "../util";
@ -502,7 +502,7 @@ describe("LocalWorkspace", () => {
await stack.workspace.removeStack(stackName);
}));
it(`imports and exports stacks`, asyncTest(async() => {
it(`imports and exports stacks`, asyncTest(async () => {
const program = async () => {
const config = new Config();
return {
@ -647,8 +647,8 @@ describe("LocalWorkspace", () => {
const projectName = "correct_project";
const stackName = fullyQualifiedStackName(getTestOrg(), projectName, `int_test${getTestSuffix()}`);
const stack = await LocalWorkspace.createStack(
{stackName, projectName, program: async() => { return; }},
{workDir: upath.joinSafe(__dirname, "data", "correct_project")},
{ stackName, projectName, program: async () => { return; } },
{ workDir: upath.joinSafe(__dirname, "data", "correct_project") },
);
const projectSettings = await stack.workspace.projectSettings();
assert.strictEqual(projectSettings.name, "correct_project");
@ -658,7 +658,7 @@ describe("LocalWorkspace", () => {
}));
it(`correctly sets config on multiple stacks concurrently`, asyncTest(async () => {
const dones = [];
const stacks = [ "dev", "dev2", "dev3", "dev4", "dev5" ];
const stacks = ["dev", "dev2", "dev3", "dev4", "dev5"];
const workDir = upath.joinSafe(__dirname, "data", "tcfg");
const ws = await LocalWorkspace.create({
workDir,
@ -679,7 +679,7 @@ describe("LocalWorkspace", () => {
const s = stacks[i];
dones.push((async () => {
for (let j = 0; j < 20; j++) {
await ws.setConfig(s, "var-" + j, { value: ((x*20)+j).toString()});
await ws.setConfig(s, "var-" + j, { value: ((x * 20) + j).toString() });
}
})());
}
@ -697,84 +697,95 @@ describe("LocalWorkspace", () => {
}));
});
const MAJOR = /Major version mismatch./;
const MINIMUM = /Minimum version requirement failed./;
const PARSE = /Failed to parse/;
describe(`checkVersionIsValid`, () => {
const versionTests = [
{
name: "higher_major",
currentVersion: "100.0.0",
expectError: true,
expectError: MAJOR,
optOut: false,
},
{
name: "lower_major",
currentVersion: "1.0.0",
expectError: true,
expectError: MINIMUM,
optOut: false,
},
{
name: "higher_minor",
currentVersion: "v2.22.0",
expectError: false,
expectError: null,
optOut: false,
},
{
name: "lower_minor",
currentVersion: "v2.1.0",
expectError: true,
expectError: MINIMUM,
optOut: false,
},
{
name: "equal_minor_higher_patch",
currentVersion: "v2.21.2",
expectError: false,
expectError: null,
optOut: false,
},
{
name: "equal_minor_equal_patch",
currentVersion: "v2.21.1",
expectError: false,
expectError: null,
optOut: false,
},
{
name: "equal_minor_lower_patch",
currentVersion: "v2.21.0",
expectError: true,
expectError: MINIMUM,
optOut: false,
},
{
name: "equal_minor_equal_patch_prerelease",
// Note that prerelease < release so this case will error
currentVersion: "v2.21.1-alpha.1234",
expectError: true,
expectError: MINIMUM,
optOut: false,
},
{
name: "opt_out_of_check_would_fail_otherwise",
currentVersion: "v2.20.0",
expectError: false,
expectError: null,
optOut: true,
},
{
name: "opt_out_of_check_would_succeed_otherwise",
currentVersion: "v2.22.0",
expectError: false,
expectError: null,
optOut: true,
},
{
name: "invalid_version",
currentVersion: "invalid",
expectError: PARSE,
optOut: false,
},
{
name: "invalid_version_opt_out",
currentVersion: "invalid",
expectError: null,
optOut: true,
},
];
const minVersion = new semver.SemVer("v2.21.1");
versionTests.forEach(test => {
it(`validates ${test.currentVersion}`, () => {
const currentVersion = new semver.SemVer(test.currentVersion);
it(`validates ${test.name} (${test.currentVersion})`, () => {
const validate = () => parseAndValidatePulumiVersion(minVersion, test.currentVersion, test.optOut);
if (test.expectError) {
if (minVersion.major < currentVersion.major) {
assert.throws(() => validatePulumiVersion(minVersion, currentVersion, test.optOut), /Major version mismatch./);
} else {
assert.throws(() => validatePulumiVersion(minVersion, currentVersion, test.optOut), /Minimum version requirement failed./);
}
assert.throws(validate, test.expectError);
} else {
assert.doesNotThrow(() => validatePulumiVersion(minVersion, currentVersion, test.optOut));
assert.doesNotThrow(validate);
}
});
});

View file

@ -91,8 +91,8 @@ class LocalWorkspace(Workspace):
opt_out = os.getenv(_SKIP_VERSION_CHECK_VAR) is not None
if env_vars:
opt_out = opt_out or env_vars.get(_SKIP_VERSION_CHECK_VAR) is not None
_validate_pulumi_version(_MINIMUM_VERSION, pulumi_version, opt_out)
self.pulumi_version = str(pulumi_version)
version = _parse_and_validate_pulumi_version(_MINIMUM_VERSION, pulumi_version, opt_out)
self.__pulumi_version = str(version) if version else None
if project_settings:
self.save_project_settings(project_settings)
@ -100,6 +100,17 @@ class LocalWorkspace(Workspace):
for key in stack_settings:
self.save_stack_settings(key, stack_settings[key])
# mypy does not support properties: https://github.com/python/mypy/issues/1362
@property # type: ignore
def pulumi_version(self) -> str: # type: ignore
if self.__pulumi_version:
return self.__pulumi_version
raise InvalidVersionError("Could not get Pulumi CLI version")
@pulumi_version.setter # type: ignore
def pulumi_version(self, v: str):
self.__pulumi_version = v
def __repr__(self):
return f"{self.__class__.__name__}(work_dir={self.work_dir!r}, " \
f"program={self.program.__name__ if self.program else None}, " \
@ -288,12 +299,12 @@ class LocalWorkspace(Workspace):
outputs[key] = OutputValue(value=plaintext_outputs[key], secret=secret)
return outputs
def _get_pulumi_version(self) -> VersionInfo:
def _get_pulumi_version(self) -> str:
result = self._run_pulumi_cmd_sync(["version"])
version_string = result.stdout.strip()
if version_string[0] == "v":
version_string = version_string[1:]
return VersionInfo.parse(version_string)
return version_string
def _run_pulumi_cmd_sync(self, args: List[str], on_output: Optional[OnOutput] = None) -> CommandResult:
envs = {"PULUMI_HOME": self.pulumi_home} if self.pulumi_home else {}
@ -474,16 +485,31 @@ def get_stack_settings_name(name: str) -> str:
return parts[-1]
def _validate_pulumi_version(min_version: VersionInfo, current_version: VersionInfo, opt_out: bool):
def _parse_and_validate_pulumi_version(min_version: VersionInfo,
current_version: str,
opt_out: bool) -> Optional[VersionInfo]:
"""
Parse and return a version. An error is raised if the version is not
valid. If *current_version* is not a valid version but *opt_out* is true,
*None* is returned.
"""
try:
version: Optional[VersionInfo] = VersionInfo.parse(current_version)
except ValueError:
version = None
if opt_out:
return
if min_version.major < current_version.major:
raise InvalidVersionError(f"Major version mismatch. You are using Pulumi CLI version {current_version} with "
return version
if version is None:
raise InvalidVersionError(f"Could not parse the Pulumi CLI version. This is probably an internal error. "
f"If you are sure you have the correct version, set {_SKIP_VERSION_CHECK_VAR}=true.")
if min_version.major < version.major:
raise InvalidVersionError(f"Major version mismatch. You are using Pulumi CLI version {version} with "
f"Automation SDK v{min_version.major}. Please update the SDK.")
if min_version.compare(current_version) == 1:
if min_version.compare(version) == 1:
raise InvalidVersionError(f"Minimum version requirement failed. The minimum CLI version requirement is "
f"{min_version}, your current CLI version is {current_version}. "
f"{min_version}, your current CLI version is {version}. "
f"Please update the Pulumi CLI.")
return version
def _load_project_settings(work_dir: str) -> ProjectSettings:

View file

@ -38,23 +38,30 @@ from pulumi.automation import (
StackAlreadyExistsError,
fully_qualified_stack_name,
)
from pulumi.automation._local_workspace import _validate_pulumi_version
from pulumi.automation._local_workspace import _parse_and_validate_pulumi_version
extensions = ["json", "yaml", "yml"]
MAJOR = "Major version mismatch."
MINIMAL = "Minimum version requirement failed."
PARSE = "Could not parse the Pulumi CLI"
version_tests = [
("100.0.0", True, False),
("1.0.0", True, False),
("2.22.0", False, False),
("2.1.0", True, False),
("2.21.2", False, False),
("2.21.1", False, False),
("2.21.0", True, False),
# current_version, expected_error regex, opt_out
("100.0.0", MAJOR, False),
("1.0.0", MINIMAL, False),
("2.22.0", None, False),
("2.1.0", MINIMAL, False),
("2.21.2", None, False),
("2.21.1", None, False),
("2.21.0", MINIMAL, False),
# Note that prerelease < release so this case will error
("2.21.1-alpha.1234", True, False),
("2.21.1-alpha.1234", MINIMAL, False),
# Test opting out of version check
("2.20.0", False, True),
("2.22.0", False, True)
("2.20.0", None, True),
("2.22.0", None, True),
# Test invalid version
("invalid", PARSE, False),
("invalid", None, True),
]
test_min_version = VersionInfo.parse("2.21.1")
@ -454,21 +461,17 @@ class TestLocalWorkspace(unittest.TestCase):
self.assertRegex(ws.pulumi_version, r"(\d+\.)(\d+\.)(\d+)(-.*)?")
def test_validate_pulumi_version(self):
for current_version, expect_error, opt_out in version_tests:
for current_version, expected_error, opt_out in version_tests:
with self.subTest():
current_version = VersionInfo.parse(current_version)
if expect_error:
error_regex = "Major version mismatch." \
if test_min_version.major < current_version.major \
else "Minimum version requirement failed."
if expected_error:
with self.assertRaisesRegex(
InvalidVersionError,
error_regex,
expected_error,
msg=f"min_version:{test_min_version}, current_version:{current_version}"
):
_validate_pulumi_version(test_min_version, current_version, opt_out)
_parse_and_validate_pulumi_version(test_min_version, current_version, opt_out)
else:
self.assertIsNone(_validate_pulumi_version(test_min_version, current_version, opt_out))
_parse_and_validate_pulumi_version(test_min_version, current_version, opt_out)
def test_project_settings_respected(self):
project_name = "correct_project"