From 2f1d3fe07e1ff8e20e3977604164b61e2d87d01c Mon Sep 17 00:00:00 2001 From: Justin Van Patten Date: Tue, 8 Nov 2022 13:16:22 -0800 Subject: [PATCH 1/2] [cli] Test remote operations Also some changes to validate flags earlier (before getting the backend). --- pkg/cmd/pulumi/util_remote.go | 73 +++++++++-------- tests/remote_test.go | 150 ++++++++++++++++++++++++++++++++++ tests/stack_test.go | 33 ++++++++ 3 files changed, 224 insertions(+), 32 deletions(-) create mode 100644 tests/remote_test.go diff --git a/pkg/cmd/pulumi/util_remote.go b/pkg/cmd/pulumi/util_remote.go index eae1a5b3a617..5be62c2ae45b 100644 --- a/pkg/cmd/pulumi/util_remote.go +++ b/pkg/cmd/pulumi/util_remote.go @@ -215,33 +215,22 @@ func (r *RemoteArgs) applyFlags(cmd *cobra.Command) { func runDeployment(ctx context.Context, opts display.Options, operation apitype.PulumiOperation, stack, url string, args RemoteArgs) result.Result { - b, err := currentBackend(ctx, opts) - if err != nil { - return result.FromError(err) - } - - // Ensure the cloud backend is being used. - cb, isCloud := b.(httpstate.Backend) - if !isCloud { - return result.FromError(errors.New("the Pulumi service backend must be used for remote operations; " + - "use `pulumi login` without arguments to log into the Pulumi service backend")) - } - - stackRef, err := b.ParseStackReference(stack) - if err != nil { - return result.FromError(err) - } - + // Validate args. if url == "" { return result.FromError(errors.New("the url arg must be specified")) } - if args.gitCommit != "" && args.gitBranch != "" { + if args.gitBranch != "" && args.gitCommit != "" { return result.FromError(errors.New("`--remote-git-branch` and `--remote-git-commit` cannot both be specified")) } - if args.gitCommit == "" && args.gitBranch == "" { + if args.gitBranch == "" && args.gitCommit == "" { return result.FromError(errors.New("either `--remote-git-branch` or `--remote-git-commit` is required")) } + if args.gitAuthSSHPrivateKey != "" && args.gitAuthSSHPrivateKeyPath != "" { + return result.FromError(errors.New("`--remote-git-auth-ssh-private-key` and " + + "`--remote-git-auth-ssh-private-key-path` cannot both be specified")) + } + // Parse and validate the environment args. env := map[string]apitype.SecretValue{} for i, e := range append(args.envVars, args.secretEnvVars...) { name, value, err := parseEnv(e) @@ -254,26 +243,46 @@ func runDeployment(ctx context.Context, opts display.Options, operation apitype. } } + // Read the SSH Private Key from the path, if necessary. + sshPrivateKey := args.gitAuthSSHPrivateKey + if args.gitAuthSSHPrivateKeyPath != "" { + key, err := os.ReadFile(args.gitAuthSSHPrivateKeyPath) + if err != nil { + return result.FromError(fmt.Errorf( + "reading SSH private key path %q: %w", args.gitAuthSSHPrivateKeyPath, err)) + } + sshPrivateKey = string(key) + } + + b, err := currentBackend(ctx, opts) + if err != nil { + return result.FromError(err) + } + + // Ensure the cloud backend is being used. + cb, isCloud := b.(httpstate.Backend) + if !isCloud { + return result.FromError(errors.New("the Pulumi service backend must be used for remote operations; " + + "use `pulumi login` without arguments to log into the Pulumi service backend")) + } + + stackRef, err := b.ParseStackReference(stack) + if err != nil { + return result.FromError(err) + } + var gitAuth *apitype.GitAuthConfig - if args.gitAuthAccessToken != "" || args.gitAuthSSHPrivateKey != "" || args.gitAuthSSHPrivateKeyPath != "" || - args.gitAuthPassword != "" || args.gitAuthUsername != "" { + if args.gitAuthAccessToken != "" || sshPrivateKey != "" || args.gitAuthPassword != "" || + args.gitAuthUsername != "" { gitAuth = &apitype.GitAuthConfig{} switch { case args.gitAuthAccessToken != "": gitAuth.PersonalAccessToken = &apitype.SecretValue{Value: args.gitAuthAccessToken, Secret: true} - case args.gitAuthSSHPrivateKey != "" || args.gitAuthSSHPrivateKeyPath != "": - sshAuth := &apitype.SSHAuth{} - if args.gitAuthSSHPrivateKeyPath != "" { - content, err := os.ReadFile(args.gitAuthSSHPrivateKeyPath) - if err != nil { - return result.FromError(fmt.Errorf( - "reading SSH private key path %q: %w", args.gitAuthSSHPrivateKeyPath, err)) - } - sshAuth.SSHPrivateKey = apitype.SecretValue{Value: string(content), Secret: true} - } else { - sshAuth.SSHPrivateKey = apitype.SecretValue{Value: args.gitAuthSSHPrivateKey, Secret: true} + case sshPrivateKey != "": + sshAuth := &apitype.SSHAuth{ + SSHPrivateKey: apitype.SecretValue{Value: sshPrivateKey, Secret: true}, } if args.gitAuthPassword != "" { sshAuth.Password = &apitype.SecretValue{Value: args.gitAuthPassword, Secret: true} diff --git a/tests/remote_test.go b/tests/remote_test.go new file mode 100644 index 000000000000..3e74ff7e9431 --- /dev/null +++ b/tests/remote_test.go @@ -0,0 +1,150 @@ +// Copyright 2016-2022, Pulumi Corporation. +// +// 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 tests + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + ptesting "github.com/pulumi/pulumi/sdk/v3/go/common/testing" +) + +const remoteTestRepo = "https://github.com/pulumi/test-repo.git" + +func TestInvalidRemoteFlags(t *testing.T) { + t.Parallel() + + commands := []string{"preview", "up", "refresh", "destroy"} + + tests := map[string]struct { + args []string + err string + }{ + "no url": { + err: "error: must specify remote URL", + }, + "no branch or commit": { + args: []string{remoteTestRepo}, + err: "error: either `--remote-git-branch` or `--remote-git-commit` is required", + }, + "both branch and commit": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-git-commit", "commit"}, + err: "error: `--remote-git-branch` and `--remote-git-commit` cannot both be specified", + }, + "both ssh private key and path": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-git-auth-ssh-private-key", "key", + "--remote-git-auth-ssh-private-key-path", "path"}, + err: "error: `--remote-git-auth-ssh-private-key` and `--remote-git-auth-ssh-private-key-path` " + + "cannot both be specified", + }, + "ssh private key path doesn't exist": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-git-auth-ssh-private-key-path", + "doesntexist"}, + err: "error: reading SSH private key path", + }, + "invalid env": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-env", "invalid"}, + err: `expected value of the form "NAME=value": missing "=" in "invalid"`, + }, + "empty env name": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-env", "=value"}, + err: `error: expected non-empty environment name in "=value"`, + }, + "invalid secret env": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-env-secret", "blah"}, + err: `expected value of the form "NAME=value": missing "=" in "blah"`, + }, + "empty secret env name": { + args: []string{remoteTestRepo, "--remote-git-branch", "branch", "--remote-env-secret", "=value"}, + err: `error: expected non-empty environment name in "=value"`, + }, + } + + for _, command := range commands { + command := command + for name, tc := range tests { + tc := tc + t.Run(command+"_"+name, func(t *testing.T) { + t.Parallel() + + e := ptesting.NewEnvironment(t) + defer deleteIfNotFailed(e) + + // Remote flags currently require PULUMI_EXPERIMENTAL. + e.Env = append(e.Env, "PULUMI_EXPERIMENTAL=true") + + args := []string{command, "--remote"} + _, err := e.RunCommandExpectError("pulumi", append(args, tc.args...)...) + assert.NotEmpty(t, tc.err) + assert.Contains(t, err, tc.err) + }) + } + } +} + +func TestRemoteLifecycle(t *testing.T) { + // This test requires the service with access to Pulumi Deployments. + // Set PULUMI_ACCESS_TOKEN to an access token with access to Pulumi Deployments, + // set PULUMI_TEST_OWNER to the organization to use for the fully qualified stack, + // and set PULUMI_TEST_DEPLOYMENTS_API to any value to enable the test. + if os.Getenv("PULUMI_ACCESS_TOKEN") == "" { + t.Skipf("Skipping: PULUMI_ACCESS_TOKEN is not set") + } + if os.Getenv("PULUMI_TEST_OWNER") == "" { + t.Skipf("Skipping: PULUMI_TEST_OWNER is not set") + } + if os.Getenv("PULUMI_TEST_DEPLOYMENTS_API") == "" { + t.Skipf("Skipping: PULUMI_TEST_DEPLOYMENTS_API is not set") + } + t.Parallel() + + e := ptesting.NewEnvironment(t) + defer deleteIfNotFailed(e) + + // Remote flags currently require PULUMI_EXPERIMENTAL. + e.Env = append(e.Env, "PULUMI_EXPERIMENTAL=true") + + randomSuffix := func() string { + b := make([]byte, 4) + _, err := rand.Read(b) + assert.NoError(t, err) + return hex.EncodeToString(b) + } + + owner := os.Getenv("PULUMI_TEST_OWNER") + proj := "go_remote_proj" + stack := strings.ToLower("p-t-remotelifecycle-" + randomSuffix()) + fullyQualifiedStack := fmt.Sprintf("%s/%s/%s", owner, proj, stack) + + e.RunCommand("pulumi", "stack", "init", "--no-select", "--stack", fullyQualifiedStack) + + args := func(command string) []string { + return []string{command, remoteTestRepo, "--stack", fullyQualifiedStack, + "--remote", "--remote-git-branch", "refs/heads/master", "--remote-git-repo-dir", "goproj"} + } + + e.RunCommand("pulumi", args("preview")...) + e.RunCommand("pulumi", args("up")...) + e.RunCommand("pulumi", args("refresh")...) + e.RunCommand("pulumi", args("destroy")...) + + e.RunCommand("pulumi", "stack", "rm", "--stack", fullyQualifiedStack, "--yes") +} diff --git a/tests/stack_test.go b/tests/stack_test.go index 611693934877..aa076188fb0b 100644 --- a/tests/stack_test.go +++ b/tests/stack_test.go @@ -114,6 +114,39 @@ func TestStackCommands(t *testing.T) { e.RunCommand("pulumi", "stack", "rm", "--yes") }) + t.Run("StackInitNoSelect", func(t *testing.T) { + t.Parallel() + + e := ptesting.NewEnvironment(t) + defer deleteIfNotFailed(e) + + integration.CreateBasicPulumiRepo(e) + e.SetBackend(e.LocalURL()) + e.RunCommand("pulumi", "stack", "init", "first") + e.RunCommand("pulumi", "stack", "init", "second") + + // Last one created is always selected. + stacks, current := integration.GetStacks(e) + if current == nil { + t.Fatalf("No stack was labeled as current among: %v", stacks) + } + assert.Equal(t, "second", *current) + + // Specifying `--no-select` prevents selection. + e.RunCommand("pulumi", "stack", "init", "third", "--no-select") + stacks, current = integration.GetStacks(e) + if current == nil { + t.Fatalf("No stack was labeled as current among: %v", stacks) + } + // "second" should still be selected. + assert.Equal(t, "second", *current) + + assert.Equal(t, 3, len(stacks)) + assert.Contains(t, stacks, "first") + assert.Contains(t, stacks, "second") + assert.Contains(t, stacks, "third") + }) + t.Run("StackUnselect", func(t *testing.T) { t.Parallel() From 81d1d03d30d53de2e533f50f587da26433140715 Mon Sep 17 00:00:00 2001 From: Justin Van Patten Date: Tue, 8 Nov 2022 13:28:55 -0800 Subject: [PATCH 2/2] [auto/python] Test remote operations Also cleans up some error messages to be consistent with the CLI and other languages. --- .../pulumi/automation/_remote_workspace.py | 18 +-- sdk/python/lib/pulumi/automation/_stack.py | 10 +- .../test/automation/test_local_workspace.py | 19 +--- .../test/automation/test_remote_workspace.py | 105 ++++++++++++++++++ sdk/python/lib/test/automation/test_utils.py | 33 ++++++ 5 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 sdk/python/lib/test/automation/test_utils.py diff --git a/sdk/python/lib/pulumi/automation/_remote_workspace.py b/sdk/python/lib/pulumi/automation/_remote_workspace.py index 53cbe58289d3..3765a3507242 100644 --- a/sdk/python/lib/pulumi/automation/_remote_workspace.py +++ b/sdk/python/lib/pulumi/automation/_remote_workspace.py @@ -104,7 +104,7 @@ def create_remote_stack_git_source( Pulumi operations on the stack (Preview, Update, Refresh, and Destroy) are performed remotely. """ if not _is_fully_qualified_stack_name(stack_name): - raise Exception(f'"{stack_name}" stack name must be fully qualified.') + raise Exception(f'stack name "{stack_name}" must be fully qualified.') ws = _create_local_workspace( url=url, @@ -133,7 +133,7 @@ def create_or_select_remote_stack_git_source( Git repository. Pulumi operations on the stack (Preview, Update, Refresh, and Destroy) are performed remotely. """ if not _is_fully_qualified_stack_name(stack_name): - raise Exception(f'"{stack_name}" stack name must be fully qualified.') + raise Exception(f'stack name "{stack_name}" must be fully qualified.') ws = _create_local_workspace( url=url, @@ -162,7 +162,7 @@ def select_remote_stack_git_source( Git repository. Pulumi operations on the stack (Preview, Update, Refresh, and Destroy) are performed remotely. """ if not _is_fully_qualified_stack_name(stack_name): - raise Exception(f'"{stack_name}" stack name must be fully qualified.') + raise Exception(f'stack name "{stack_name}" must be fully qualified.') ws = _create_local_workspace( url=url, @@ -185,12 +185,14 @@ def _create_local_workspace( opts: Optional[RemoteWorkspaceOptions] = None, ) -> LocalWorkspace: - if commit_hash is not None and branch is not None: - raise Exception("commit_hash and branch cannot both be specified.") - if commit_hash is None and branch is None: - raise Exception("at least commit_hash or branch are required.") + if not url: + raise Exception("url is required.") + if branch and commit_hash: + raise Exception("branch and commit_hash cannot both be specified.") + if not branch and not commit_hash: + raise Exception("either branch or commit_hash is required.") if auth is not None: - if auth.ssh_private_key is not None and auth.ssh_private_key_path is not None: + if auth.ssh_private_key and auth.ssh_private_key_path: raise Exception( "ssh_private_key and ssh_private_key_path cannot both be specified." ) diff --git a/sdk/python/lib/pulumi/automation/_stack.py b/sdk/python/lib/pulumi/automation/_stack.py index c2a63378ee6b..c0d54ba77fbd 100644 --- a/sdk/python/lib/pulumi/automation/_stack.py +++ b/sdk/python/lib/pulumi/automation/_stack.py @@ -78,9 +78,15 @@ def __init__( self.config: ConfigMap = {} for key in config: config_value = config[key] - self.config[key] = ConfigValue( - value=config_value["value"], secret=config_value["secret"] + secret = config_value["secret"] + # If it is a secret, and we're not showing secrets, the value is excluded from the JSON results. + # In that case, we'll just use the sentinal `[secret]` value. Otherwise, we expect to get a value. + value = ( + config_value.get("value", "[secret]") + if secret + else config_value["value"] ) + self.config[key] = ConfigValue(value=value, secret=secret) def __repr__(self): return ( diff --git a/sdk/python/lib/test/automation/test_local_workspace.py b/sdk/python/lib/test/automation/test_local_workspace.py index fe2276e0751b..eb041e42e725 100644 --- a/sdk/python/lib/test/automation/test_local_workspace.py +++ b/sdk/python/lib/test/automation/test_local_workspace.py @@ -15,7 +15,6 @@ import json import os import unittest -from random import random from semver import VersionInfo from typing import List, Optional @@ -43,6 +42,8 @@ ) from pulumi.automation._local_workspace import _parse_and_validate_pulumi_version +from .test_utils import get_test_org, get_test_suffix, stack_namer + extensions = ["json", "yaml", "yml"] MAJOR = "Major version mismatch." @@ -73,28 +74,12 @@ def test_path(*paths): return os.path.join(os.path.dirname(os.path.abspath(__file__)), *paths) -def get_test_org(): - test_org = "pulumi-test" - env_var = os.getenv("PULUMI_TEST_ORG") - if env_var is not None: - test_org = env_var - return test_org - - -def stack_namer(project_name): - return fully_qualified_stack_name(get_test_org(), project_name, f"int_test_{get_test_suffix()}") - - def normalize_config_key(key: str, project_name: str): parts = key.split(":") if len(parts) < 2: return f"{project_name}:{key}" -def get_test_suffix() -> int: - return int(100000 + random() * 900000) - - def found_plugin(plugin_list: List[PluginInfo], name: str, version: str) -> bool: for plugin in plugin_list: if plugin.name == name and plugin.version == version: diff --git a/sdk/python/lib/test/automation/test_remote_workspace.py b/sdk/python/lib/test/automation/test_remote_workspace.py index fb2c6ce9f23c..f0ed11b5a9ff 100644 --- a/sdk/python/lib/test/automation/test_remote_workspace.py +++ b/sdk/python/lib/test/automation/test_remote_workspace.py @@ -12,10 +12,115 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +from typing import Optional import pytest from pulumi.automation._remote_workspace import _is_fully_qualified_stack_name +from pulumi.automation import ( + LocalWorkspace, + OpType, + RemoteGitAuth, + RemoteWorkspaceOptions, + create_remote_stack_git_source, + create_or_select_remote_stack_git_source, + select_remote_stack_git_source, +) + +from .test_utils import stack_namer + + +test_repo = "https://github.com/pulumi/test-repo.git" + + +@pytest.mark.parametrize("factory", [ + create_remote_stack_git_source, + create_or_select_remote_stack_git_source, + select_remote_stack_git_source, +]) +@pytest.mark.parametrize("error,stack_name,url,branch,commit_hash,auth", [ + ('stack name "" must be fully qualified.', "", "", None, None, None), + ('stack name "name" must be fully qualified.', "name", "", None, None, None), + ('stack name "owner/name" must be fully qualified.', "owner/name", "", None, None, None), + ('stack name "/" must be fully qualified.', "/", "", None, None, None), + ('stack name "//" must be fully qualified.', "//", "", None, None, None), + ('stack name "///" must be fully qualified.', "///", "", None, None, None), + ('stack name "owner/project/stack/wat" must be fully qualified.', "owner/project/stack/wat", "", None, None, None), + ('url is required.', "owner/project/stack", None, None, None, None), + ('url is required.', "owner/project/stack", "", None, None, None), + ('either branch or commit_hash is required.', "owner/project/stack", test_repo, None, None, None), + ('either branch or commit_hash is required.', "owner/project/stack", test_repo, "", "", None), + ('branch and commit_hash cannot both be specified.', "owner/project/stack", test_repo, "branch", "commit", None), + ('ssh_private_key and ssh_private_key_path cannot both be specified.', "owner/project/stack", test_repo, "branch", + None, RemoteGitAuth(ssh_private_key="key", ssh_private_key_path="path")), +]) +def test_remote_workspace_errors( + factory, + error: str, + stack_name: str, + url: str, + branch: Optional[str], + commit_hash: Optional[str], + auth: Optional[RemoteGitAuth], +): + with pytest.raises(Exception) as e_info: + factory(stack_name=stack_name, url=url, branch=branch, commit_hash=commit_hash, auth=auth) + assert str(e_info.value) == error + + +# These tests require the service with access to Pulumi Deployments. +# Set PULUMI_ACCESS_TOKEN to an access token with access to Pulumi Deployments +# and set PULUMI_TEST_DEPLOYMENTS_API to any value to enable the tests. +@pytest.mark.parametrize("factory", [ + create_remote_stack_git_source, + create_or_select_remote_stack_git_source, +]) +@pytest.mark.skipif("PULUMI_ACCESS_TOKEN" not in os.environ, reason="PULUMI_ACCESS_TOKEN not set") +@pytest.mark.skipif("PULUMI_TEST_DEPLOYMENTS_API" not in os.environ, reason="PULUMI_TEST_DEPLOYMENTS_API not set") +def test_remote_workspace_stack_lifecycle(factory): + project_name = "go_remote_proj" + stack_name = stack_namer(project_name) + stack = factory( + stack_name=stack_name, + url=test_repo, + branch="refs/heads/master", + project_path="goproj", + opts=RemoteWorkspaceOptions(pre_run_commands=[ + f"pulumi config set bar abc --stack {stack_name}", + f"pulumi config set --secret buzz secret --stack {stack_name}", + ]), + ) + + # pulumi up + up_res = stack.up() + assert len(up_res.outputs) == 3 + assert up_res.outputs["exp_static"].value == "foo" + assert not up_res.outputs["exp_static"].secret + assert up_res.outputs["exp_cfg"].value == "abc" + assert not up_res.outputs["exp_cfg"].secret + assert up_res.outputs["exp_secret"].value == "secret" + assert up_res.outputs["exp_secret"].secret + assert up_res.summary.kind == "update" + assert up_res.summary.result == "succeeded" + + # pulumi preview + preview_result = stack.preview() + assert preview_result.change_summary.get(OpType.SAME) == 1 + + # pulumi refresh + refresh_res = stack.refresh() + assert refresh_res.summary.kind == "refresh" + assert refresh_res.summary.result == "succeeded" + + # pulumi destroy + destroy_res = stack.destroy() + assert destroy_res.summary.kind == "destroy" + assert destroy_res.summary.result == "succeeded" + + LocalWorkspace().remove_stack(stack_name) + + @pytest.mark.parametrize("input,expected", [ ("owner/project/stack", True), ("", False), diff --git a/sdk/python/lib/test/automation/test_utils.py b/sdk/python/lib/test/automation/test_utils.py new file mode 100644 index 000000000000..11528a5290bb --- /dev/null +++ b/sdk/python/lib/test/automation/test_utils.py @@ -0,0 +1,33 @@ +# Copyright 2016-2022, Pulumi Corporation. +# +# 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. + +import os +from random import random + +from pulumi.automation import fully_qualified_stack_name + +def get_test_org(): + test_org = "pulumi-test" + env_var = os.getenv("PULUMI_TEST_ORG") + if env_var is not None: + test_org = env_var + return test_org + + +def get_test_suffix() -> int: + return int(100000 + random() * 900000) + + +def stack_namer(project_name): + return fully_qualified_stack_name(get_test_org(), project_name, f"int_test_{get_test_suffix()}")