diff --git a/changelog/pending/20221027--auto-python--support-for-remote-operations.yaml b/changelog/pending/20221027--auto-python--support-for-remote-operations.yaml new file mode 100644 index 000000000000..8279c1399613 --- /dev/null +++ b/changelog/pending/20221027--auto-python--support-for-remote-operations.yaml @@ -0,0 +1,4 @@ +changes: +- type: feat + scope: auto/python + description: Support for remote operations diff --git a/sdk/python/lib/pulumi/automation/__init__.py b/sdk/python/lib/pulumi/automation/__init__.py index b309c2a3a26e..c1bcd3063dc5 100644 --- a/sdk/python/lib/pulumi/automation/__init__.py +++ b/sdk/python/lib/pulumi/automation/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2016-2021, Pulumi Corporation. +# 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. @@ -88,6 +88,16 @@ def pulumi_program(): """ +from pulumi.automation._remote_workspace import ( + RemoteWorkspaceOptions, + RemoteGitAuth, + create_remote_stack_git_source, + create_or_select_remote_stack_git_source, + select_remote_stack_git_source, +) + +from pulumi.automation._remote_stack import RemoteStack + from ._cmd import CommandResult, OnOutput from ._config import ConfigMap, ConfigValue @@ -126,6 +136,7 @@ def pulumi_program(): from ._local_workspace import ( LocalWorkspace, LocalWorkspaceOptions, + Secret, create_stack, select_stack, create_or_select_stack, @@ -197,6 +208,7 @@ def pulumi_program(): # _local_workspace "LocalWorkspace", "LocalWorkspaceOptions", + "Secret", "create_stack", "select_stack", "create_or_select_stack", @@ -225,6 +237,14 @@ def pulumi_program(): "RefreshResult", "DestroyResult", "fully_qualified_stack_name", + # _remote_workspace + "RemoteWorkspaceOptions", + "RemoteGitAuth", + "create_remote_stack_git_source", + "create_or_select_remote_stack_git_source", + "select_remote_stack_git_source", + # _remote_stack + "RemoteStack", # sub-modules "errors", "events", diff --git a/sdk/python/lib/pulumi/automation/_local_workspace.py b/sdk/python/lib/pulumi/automation/_local_workspace.py index dbdc64f64e29..87c25258bdfb 100644 --- a/sdk/python/lib/pulumi/automation/_local_workspace.py +++ b/sdk/python/lib/pulumi/automation/_local_workspace.py @@ -1,4 +1,4 @@ -# Copyright 2016-2021, Pulumi Corporation. +# 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. @@ -12,11 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import os import tempfile import json from datetime import datetime -from typing import Optional, List, Mapping, Callable +from typing import Optional, List, Mapping, Callable, Union, TYPE_CHECKING from semver import VersionInfo import yaml @@ -37,11 +39,20 @@ from ._minimum_version import _MINIMUM_VERSION from .errors import InvalidVersionError +if TYPE_CHECKING: + from pulumi.automation._remote_workspace import RemoteGitAuth + _setting_extensions = [".yaml", ".yml", ".json"] _SKIP_VERSION_CHECK_VAR = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK" +class Secret(str): + """ + Represents a secret value. + """ + + class LocalWorkspaceOptions: work_dir: Optional[str] = None pulumi_home: Optional[str] = None @@ -83,6 +94,15 @@ class LocalWorkspace(Workspace): This is identical to the behavior of Pulumi CLI driven workspaces. """ + _remote: bool = False + _remote_env_vars: Optional[Mapping[str, Union[str, Secret]]] + _remote_pre_run_commands: Optional[List[str]] + _remote_git_url: str + _remote_git_project_path: Optional[str] + _remote_git_branch: Optional[str] + _remote_git_commit_hash: Optional[str] + _remote_git_auth: Optional[RemoteGitAuth] + def __init__( self, work_dir: Optional[str] = None, @@ -102,9 +122,7 @@ def __init__( ) pulumi_version = self._get_pulumi_version() - 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 + opt_out = self._version_check_opt_out() version = _parse_and_validate_pulumi_version( _MINIMUM_VERSION, pulumi_version, opt_out ) @@ -269,6 +287,8 @@ def create_stack(self, stack_name: str) -> None: args = ["stack", "init", stack_name] if self.secrets_provider: args.extend(["--secrets-provider", self.secrets_provider]) + if self._remote: + args.append("--no-select") self._run_pulumi_cmd_sync(args) def select_stack(self, stack_name: str) -> None: @@ -373,6 +393,12 @@ def stack_outputs(self, stack_name: str) -> OutputMap: outputs[key] = OutputValue(value=plaintext_outputs[key], secret=secret) return outputs + def _version_check_opt_out(self) -> bool: + return ( + os.getenv(_SKIP_VERSION_CHECK_VAR) is not None + or self.env_vars.get(_SKIP_VERSION_CHECK_VAR) is not None + ) + def _get_pulumi_version(self) -> str: result = self._run_pulumi_cmd_sync(["version"]) version_string = result.stdout.strip() @@ -380,13 +406,75 @@ def _get_pulumi_version(self) -> str: version_string = version_string[1:] return version_string + def _remote_supported(self) -> bool: + # See if `--remote` is present in `pulumi preview --help`'s output. + result = self._run_pulumi_cmd_sync(["preview", "--help"]) + help_string = result.stdout.strip() + return "--remote" in help_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 {} + if self._remote: + envs["PULUMI_EXPERIMENTAL"] = "true" envs = {**envs, **self.env_vars} return _run_pulumi_cmd(args, self.work_dir, envs, on_output) + def _remote_args(self) -> List[str]: + args: List[str] = [] + if not self._remote: + return args + + args.append("--remote") + if self._remote_git_url: + args.append(self._remote_git_url) + if self._remote_git_project_path: + args.append("--remote-git-repo-dir") + args.append(self._remote_git_project_path) + if self._remote_git_branch: + args.append("--remote-git-branch") + args.append(self._remote_git_branch) + if self._remote_git_commit_hash: + args.append("--remote-git-commit") + args.append(self._remote_git_commit_hash) + auth = self._remote_git_auth + if auth is not None: + if auth.personal_access_token: + args.append("--remote-git-auth-access-token") + args.append(auth.personal_access_token) + if auth.ssh_private_key: + args.append("--remote-git-auth-ssh-private-key") + args.append(auth.ssh_private_key) + if auth.ssh_private_key_path: + args.append("--remote-git-auth-ssh-private-key-path") + args.append(auth.ssh_private_key_path) + if auth.password: + args.append("--remote-git-auth-password") + args.append(auth.password) + if auth.username: + args.append("--remote-git-auth-username") + args.append(auth.username) + + if self._remote_env_vars is not None: + for k in self._remote_env_vars: + v = self._remote_env_vars[k] + if isinstance(v, Secret): + args.append("--remote-env-secret") + args.append(f"{k}={v}") + elif isinstance(v, str): + args.append("--remote-env") + args.append(f"{k}={v}") + else: + raise AssertionError(f"unexpected env value {v} for key '{k}'") + + if self._remote_pre_run_commands is not None: + for command in self._remote_pre_run_commands: + args.append("--remote-pre-run-command") + args.append(command) + + return args + def _is_inline_program(**kwargs) -> bool: for key in ["program", "project_name"]: diff --git a/sdk/python/lib/pulumi/automation/_remote_stack.py b/sdk/python/lib/pulumi/automation/_remote_stack.py new file mode 100644 index 000000000000..5dc8c69ee692 --- /dev/null +++ b/sdk/python/lib/pulumi/automation/_remote_stack.py @@ -0,0 +1,156 @@ +# 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. + +from typing import List, Optional + +from pulumi.automation._cmd import OnOutput +from pulumi.automation._output import OutputMap +from pulumi.automation._stack import ( + DestroyResult, + OnEvent, + PreviewResult, + RefreshResult, + Stack, + UpResult, + UpdateSummary, +) +from pulumi.automation._workspace import Deployment + + +class RemoteStack: + """ + RemoteStack is an isolated, independencly configurable instance of a Pulumi program that is + operated on remotely (up/preview/refresh/destroy). + """ + + __stack: Stack + + @property + def name(self) -> str: + return self.__stack.name + + def __init__(self, stack: Stack): + self.__stack = stack + + def up( + self, + on_output: Optional[OnOutput] = None, + on_event: Optional[OnEvent] = None, + ) -> UpResult: + """ + Creates or updates the resources in a stack by executing the program in the Workspace. + https://www.pulumi.com/docs/reference/cli/pulumi_up/ + + :param on_output: A function to process the stdout stream. + :param on_event: A function to process structured events from the Pulumi event stream. + :returns: UpResult + """ + return self.__stack.up(on_output=on_output, on_event=on_event) + + def preview( + self, + on_output: Optional[OnOutput] = None, + on_event: Optional[OnEvent] = None, + ) -> PreviewResult: + """ + Performs a dry-run update to a stack, returning pending changes. + https://www.pulumi.com/docs/reference/cli/pulumi_preview/ + + :param on_output: A function to process the stdout stream. + :param on_event: A function to process structured events from the Pulumi event stream. + :returns: PreviewResult + """ + return self.__stack.preview(on_output=on_output, on_event=on_event) + + def refresh( + self, + on_output: Optional[OnOutput] = None, + on_event: Optional[OnEvent] = None, + ) -> RefreshResult: + """ + Compares the current stack’s resource state with the state known to exist in the actual + cloud provider. Any such changes are adopted into the current stack. + + :param on_output: A function to process the stdout stream. + :param on_event: A function to process structured events from the Pulumi event stream. + :returns: RefreshResult + """ + return self.__stack.refresh(on_output=on_output, on_event=on_event) + + def destroy( + self, + on_output: Optional[OnOutput] = None, + on_event: Optional[OnEvent] = None, + ) -> DestroyResult: + """ + Destroy deletes all resources in a stack, leaving all history and configuration intact. + + :param on_output: A function to process the stdout stream. + :param on_event: A function to process structured events from the Pulumi event stream. + :returns: DestroyResult + """ + return self.__stack.destroy(on_output=on_output, on_event=on_event) + + def outputs(self) -> OutputMap: + """ + Gets the current set of Stack outputs from the last Stack.up(). + + :returns: OutputMap + """ + return self.__stack.outputs() + + def history( + self, + page_size: Optional[int] = None, + page: Optional[int] = None, + ) -> List[UpdateSummary]: + """ + Returns a list summarizing all previous and current results from Stack lifecycle operations + (up/preview/refresh/destroy). + + :param page_size: Paginate history entries (used in combination with page), defaults to all. + :param page: Paginate history entries (used in combination with page_size), defaults to all. + :param show_secrets: Show config secrets when they appear in history. + + :returns: List[UpdateSummary] + """ + # Note: Find a way to allow show_secrets as an option that doesn't require loading the project. + return self.__stack.history(page_size=page_size, page=page, show_secrets=False) + + def cancel(self) -> None: + """ + Cancel stops a stack's currently running update. It returns an error if no update is currently running. + Note that this operation is _very dangerous_, and may leave the stack in an inconsistent state + if a resource operation was pending when the update was canceled. + This command is not supported for local backends. + """ + self.__stack.cancel() + + def export_stack(self) -> Deployment: + """ + export_stack exports the deployment state of the stack. + This can be combined with Stack.import_state to edit a stack's state (such as recovery from failed deployments). + + :returns: Deployment + """ + return self.__stack.export_stack() + + def import_stack(self, state: Deployment) -> None: + """ + import_stack imports the specified deployment state into a pre-existing stack. + This can be combined with Stack.export_state to edit a stack's state (such as recovery from failed deployments). + + :param state: The deployment state to import. + """ + self.__stack.import_stack(state=state) diff --git a/sdk/python/lib/pulumi/automation/_remote_workspace.py b/sdk/python/lib/pulumi/automation/_remote_workspace.py new file mode 100644 index 000000000000..fa23b31bd517 --- /dev/null +++ b/sdk/python/lib/pulumi/automation/_remote_workspace.py @@ -0,0 +1,225 @@ +# 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. + +from typing import List, Mapping, Optional, Union + +from pulumi.automation._local_workspace import LocalWorkspace, Secret +from pulumi.automation._remote_stack import RemoteStack +from pulumi.automation._stack import Stack, StackInitMode + + +class RemoteWorkspaceOptions: + """ + Extensibility options to configure a RemoteWorkspace. + """ + + env_vars: Optional[Mapping[str, Union[str, Secret]]] + pre_run_commands: Optional[List[str]] + + def __init__( + self, + *, + env_vars: Optional[Mapping[str, Union[str, Secret]]] = None, + pre_run_commands: Optional[List[str]] = None, + ): + self.env_vars = env_vars + self.pre_run_commands = pre_run_commands + + +class RemoteGitAuth: + """ + Authentication options for the repository that can be specified for a private Git repo. + There are three different authentication paths: + - Personal accesstoken + - SSH private key (and its optional password) + - Basic auth username and password + + Only one authentication path is valid. + """ + + ssh_private_key_path: Optional[str] + """ + The absolute path to a private key for access to the git repo. + """ + + ssh_private_key: Optional[str] + """ + The (contents) private key for access to the git repo. + """ + + password: Optional[str] + """ + The password that pairs with a username or as part of an SSH Private Key. + """ + + personal_access_token: Optional[str] + """ + A Git personal access token in replacement of your password. + """ + + username: Optional[str] + """ + The username to use when authenticating to a git repository. + """ + + def __init__( + self, + *, + ssh_private_key_path: Optional[str] = None, + ssh_private_key: Optional[str] = None, + password: Optional[str] = None, + personal_access_token: Optional[str] = None, + username: Optional[str] = None, + ): + self.ssh_private_key_path = ssh_private_key_path + self.ssh_private_key = ssh_private_key + self.password = password + self.personal_access_token = personal_access_token + self.username = username + + +def create_remote_stack_git_source( + stack_name: str, + url: str, + *, + branch: Optional[str] = None, + commit_hash: Optional[str] = None, + project_path: Optional[str] = None, + auth: Optional[RemoteGitAuth] = None, + opts: Optional[RemoteWorkspaceOptions] = None, +) -> RemoteStack: + """ + PREVIEW: Creates a Stack backed by a RemoteWorkspace with source code from the specified 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.') + + ws = _create_local_workspace( + url=url, + project_path=project_path, + branch=branch, + commit_hash=commit_hash, + auth=auth, + opts=opts, + ) + stack = Stack.create(stack_name, ws) + return RemoteStack(stack) + + +def create_or_select_remote_stack_git_source( + stack_name: str, + url: str, + *, + branch: Optional[str] = None, + commit_hash: Optional[str] = None, + project_path: Optional[str] = None, + auth: Optional[RemoteGitAuth] = None, + opts: Optional[RemoteWorkspaceOptions] = None, +) -> RemoteStack: + """ + PREVIEW: Creates or selects an existing Stack backed by a RemoteWorkspace with source code from the specified + 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.') + + ws = _create_local_workspace( + url=url, + project_path=project_path, + branch=branch, + commit_hash=commit_hash, + auth=auth, + opts=opts, + ) + stack = Stack(stack_name, ws, StackInitMode.CREATE_OR_SELECT, select=False) + return RemoteStack(stack) + + +def select_remote_stack_git_source( + stack_name: str, + url: str, + *, + branch: Optional[str] = None, + commit_hash: Optional[str] = None, + project_path: Optional[str] = None, + auth: Optional[RemoteGitAuth] = None, + opts: Optional[RemoteWorkspaceOptions] = None, +) -> RemoteStack: + """ + PREVIEW: Creates or selects an existing Stack backed by a RemoteWorkspace with source code from the specified + 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.') + + ws = _create_local_workspace( + url=url, + project_path=project_path, + branch=branch, + commit_hash=commit_hash, + auth=auth, + opts=opts, + ) + stack = Stack(stack_name, ws, StackInitMode.SELECT, select=False) + return RemoteStack(stack) + + +def _create_local_workspace( + url: str, + branch: Optional[str] = None, + commit_hash: Optional[str] = None, + project_path: Optional[str] = None, + auth: Optional[RemoteGitAuth] = None, + 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 auth is not None: + if auth.ssh_private_key is not None and auth.ssh_private_key_path is not None: + raise Exception( + "ssh_private_key and ssh_private_key_path cannot both be specified." + ) + + env_vars = None + pre_run_commands = None + if opts is not None: + env_vars = opts.env_vars + pre_run_commands = opts.pre_run_commands + + ws = LocalWorkspace() + ws._remote = True + ws._remote_env_vars = env_vars + ws._remote_pre_run_commands = pre_run_commands + ws._remote_git_url = url + ws._remote_git_project_path = project_path + ws._remote_git_branch = branch + ws._remote_git_commit_hash = commit_hash + ws._remote_git_auth = auth + + # Ensure the CLI supports --remote. + if not ws._version_check_opt_out() and not ws._remote_supported(): + raise Exception( + "The Pulumi CLI does not support remote operations. Please upgrade." + ) + + return ws + + +def _is_fully_qualified_stack_name(stack: str) -> bool: + split = stack.split("/") + return len(split) == 3 and split[0] != "" and split[1] != "" and split[2] != "" diff --git a/sdk/python/lib/pulumi/automation/_stack.py b/sdk/python/lib/pulumi/automation/_stack.py index 6c59d64586a0..8aff8e0653ce 100644 --- a/sdk/python/lib/pulumi/automation/_stack.py +++ b/sdk/python/lib/pulumi/automation/_stack.py @@ -162,7 +162,9 @@ def create_or_select(cls, stack_name: str, workspace: Workspace) -> "Stack": """ return Stack(stack_name, workspace, StackInitMode.CREATE_OR_SELECT) - def __init__(self, name: str, workspace: Workspace, mode: StackInitMode) -> None: + def __init__( + self, name: str, workspace: Workspace, mode: StackInitMode, select=True + ) -> None: """ Stack is an isolated, independently configurable instance of a Pulumi program. Stack exposes methods for the full pulumi lifecycle (up/preview/refresh/destroy), as well as managing configuration. @@ -183,12 +185,20 @@ def __init__(self, name: str, workspace: Workspace, mode: StackInitMode) -> None if mode is StackInitMode.CREATE: workspace.create_stack(name) elif mode is StackInitMode.SELECT: - workspace.select_stack(name) - elif mode is StackInitMode.CREATE_OR_SELECT: - try: + if select: workspace.select_stack(name) - except StackNotFoundError: - workspace.create_stack(name) + # TODO: if not select, verify the stack exists + elif mode is StackInitMode.CREATE_OR_SELECT: + if select: + try: + workspace.select_stack(name) + except StackNotFoundError: + workspace.create_stack(name) + else: + try: + workspace.create_stack(name) + except StackAlreadyExistsError: + pass def __repr__(self): return f"Stack(stack_name={self.name!r}, workspace={self.workspace!r}, mode={self._mode!r})" @@ -257,6 +267,8 @@ def up( args.append("--plan") args.append(plan) + args.extend(self._remote_args()) + kind = ExecKind.LOCAL.value on_exit = None @@ -299,7 +311,9 @@ def on_exit_fn(): try: up_result = self._run_pulumi_cmd_sync(args, on_output) outputs = self.outputs() - summary = self.info(show_secrets) + # If it's a remote workspace, explicitly set show_secrets to False to prevent attempting to + # load the project file. + summary = self.info(show_secrets and not self._is_remote) assert summary is not None finally: _cleanup(temp_dir, log_watcher_thread, on_exit) @@ -370,6 +384,8 @@ def preview( args.append("--save-plan") args.append(plan) + args.extend(self._remote_args()) + kind = ExecKind.LOCAL.value on_exit = None @@ -470,6 +486,8 @@ def refresh( args = ["refresh", "--yes", "--skip-preview"] args.extend(extra_args) + args.extend(self._remote_args()) + kind = ExecKind.INLINE.value if self.workspace.program else ExecKind.LOCAL.value args.extend(["--exec-kind", kind]) @@ -488,7 +506,9 @@ def refresh( finally: _cleanup(temp_dir, log_watcher_thread) - summary = self.info(show_secrets) + # If it's a remote workspace, explicitly set show_secrets to False to prevent attempting to + # load the project file. + summary = self.info(show_secrets and not self._is_remote) assert summary is not None return RefreshResult( stdout=refresh_result.stdout, stderr=refresh_result.stderr, summary=summary @@ -535,6 +555,8 @@ def destroy( args = ["destroy", "--yes", "--skip-preview"] args.extend(extra_args) + args.extend(self._remote_args()) + kind = ExecKind.INLINE.value if self.workspace.program else ExecKind.LOCAL.value args.extend(["--exec-kind", kind]) @@ -553,7 +575,9 @@ def destroy( finally: _cleanup(temp_dir, log_watcher_thread) - summary = self.info(show_secrets) + # If it's a remote workspace, explicitly set show_secrets to False to prevent attempting to + # load the project file. + summary = self.info(show_secrets and not self._is_remote) assert summary is not None return DestroyResult( stdout=destroy_result.stdout, stderr=destroy_result.stderr, summary=summary @@ -715,6 +739,8 @@ def _run_pulumi_cmd_sync( self, args: List[str], on_output: Optional[OnOutput] = None ) -> CommandResult: envs = {"PULUMI_DEBUG_COMMANDS": "true"} + if self._is_remote(): + envs = {**envs, "PULUMI_EXPERIMENTAL": "true"} if self.workspace.pulumi_home is not None: envs = {**envs, "PULUMI_HOME": self.workspace.pulumi_home} envs = {**envs, **self.workspace.env_vars} @@ -726,6 +752,26 @@ def _run_pulumi_cmd_sync( self.workspace.post_command_callback(self.name) return result + def _is_remote(self) -> bool: + # pylint: disable=import-outside-toplevel + from pulumi.automation._local_workspace import LocalWorkspace + + return ( + self.workspace._remote + if isinstance(self.workspace, LocalWorkspace) + else False + ) + + def _remote_args(self) -> List[str]: + # pylint: disable=import-outside-toplevel + from pulumi.automation._local_workspace import LocalWorkspace + + return ( + self.workspace._remote_args() + if isinstance(self.workspace, LocalWorkspace) + else [] + ) + def _parse_extra_args(**kwargs) -> List[str]: extra_args: List[str] = [] diff --git a/sdk/python/lib/test/automation/test_remote_workspace.py b/sdk/python/lib/test/automation/test_remote_workspace.py new file mode 100644 index 000000000000..fb2c6ce9f23c --- /dev/null +++ b/sdk/python/lib/test/automation/test_remote_workspace.py @@ -0,0 +1,31 @@ +# 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 pytest + +from pulumi.automation._remote_workspace import _is_fully_qualified_stack_name + +@pytest.mark.parametrize("input,expected", [ + ("owner/project/stack", True), + ("", False), + ("name", False), + ("owner/name", False), + ("/", False), + ("//", False), + ("///", False), + ("owner/project/stack/wat", False), +]) +def test_config_get_with_defaults(input, expected): + actual = _is_fully_qualified_stack_name(input) + assert expected == actual