Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[auto/python] Support for remote operations #11174

Merged
merged 1 commit into from Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,4 @@
changes:
- type: feat
scope: auto/python
description: Support for remote operations
22 changes: 21 additions & 1 deletion 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -126,6 +136,7 @@ def pulumi_program():
from ._local_workspace import (
LocalWorkspace,
LocalWorkspaceOptions,
Secret,
create_stack,
select_stack,
create_or_select_stack,
Expand Down Expand Up @@ -197,6 +208,7 @@ def pulumi_program():
# _local_workspace
"LocalWorkspace",
"LocalWorkspaceOptions",
"Secret",
"create_stack",
"select_stack",
"create_or_select_stack",
Expand Down Expand Up @@ -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",
Expand Down
107 changes: 101 additions & 6 deletions 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.
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
)
Expand Down Expand Up @@ -269,10 +287,19 @@ 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:
self._run_pulumi_cmd_sync(["stack", "select", stack_name])
# If this is a remote workspace, we don't want to actually select the stack (which would modify global state);
# but we will ensure the stack exists by calling `pulumi stack`.
args: List[str] = ["stack"]
if not self._remote:
args.append("select")
args.append("--stack")
args.append(stack_name)
self._run_pulumi_cmd_sync(args)

def remove_stack(self, stack_name: str) -> None:
self._run_pulumi_cmd_sync(["stack", "rm", "--yes", stack_name])
Expand Down Expand Up @@ -373,20 +400,88 @@ 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()
if version_string[0] == "v":
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"]:
Expand Down
156 changes: 156 additions & 0 deletions 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)