Skip to content

Commit

Permalink
Merge #11290 #11293
Browse files Browse the repository at this point in the history
11290: [cli] Test remote operations r=justinvp a=justinvp

Also some changes to validate flags earlier (before getting the backend).

Related:
- #11291
- #11292
- #11293
- #11294

11293: [auto/python] Test remote operations r=justinvp a=justinvp

Also cleans up some error messages to be consistent with the CLI and other languages.

Related:
- #11290
- #11291
- #11292
- #11294

Co-authored-by: Justin Van Patten <jvp@justinvp.com>
  • Loading branch information
bors[bot] and justinvp committed Nov 9, 2022
3 parents d3422d5 + 2f1d3fe + 81d1d03 commit 9df03f5
Show file tree
Hide file tree
Showing 8 changed files with 382 additions and 59 deletions.
73 changes: 41 additions & 32 deletions pkg/cmd/pulumi/util_remote.go
Expand Up @@ -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)
Expand All @@ -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}
Expand Down
18 changes: 10 additions & 8 deletions sdk/python/lib/pulumi/automation/_remote_workspace.py
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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."
)
Expand Down
10 changes: 8 additions & 2 deletions sdk/python/lib/pulumi/automation/_stack.py
Expand Up @@ -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 (
Expand Down
19 changes: 2 additions & 17 deletions sdk/python/lib/test/automation/test_local_workspace.py
Expand Up @@ -15,7 +15,6 @@
import json
import os
import unittest
from random import random
from semver import VersionInfo
from typing import List, Optional

Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions sdk/python/lib/test/automation/test_remote_workspace.py
Expand Up @@ -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),
Expand Down
33 changes: 33 additions & 0 deletions 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()}")

0 comments on commit 9df03f5

Please sign in to comment.