diff --git a/changelog/pending/20221027--auto-go--support-for-remote-operations.yaml b/changelog/pending/20221027--auto-go--support-for-remote-operations.yaml new file mode 100644 index 000000000000..0e5690e1db0a --- /dev/null +++ b/changelog/pending/20221027--auto-go--support-for-remote-operations.yaml @@ -0,0 +1,4 @@ +changes: +- type: feat + scope: auto/go + description: Support for remote operations diff --git a/changelog/pending/20221027--auto-nodejs--support-for-remote-operations.yaml b/changelog/pending/20221027--auto-nodejs--support-for-remote-operations.yaml new file mode 100644 index 000000000000..fa779e70efdf --- /dev/null +++ b/changelog/pending/20221027--auto-nodejs--support-for-remote-operations.yaml @@ -0,0 +1,4 @@ +changes: +- type: feat + scope: auto/nodejs + description: Support for remote operations 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/go/auto/local_workspace.go b/sdk/go/auto/local_workspace.go index cc814daf01f2..edbeba0f0b0f 100644 --- a/sdk/go/auto/local_workspace.go +++ b/sdk/go/auto/local_workspace.go @@ -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. @@ -51,6 +51,10 @@ type LocalWorkspace struct { envvars map[string]string secretsProvider string pulumiVersion semver.Version + repo *GitRepo + remote bool + remoteEnvVars map[string]EnvVarValue + preRunCommands []string } var settingsExtensions = []string{".yaml", ".yml", ".json"} @@ -324,6 +328,9 @@ func (l *LocalWorkspace) CreateStack(ctx context.Context, stackName string) erro if l.secretsProvider != "" { args = append(args, "--secrets-provider", l.secretsProvider) } + if l.remote { + args = append(args, "--no-select") + } stdout, stderr, errCode, err := l.runPulumiCmdSync(ctx, args...) if err != nil { return newAutoError(errors.Wrap(err, "failed to create stack"), stdout, stderr, errCode) @@ -334,7 +341,15 @@ func (l *LocalWorkspace) CreateStack(ctx context.Context, stackName string) erro // SelectStack selects and sets an existing stack matching the stack name, failing if none exists. func (l *LocalWorkspace) SelectStack(ctx context.Context, stackName string) error { - stdout, stderr, errCode, err := l.runPulumiCmdSync(ctx, "stack", "select", stackName) + // 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 := []string{"stack"} + if !l.remote { + args = append(args, "select") + } + args = append(args, "--stack", stackName) + + stdout, stderr, errCode, err := l.runPulumiCmdSync(ctx, args...) if err != nil { return newAutoError(errors.Wrap(err, "failed to select stack"), stdout, stderr, errCode) } @@ -576,6 +591,28 @@ func (l *LocalWorkspace) runPulumiCmdSync( ) } +// supportsPulumiCmdFlag runs a command with `--help` to see if the specified flag is found within the resulting +// output, in which case we assume the flag is supported. +func (l *LocalWorkspace) supportsPulumiCmdFlag(ctx context.Context, flag string, args ...string) (bool, error) { + env := []string{ + "PULUMI_DEBUG_COMMANDS=true", + "PULUMI_EXPERIMENTAL=true", + } + + // Run the command with `--help`, and then we'll look for the flag in the output. + stdout, _, _, err := runPulumiCommandSync(ctx, l.WorkDir(), nil, nil, env, append(args, "--help")...) + if err != nil { + return false, err + } + + // Does the help test in stdout mention the flag? If so, assume it's supported. + if strings.Contains(stdout, flag) { + return true, nil + } + + return false, nil +} + // NewLocalWorkspace creates and configures a LocalWorkspace. LocalWorkspaceOptions can be used to // configure things like the working directory, the program to execute, and to seed the directory with source code // from a git repository. @@ -598,7 +635,7 @@ func NewLocalWorkspace(ctx context.Context, opts ...LocalWorkspaceOption) (Works workDir = dir } - if lwOpts.Repo != nil { + if lwOpts.Repo != nil && !lwOpts.Remote { // now do the git clone projDir, err := setupGitRepo(ctx, workDir, lwOpts.Repo) if err != nil { @@ -613,9 +650,13 @@ func NewLocalWorkspace(ctx context.Context, opts ...LocalWorkspaceOption) (Works } l := &LocalWorkspace{ - workDir: workDir, - program: program, - pulumiHome: lwOpts.PulumiHome, + workDir: workDir, + preRunCommands: lwOpts.PreRunCommands, + program: program, + pulumiHome: lwOpts.PulumiHome, + remote: lwOpts.Remote, + remoteEnvVars: lwOpts.RemoteEnvVars, + repo: lwOpts.Repo, } // optOut indicates we should skip the version check. @@ -631,6 +672,18 @@ func NewLocalWorkspace(ctx context.Context, opts ...LocalWorkspaceOption) (Works return nil, err } + // If remote was specified, ensure the CLI supports it. + if !optOut && l.remote { + // See if `--remote` is present in `pulumi preview --help`'s output. + supportsRemote, err := l.supportsPulumiCmdFlag(ctx, "--remote", "preview") + if err != nil { + return nil, err + } + if !supportsRemote { + return nil, errors.New("Pulumi CLI does not support remote operations; please upgrade") + } + } + if lwOpts.Project != nil { err := l.SaveProjectSettings(ctx, lwOpts.Project) if err != nil { @@ -647,7 +700,7 @@ func NewLocalWorkspace(ctx context.Context, opts ...LocalWorkspaceOption) (Works } // setup - if lwOpts.Repo != nil && lwOpts.Repo.Setup != nil { + if !lwOpts.Remote && lwOpts.Repo != nil && lwOpts.Repo.Setup != nil { err := lwOpts.Repo.Setup(ctx, l) if err != nil { return nil, errors.Wrap(err, "error while running setup function") @@ -669,6 +722,13 @@ func NewLocalWorkspace(ctx context.Context, opts ...LocalWorkspaceOption) (Works return l, nil } +// EnvVarValue represents the value of an envvar. A value can be a secret, which is passed along +// to remote operations when used with remote workspaces, otherwise, it has no affect. +type EnvVarValue struct { + Value string + Secret bool +} + type localWorkspaceOptions struct { // WorkDir is the directory to execute commands from and store state. // Defaults to a tmp dir. @@ -690,6 +750,12 @@ type localWorkspaceOptions struct { // EnvVars is a map of environment values scoped to the workspace. // These values will be passed to all Workspace and Stack level commands. EnvVars map[string]string + // Whether the workspace represents a remote workspace. + Remote bool + // Remote environment variables to be passed to the remote Pulumi operation. + RemoteEnvVars map[string]EnvVarValue + // PreRunCommands is an optional list of arbitrary commands to run before the remote Pulumi operation is invoked. + PreRunCommands []string } // LocalWorkspaceOption is used to customize and configure a LocalWorkspace at initialization time. @@ -809,6 +875,28 @@ func EnvVars(envvars map[string]string) LocalWorkspaceOption { }) } +// remoteEnvVars is a map of environment values scoped to the workspace. +// These values will be passed to the remote Pulumi operation. +func remoteEnvVars(envvars map[string]EnvVarValue) LocalWorkspaceOption { + return localWorkspaceOption(func(lo *localWorkspaceOptions) { + lo.RemoteEnvVars = envvars + }) +} + +// remote is set on the local workspace to indicate it is actually a remote workspace. +func remote(remote bool) LocalWorkspaceOption { + return localWorkspaceOption(func(lo *localWorkspaceOptions) { + lo.Remote = remote + }) +} + +// preRunCommands is an optional list of arbitrary commands to run before the remote Pulumi operation is invoked. +func preRunCommands(commands ...string) LocalWorkspaceOption { + return localWorkspaceOption(func(lo *localWorkspaceOptions) { + lo.PreRunCommands = commands + }) +} + // NewStackLocalSource creates a Stack backed by a LocalWorkspace created on behalf of the user, // from the specified WorkDir. This Workspace will pick up // any available Settings files (Pulumi.yaml, Pulumi..yaml). diff --git a/sdk/go/auto/optremotedestroy/optremotedestroy.go b/sdk/go/auto/optremotedestroy/optremotedestroy.go new file mode 100644 index 000000000000..bbbffd59d7a4 --- /dev/null +++ b/sdk/go/auto/optremotedestroy/optremotedestroy.go @@ -0,0 +1,68 @@ +// 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 optremotedestroy contains functional options to be used with remote stack destroy operations +// github.com/sdk/v3/go/auto RemoteStack.Destroy(...optremotedestroy.Option) +package optremotedestroy + +import ( + "io" + + "github.com/pulumi/pulumi/sdk/v3/go/auto/events" +) + +// ProgressStreams allows specifying one or more io.Writers to redirect incremental destroy stdout +func ProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ProgressStreams = writers + }) +} + +// ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental destroy stderr +func ErrorProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ErrorProgressStreams = writers + }) +} + +// EventStreams allows specifying one or more channels to receive the Pulumi event stream +func EventStreams(channels ...chan<- events.EngineEvent) Option { + return optionFunc(func(opts *Options) { + opts.EventStreams = channels + }) +} + +// Option is a parameter to be applied to a Stack.Destroy() operation +type Option interface { + ApplyOption(*Options) +} + +// ---------------------------------- implementation details ---------------------------------- + +// Options is an implementation detail +type Options struct { + // ProgressStreams allows specifying one or more io.Writers to redirect incremental destroy stdout + ProgressStreams []io.Writer + // ProgressStreams allows specifying one or more io.Writers to redirect incremental destroy stderr + ErrorProgressStreams []io.Writer + // EventStreams allows specifying one or more channels to receive the Pulumi event stream + EventStreams []chan<- events.EngineEvent +} + +type optionFunc func(*Options) + +// ApplyOption is an implementation detail +func (o optionFunc) ApplyOption(opts *Options) { + o(opts) +} diff --git a/sdk/go/auto/optremotepreview/optremotepreview.go b/sdk/go/auto/optremotepreview/optremotepreview.go new file mode 100644 index 000000000000..40c1bcb27781 --- /dev/null +++ b/sdk/go/auto/optremotepreview/optremotepreview.go @@ -0,0 +1,68 @@ +// 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 optremotepreview contains functional options to be used with remote stack preview operations +// github.com/sdk/v3/go/auto RemoteStack.Preview(...optremotepreview.Option) +package optremotepreview + +import ( + "io" + + "github.com/pulumi/pulumi/sdk/v3/go/auto/events" +) + +// ProgressStreams allows specifying one or more io.Writers to redirect incremental preview stdout +func ProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ProgressStreams = writers + }) +} + +// ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental preview stderr +func ErrorProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ErrorProgressStreams = writers + }) +} + +// EventStreams allows specifying one or more channels to receive the Pulumi event stream +func EventStreams(channels ...chan<- events.EngineEvent) Option { + return optionFunc(func(opts *Options) { + opts.EventStreams = channels + }) +} + +// Option is a parameter to be applied to a Stack.Preview() operation +type Option interface { + ApplyOption(*Options) +} + +// ---------------------------------- implementation details ---------------------------------- + +// Options is an implementation detail +type Options struct { + // ProgressStreams allows specifying one or more io.Writers to redirect incremental preview stdout + ProgressStreams []io.Writer + // ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental preview stderr + ErrorProgressStreams []io.Writer + // EventStreams allows specifying one or more channels to receive the Pulumi event stream + EventStreams []chan<- events.EngineEvent +} + +type optionFunc func(*Options) + +// ApplyOption is an implementation detail +func (o optionFunc) ApplyOption(opts *Options) { + o(opts) +} diff --git a/sdk/go/auto/optremoterefresh/optremoterefresh.go b/sdk/go/auto/optremoterefresh/optremoterefresh.go new file mode 100644 index 000000000000..ce017d1e3e53 --- /dev/null +++ b/sdk/go/auto/optremoterefresh/optremoterefresh.go @@ -0,0 +1,68 @@ +// 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 optremoterefresh contains functional options to be used with remote stack refresh operations +// github.com/sdk/v3/go/auto RemoteStack.Refresh(...optremoterefresh.Option) +package optremoterefresh + +import ( + "io" + + "github.com/pulumi/pulumi/sdk/v3/go/auto/events" +) + +// ProgressStreams allows specifying one or more io.Writers to redirect incremental refresh stdout +func ProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ProgressStreams = writers + }) +} + +// ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental refresh stderr +func ErrorProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ErrorProgressStreams = writers + }) +} + +// EventStreams allows specifying one or more channels to receive the Pulumi event stream +func EventStreams(channels ...chan<- events.EngineEvent) Option { + return optionFunc(func(opts *Options) { + opts.EventStreams = channels + }) +} + +// Option is a parameter to be applied to a Stack.Refresh() operation +type Option interface { + ApplyOption(*Options) +} + +// ---------------------------------- implementation details ---------------------------------- + +// Options is an implementation detail +type Options struct { + // ProgressStreams allows specifying one or more io.Writers to redirect incremental refresh stdout + ProgressStreams []io.Writer + // ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental refresh stderr + ErrorProgressStreams []io.Writer + // EventStreams allows specifying one or more channels to receive the Pulumi event stream + EventStreams []chan<- events.EngineEvent +} + +type optionFunc func(*Options) + +// ApplyOption is an implementation detail +func (o optionFunc) ApplyOption(opts *Options) { + o(opts) +} diff --git a/sdk/go/auto/optremoteup/optremoteup.go b/sdk/go/auto/optremoteup/optremoteup.go new file mode 100644 index 000000000000..d1386b9a3937 --- /dev/null +++ b/sdk/go/auto/optremoteup/optremoteup.go @@ -0,0 +1,68 @@ +// 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 optremoteup contains functional options to be used with remote stack updates +// github.com/sdk/v3/go/auto RemoteStack.Up(...optremoteup.Option) +package optremoteup + +import ( + "io" + + "github.com/pulumi/pulumi/sdk/v3/go/auto/events" +) + +// ProgressStreams allows specifying one or more io.Writers to redirect incremental update stdout +func ProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ProgressStreams = writers + }) +} + +// ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental update stderr +func ErrorProgressStreams(writers ...io.Writer) Option { + return optionFunc(func(opts *Options) { + opts.ErrorProgressStreams = writers + }) +} + +// EventStreams allows specifying one or more channels to receive the Pulumi event stream +func EventStreams(channels ...chan<- events.EngineEvent) Option { + return optionFunc(func(opts *Options) { + opts.EventStreams = channels + }) +} + +// Option is a parameter to be applied to a Stack.Up() operation +type Option interface { + ApplyOption(*Options) +} + +// ---------------------------------- implementation details ---------------------------------- + +// Options is an implementation detail +type Options struct { + // ProgressStreams allows specifying one or more io.Writers to redirect incremental update stdout + ProgressStreams []io.Writer + // ErrorProgressStreams allows specifying one or more io.Writers to redirect incremental update stderr + ErrorProgressStreams []io.Writer + // EventStreams allows specifying one or more channels to receive the Pulumi event stream + EventStreams []chan<- events.EngineEvent +} + +type optionFunc func(*Options) + +// ApplyOption is an implementation detail +func (o optionFunc) ApplyOption(opts *Options) { + o(opts) +} diff --git a/sdk/go/auto/remote_stack.go b/sdk/go/auto/remote_stack.go new file mode 100644 index 000000000000..42be679697f3 --- /dev/null +++ b/sdk/go/auto/remote_stack.go @@ -0,0 +1,164 @@ +// 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 auto + +import ( + "context" + + "github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy" + "github.com/pulumi/pulumi/sdk/v3/go/auto/opthistory" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optrefresh" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optremotedestroy" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optremotepreview" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optremoterefresh" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optremoteup" + "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" + "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" +) + +// RemoteStack is an isolated, independently configurable instance of a Pulumi program that is +// operated on remotely (up/preview/refresh/destroy). +type RemoteStack struct { + stack Stack +} + +// Name returns the stack name. +func (s *RemoteStack) Name() string { + return s.stack.Name() +} + +// Preview preforms a dry-run update to a stack, returning pending changes. +// https://www.pulumi.com/docs/reference/cli/pulumi_preview/ +// This operation runs remotely. +func (s *RemoteStack) Preview(ctx context.Context, opts ...optremotepreview.Option) (PreviewResult, error) { + preOpts := &optremotepreview.Options{} + for _, o := range opts { + o.ApplyOption(preOpts) + } + + implOpts := []optpreview.Option{} + if preOpts.ProgressStreams != nil { + implOpts = append(implOpts, optpreview.ProgressStreams(preOpts.ProgressStreams...)) + } + if preOpts.ErrorProgressStreams != nil { + implOpts = append(implOpts, optpreview.ErrorProgressStreams(preOpts.ErrorProgressStreams...)) + } + if preOpts.EventStreams != nil { + implOpts = append(implOpts, optpreview.EventStreams(preOpts.EventStreams...)) + } + + return s.stack.Preview(ctx, implOpts...) +} + +// Up creates or updates the resources in a stack by executing the program in the Workspace. +// https://www.pulumi.com/docs/reference/cli/pulumi_up/ +// This operation runs remotely. +func (s *RemoteStack) Up(ctx context.Context, opts ...optremoteup.Option) (UpResult, error) { + upOpts := &optremoteup.Options{} + for _, o := range opts { + o.ApplyOption(upOpts) + } + + implOpts := []optup.Option{} + if upOpts.ProgressStreams != nil { + implOpts = append(implOpts, optup.ProgressStreams(upOpts.ProgressStreams...)) + } + if upOpts.ErrorProgressStreams != nil { + implOpts = append(implOpts, optup.ErrorProgressStreams(upOpts.ErrorProgressStreams...)) + } + if upOpts.EventStreams != nil { + implOpts = append(implOpts, optup.EventStreams(upOpts.EventStreams...)) + } + + return s.stack.Up(ctx, implOpts...) +} + +// Refresh 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. +// This operation runs remotely. +func (s *RemoteStack) Refresh(ctx context.Context, opts ...optremoterefresh.Option) (RefreshResult, error) { + refreshOpts := &optremoterefresh.Options{} + for _, o := range opts { + o.ApplyOption(refreshOpts) + } + + implOpts := []optrefresh.Option{} + if refreshOpts.ProgressStreams != nil { + implOpts = append(implOpts, optrefresh.ProgressStreams(refreshOpts.ProgressStreams...)) + } + if refreshOpts.ErrorProgressStreams != nil { + implOpts = append(implOpts, optrefresh.ErrorProgressStreams(refreshOpts.ErrorProgressStreams...)) + } + if refreshOpts.EventStreams != nil { + implOpts = append(implOpts, optrefresh.EventStreams(refreshOpts.EventStreams...)) + } + + return s.stack.Refresh(ctx, implOpts...) +} + +// Destroy deletes all resources in a stack, leaving all history and configuration intact. +// This operation runs remotely. +func (s *RemoteStack) Destroy(ctx context.Context, opts ...optremotedestroy.Option) (DestroyResult, error) { + destroyOpts := &optremotedestroy.Options{} + for _, o := range opts { + o.ApplyOption(destroyOpts) + } + + implOpts := []optdestroy.Option{} + if destroyOpts.ProgressStreams != nil { + implOpts = append(implOpts, optdestroy.ProgressStreams(destroyOpts.ProgressStreams...)) + } + if destroyOpts.ErrorProgressStreams != nil { + implOpts = append(implOpts, optdestroy.ErrorProgressStreams(destroyOpts.ErrorProgressStreams...)) + } + if destroyOpts.EventStreams != nil { + implOpts = append(implOpts, optdestroy.EventStreams(destroyOpts.EventStreams...)) + } + + return s.stack.Destroy(ctx, implOpts...) +} + +// Outputs get the current set of Stack outputs from the last Stack.Up(). +func (s *RemoteStack) Outputs(ctx context.Context) (OutputMap, error) { + return s.stack.Workspace().StackOutputs(ctx, s.Name()) +} + +// History returns a list summarizing all previous and current results from Stack lifecycle operations +// (up/preview/refresh/destroy). +func (s *RemoteStack) History(ctx context.Context, pageSize, page int) ([]UpdateSummary, error) { + // Note: Find a way to allow options for ShowSecrets(true) that doesn't require loading the project. + return s.stack.History(ctx, pageSize, page, opthistory.ShowSecrets(false)) +} + +// 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. +func (s *RemoteStack) Cancel(ctx context.Context) error { + return s.stack.Cancel(ctx) +} + +// Export exports the deployment state of the stack. +// This can be combined with Stack.Import to edit a stack's state (such as recovery from failed deployments). +func (s *RemoteStack) Export(ctx context.Context) (apitype.UntypedDeployment, error) { + return s.stack.Workspace().ExportStack(ctx, s.Name()) +} + +// Import imports the specified deployment state into the stack. +// This can be combined with Stack.Export to edit a stack's state (such as recovery from failed deployments). +func (s *RemoteStack) Import(ctx context.Context, state apitype.UntypedDeployment) error { + return s.stack.Workspace().ImportStack(ctx, s.Name(), state) +} diff --git a/sdk/go/auto/remote_workspace.go b/sdk/go/auto/remote_workspace.go new file mode 100644 index 000000000000..e472d7a3494d --- /dev/null +++ b/sdk/go/auto/remote_workspace.go @@ -0,0 +1,195 @@ +// 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 auto + +import ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// PREVIEW: NewRemoteStackGitSource creates a Stack backed by a RemoteWorkspace with source code from the specified +// GitRepo. Pulumi operations on the stack (Preview, Update, Refresh, and Destroy) are performed remotely. +func NewRemoteStackGitSource( + ctx context.Context, + stackName string, repo GitRepo, + opts ...RemoteWorkspaceOption, +) (RemoteStack, error) { + if !isFullyQualifiedStackName(stackName) { + return RemoteStack{}, fmt.Errorf("%q stack name must be fully qualified", stackName) + } + + localOpts, err := remoteToLocalOptions(repo, opts...) + if err != nil { + return RemoteStack{}, err + } + w, err := NewLocalWorkspace(ctx, localOpts...) + if err != nil { + return RemoteStack{}, errors.Wrap(err, "failed to create stack") + } + + s, err := NewStack(ctx, stackName, w) + if err != nil { + return RemoteStack{}, err + } + return RemoteStack{stack: s}, nil +} + +// PREVIEW: UpsertRemoteStackGitSource creates a Stack backed by a RemoteWorkspace with source code from the +// specified GitRepo. If the Stack already exists, it will not error and proceed with returning the Stack. +// Pulumi operations on the stack (Preview, Update, Refresh, and Destroy) are performed remotely. +func UpsertRemoteStackGitSource( + ctx context.Context, + stackName string, repo GitRepo, + opts ...RemoteWorkspaceOption, +) (RemoteStack, error) { + if !isFullyQualifiedStackName(stackName) { + return RemoteStack{}, fmt.Errorf("%q stack name must be fully qualified", stackName) + } + + localOpts, err := remoteToLocalOptions(repo, opts...) + if err != nil { + return RemoteStack{}, err + } + w, err := NewLocalWorkspace(ctx, localOpts...) + if err != nil { + return RemoteStack{}, errors.Wrap(err, "failed to create stack") + } + + s, err := UpsertStack(ctx, stackName, w) + if err != nil { + return RemoteStack{}, err + } + return RemoteStack{stack: s}, nil +} + +// PREVIEW: SelectRemoteStackGitSource selects an existing Stack backed by a RemoteWorkspace with source code from the +// specified GitRepo. Pulumi operations on the stack (Preview, Update, Refresh, and Destroy) are performed remotely. +func SelectRemoteStackGitSource( + ctx context.Context, + stackName string, repo GitRepo, + opts ...RemoteWorkspaceOption, +) (RemoteStack, error) { + if !isFullyQualifiedStackName(stackName) { + return RemoteStack{}, fmt.Errorf("%q stack name must be fully qualified", stackName) + } + + localOpts, err := remoteToLocalOptions(repo, opts...) + if err != nil { + return RemoteStack{}, err + } + w, err := NewLocalWorkspace(ctx, localOpts...) + if err != nil { + return RemoteStack{}, errors.Wrap(err, "failed to select stack") + } + + s, err := SelectStack(ctx, stackName, w) + if err != nil { + return RemoteStack{}, err + } + return RemoteStack{stack: s}, nil +} + +func remoteToLocalOptions(repo GitRepo, opts ...RemoteWorkspaceOption) ([]LocalWorkspaceOption, error) { + if repo.Setup != nil { + return nil, errors.New("repo.Setup cannot be used with remote workspaces") + } + if repo.URL == "" { + return nil, errors.New("repo.URL is required") + } + if repo.CommitHash != "" && repo.Branch != "" { + return nil, errors.New("repo.CommitHash and repo.Branch cannot both be specified") + } + if repo.CommitHash == "" && repo.Branch == "" { + return nil, errors.New("at least repo.CommitHash or repo.Branch are required") + } + if repo.Auth != nil { + if repo.Auth.SSHPrivateKey != "" && repo.Auth.SSHPrivateKeyPath != "" { + return nil, errors.New("repo.Auth.SSHPrivateKey and repo.Auth.SSHPrivateKeyPath cannot both be specified") + } + } + + remoteOpts := &remoteWorkspaceOptions{} + for _, o := range opts { + o.applyOption(remoteOpts) + } + + for k, v := range remoteOpts.EnvVars { + if k == "" { + return nil, errors.New("envvar cannot be empty") + } + if v.Value == "" { + return nil, fmt.Errorf("envvar %q cannot have an empty value", k) + } + } + + for index, command := range remoteOpts.PreRunCommands { + if command == "" { + return nil, fmt.Errorf("pre run command at index %v cannot be empty", index) + } + } + + localOpts := []LocalWorkspaceOption{ + remote(true), + remoteEnvVars(remoteOpts.EnvVars), + preRunCommands(remoteOpts.PreRunCommands...), + Repo(repo), + } + return localOpts, nil +} + +type remoteWorkspaceOptions struct { + // EnvVars is a map of environment values scoped to the workspace. + // These values will be passed to all Workspace and Stack level commands. + EnvVars map[string]EnvVarValue + // PreRunCommands is an optional list of arbitrary commands to run before the remote Pulumi operation is invoked. + PreRunCommands []string +} + +// LocalWorkspaceOption is used to customize and configure a LocalWorkspace at initialization time. +// See Workdir, Program, PulumiHome, Project, Stacks, and Repo for concrete options. +type RemoteWorkspaceOption interface { + applyOption(*remoteWorkspaceOptions) +} + +type remoteWorkspaceOption func(*remoteWorkspaceOptions) + +func (o remoteWorkspaceOption) applyOption(opts *remoteWorkspaceOptions) { + o(opts) +} + +// RemoteEnvVars is a map of environment values scoped to the remote workspace. +// These will be passed to remote operations. +func RemoteEnvVars(envvars map[string]EnvVarValue) RemoteWorkspaceOption { + return remoteWorkspaceOption(func(opts *remoteWorkspaceOptions) { + opts.EnvVars = envvars + }) +} + +// RemotePreRunCommands is an optional list of arbitrary commands to run before the remote Pulumi operation is invoked. +func RemotePreRunCommands(commands ...string) RemoteWorkspaceOption { + return remoteWorkspaceOption(func(opts *remoteWorkspaceOptions) { + opts.PreRunCommands = commands + }) +} + +// isFullyQualifiedStackName returns true if the stack is fully qualified, +// i.e. has owner, project, and stack components. +func isFullyQualifiedStackName(stackName string) bool { + split := strings.Split(stackName, "/") + return len(split) == 3 && split[0] != "" && split[1] != "" && split[2] != "" +} diff --git a/sdk/go/auto/remote_workspace_test.go b/sdk/go/auto/remote_workspace_test.go new file mode 100644 index 000000000000..0929828edb88 --- /dev/null +++ b/sdk/go/auto/remote_workspace_test.go @@ -0,0 +1,49 @@ +// 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 auto + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsFullyQualifiedStackName(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input string + expected bool + }{ + "fully qualified": {input: "owner/project/stack", expected: true}, + "empty": {input: "", expected: false}, + "name": {input: "name", expected: false}, + "name & owner": {input: "owner/name", expected: false}, + "sep": {input: "/", expected: false}, + "two seps": {input: "//", expected: false}, + "three seps": {input: "///", expected: false}, + "invalid": {input: "owner/project/stack/wat", expected: false}, + } + + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + actual := isFullyQualifiedStackName(tc.input) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/sdk/go/auto/stack.go b/sdk/go/auto/stack.go index 5661e1d52cd0..db9f951e3087 100644 --- a/sdk/go/auto/stack.go +++ b/sdk/go/auto/stack.go @@ -1,4 +1,4 @@ -// Copyright 2016-2020, 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. @@ -266,6 +266,9 @@ func (s *Stack) Preview(ctx context.Context, opts ...optpreview.Option) (Preview sharedArgs = append(sharedArgs, fmt.Sprintf("--save-plan=%s", preOpts.Plan)) } + // Apply the remote args, if needed. + sharedArgs = append(sharedArgs, s.remoteArgs()...) + kind, args := constant.ExecKindAutoLocal, []string{"preview"} if program := s.Workspace().Program(); program != nil { server, err := startLanguageRuntimeServer(program) @@ -384,6 +387,9 @@ func (s *Stack) Up(ctx context.Context, opts ...optup.Option) (UpResult, error) sharedArgs = append(sharedArgs, fmt.Sprintf("--plan=%s", upOpts.Plan)) } + // Apply the remote args, if needed. + sharedArgs = append(sharedArgs, s.remoteArgs()...) + kind, args := constant.ExecKindAutoLocal, []string{"up", "--yes", "--skip-preview"} if program := s.Workspace().Program(); program != nil { server, err := startLanguageRuntimeServer(program) @@ -421,6 +427,11 @@ func (s *Stack) Up(ctx context.Context, opts ...optup.Option) (UpResult, error) if upOpts.ShowSecrets != nil { historyOpts = append(historyOpts, opthistory.ShowSecrets(*upOpts.ShowSecrets)) } + // If it's a remote workspace, explicitly set ShowSecrets to false to prevent attempting to + // load the project file. + if s.isRemote() { + historyOpts = append(historyOpts, opthistory.ShowSecrets(false)) + } history, err := s.History(ctx, 1 /*pageSize*/, 1 /*page*/, historyOpts...) if err != nil { return res, err @@ -487,6 +498,9 @@ func (s *Stack) Refresh(ctx context.Context, opts ...optrefresh.Option) (Refresh args = append(args, "--event-log", t.Filename) } + // Apply the remote args, if needed. + args = append(args, s.remoteArgs()...) + stdout, stderr, code, err := s.runPulumiCmdSync( ctx, refreshOpts.ProgressStreams, /* additionalOutputs */ @@ -501,6 +515,11 @@ func (s *Stack) Refresh(ctx context.Context, opts ...optrefresh.Option) (Refresh if showSecrets := refreshOpts.ShowSecrets; showSecrets != nil { historyOpts = append(historyOpts, opthistory.ShowSecrets(*showSecrets)) } + // If it's a remote workspace, explicitly set ShowSecrets to false to prevent attempting to + // load the project file. + if s.isRemote() { + historyOpts = append(historyOpts, opthistory.ShowSecrets(false)) + } history, err := s.History(ctx, 1 /*pageSize*/, 1 /*page*/, historyOpts...) if err != nil { return res, errors.Wrap(err, "failed to refresh stack") @@ -567,6 +586,9 @@ func (s *Stack) Destroy(ctx context.Context, opts ...optdestroy.Option) (Destroy args = append(args, "--event-log", t.Filename) } + // Apply the remote args, if needed. + args = append(args, s.remoteArgs()...) + stdout, stderr, code, err := s.runPulumiCmdSync( ctx, destroyOpts.ProgressStreams, /* additionalOutputs */ @@ -581,6 +603,11 @@ func (s *Stack) Destroy(ctx context.Context, opts ...optdestroy.Option) (Destroy if showSecrets := destroyOpts.ShowSecrets; showSecrets != nil { historyOpts = append(historyOpts, opthistory.ShowSecrets(*showSecrets)) } + // If it's a remote workspace, explicitly set ShowSecrets to false to prevent attempting to + // load the project file. + if s.isRemote() { + historyOpts = append(historyOpts, opthistory.ShowSecrets(false)) + } history, err := s.History(ctx, 1 /*pageSize*/, 1 /*page*/, historyOpts...) if err != nil { return res, errors.Wrap(err, "failed to destroy stack") @@ -609,10 +636,6 @@ func (s *Stack) Outputs(ctx context.Context) (OutputMap, error) { // (up/preview/refresh/destroy). func (s *Stack) History(ctx context.Context, pageSize int, page int, opts ...opthistory.Option) ([]UpdateSummary, error) { - err := s.Workspace().SelectStack(ctx, s.Name()) - if err != nil { - return nil, errors.Wrap(err, "failed to get stack history") - } var options opthistory.Options for _, opt := range opts { opt.ApplyOption(&options) @@ -876,6 +899,16 @@ func (s *Stack) runPulumiCmdSync( var env []string debugEnv := fmt.Sprintf("%s=%s", "PULUMI_DEBUG_COMMANDS", "true") env = append(env, debugEnv) + + var remote bool + if lws, isLocalWorkspace := s.Workspace().(*LocalWorkspace); isLocalWorkspace { + remote = lws.remote + } + if remote { + experimentalEnv := fmt.Sprintf("%s=%s", "PULUMI_EXPERIMENTAL", "true") + env = append(env, experimentalEnv) + } + if s.Workspace().PulumiHome() != "" { homeEnv := fmt.Sprintf("%s=%s", pulumiHomeEnv, s.Workspace().PulumiHome()) env = append(env, homeEnv) @@ -911,6 +944,79 @@ func (s *Stack) runPulumiCmdSync( return stdout, stderr, errCode, nil } +func (s *Stack) isRemote() bool { + var remote bool + if lws, isLocalWorkspace := s.Workspace().(*LocalWorkspace); isLocalWorkspace { + remote = lws.remote + } + return remote +} + +func (s *Stack) remoteArgs() []string { + var remote bool + var repo *GitRepo + var preRunCommands []string + var envvars map[string]EnvVarValue + if lws, isLocalWorkspace := s.Workspace().(*LocalWorkspace); isLocalWorkspace { + remote = lws.remote + repo = lws.repo + preRunCommands = lws.preRunCommands + envvars = lws.remoteEnvVars + } + if !remote { + return nil + } + + var args []string + args = append(args, "--remote") + if repo != nil { + if repo.URL != "" { + args = append(args, repo.URL) + } + if repo.Branch != "" { + args = append(args, fmt.Sprintf("--remote-git-branch=%s", repo.Branch)) + } + if repo.CommitHash != "" { + args = append(args, fmt.Sprintf("--remote-git-commit=%s", repo.CommitHash)) + } + if repo.ProjectPath != "" { + args = append(args, fmt.Sprintf("--remote-git-repo-dir=%s", repo.ProjectPath)) + } + if repo.Auth != nil { + if repo.Auth.PersonalAccessToken != "" { + args = append(args, fmt.Sprintf("--remote-git-auth-access-token=%s", repo.Auth.PersonalAccessToken)) + } + if repo.Auth.SSHPrivateKey != "" { + args = append(args, fmt.Sprintf("--remote-git-auth-ssh-private-key=%s", repo.Auth.SSHPrivateKey)) + } + if repo.Auth.SSHPrivateKeyPath != "" { + args = append(args, + fmt.Sprintf("--remote-git-auth-ssh-private-key-path=%s", repo.Auth.SSHPrivateKeyPath)) + } + if repo.Auth.Password != "" { + args = append(args, fmt.Sprintf("--remote-git-auth-password=%s", repo.Auth.Password)) + } + if repo.Auth.Username != "" { + args = append(args, fmt.Sprintf("--remote-git-auth-username=%s", repo.Auth.Username)) + } + } + } + + for k, v := range envvars { + flag := "--remote-env" + if v.Secret { + flag += "-secret" + } + args = append(args, fmt.Sprintf("%s=%s=%s", flag, k, v.Value)) + } + + for _, command := range preRunCommands { + args = append(args, fmt.Sprintf("--remote-pre-run-command=%s", command)) + } + + return args +} + const ( stateWaiting = iota stateRunning diff --git a/sdk/nodejs/automation/index.ts b/sdk/nodejs/automation/index.ts index 44b33518d0a9..8fff1f0777b1 100644 --- a/sdk/nodejs/automation/index.ts +++ b/sdk/nodejs/automation/index.ts @@ -1,4 +1,4 @@ -// Copyright 2016-2020, 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. @@ -21,3 +21,5 @@ export * from "./projectSettings"; export * from "./localWorkspace"; export * from "./workspace"; export * from "./events"; +export * from "./remoteStack"; +export * from "./remoteWorkspace"; diff --git a/sdk/nodejs/automation/localWorkspace.ts b/sdk/nodejs/automation/localWorkspace.ts index 5c64ea841562..14e3111f8b82 100644 --- a/sdk/nodejs/automation/localWorkspace.ts +++ b/sdk/nodejs/automation/localWorkspace.ts @@ -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. @@ -22,6 +22,7 @@ import { CommandResult, runPulumiCmd } from "./cmd"; import { ConfigMap, ConfigValue } from "./config"; import { minimumVersion } from "./minimumVersion"; import { ProjectSettings } from "./projectSettings"; +import { RemoteGitProgramArgs } from "./remoteWorkspace"; import { OutputMap, Stack } from "./stack"; import * as localState from "../runtime/state"; import { StackSettings, stackSettingsSerDeKeys } from "./stackSettings"; @@ -79,6 +80,27 @@ export class LocalWorkspace implements Workspace { return this._pulumiVersion.toString(); } private ready: Promise; + + /** + * Whether the workspace is a remote workspace. + */ + private remote?: boolean; + + /** + * Remote Git source info. + */ + private remoteGitProgramArgs?: RemoteGitProgramArgs; + + /** + * An optional list of arbitrary commands to run before the remote Pulumi operation is invoked. + */ + private remotePreRunCommands?: string[]; + + /** + * The environment variables to pass along when running remote Pulumi operations. + */ + private remoteEnvVars?: { [key: string]: string | { secret: string } }; + /** * Creates a workspace using the specified options. Used for maximal control and customization * of the underlying environment before any stacks are created or selected. @@ -223,13 +245,18 @@ export class LocalWorkspace implements Workspace { localState.asyncLocalStorage.enterWith(store); if (opts) { - const { workDir, pulumiHome, program, envVars, secretsProvider } = opts; + const { workDir, pulumiHome, program, envVars, secretsProvider, + remote, remoteGitProgramArgs, remotePreRunCommands, remoteEnvVars } = opts; if (workDir) { dir = workDir; } this.pulumiHome = pulumiHome; this.program = program; this.secretsProvider = secretsProvider; + this.remote = remote; + this.remoteGitProgramArgs = remoteGitProgramArgs; + this.remotePreRunCommands = remotePreRunCommands; + this.remoteEnvVars = { ...remoteEnvVars }; envs = { ...envVars }; } @@ -363,6 +390,9 @@ export class LocalWorkspace implements Workspace { if (this.secretsProvider) { args.push("--secrets-provider", this.secretsProvider); } + if (this.isRemote) { + args.push("--no-select"); + } await this.runPulumiCmd(args); } /** @@ -371,7 +401,15 @@ export class LocalWorkspace implements Workspace { * @param stackName The stack to select. */ async selectStack(stackName: string): Promise { - await this.runPulumiCmd(["stack", "select", stackName]); + // 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`. + const args = ["stack"]; + if (!this.isRemote) { + args.push("select"); + } + args.push("--stack", stackName); + + await this.runPulumiCmd(args); } /** * Deletes the stack and all associated configuration and history. @@ -611,6 +649,16 @@ export class LocalWorkspace implements Workspace { if (version != null) { this._pulumiVersion = version; } + + // If remote was specified, ensure the CLI supports it. + if (!optOut && this.isRemote) { + // See if `--remote` is present in `pulumi preview --help`'s output. + const previewResult = await this.runPulumiCmd(["preview", "--help"]); + const previewOutput = previewResult.stdout.trim(); + if (!previewOutput.includes("--remote")) { + throw new Error("The Pulumi CLI does not support remote operations. Please upgrade."); + } + } } private async runPulumiCmd( args: string[], @@ -619,9 +667,75 @@ export class LocalWorkspace implements Workspace { if (this.pulumiHome) { envs["PULUMI_HOME"] = this.pulumiHome; } + if (this.isRemote) { + envs["PULUMI_EXPERIMENTAL"] = "true"; + } envs = { ...envs, ...this.envVars }; return runPulumiCmd(args, this.workDir, envs); } + /** @internal */ + get isRemote(): boolean { + return !!this.remote; + } + /** @internal */ + remoteArgs(): string[] { + const args: string[] = []; + if (!this.isRemote) { + return args; + } + + args.push("--remote"); + if (this.remoteGitProgramArgs) { + const { url, projectPath, branch, commitHash, auth } = this.remoteGitProgramArgs; + if (url) { + args.push(url); + } + if (projectPath) { + args.push("--remote-git-repo-dir", projectPath); + } + if (branch) { + args.push("--remote-git-branch", branch); + } + if (commitHash) { + args.push("--remote-git-commit", commitHash); + } + if (auth) { + const { personalAccessToken, sshPrivateKey, sshPrivateKeyPath, password, username } = auth; + if (personalAccessToken) { + args.push("--remote-git-auth-access-token", personalAccessToken); + } + if (sshPrivateKey) { + args.push("--remote-git-auth-ssh-private-key", sshPrivateKey); + } + if (sshPrivateKeyPath) { + args.push("--remote-git-auth-ssh-private-key-path", sshPrivateKeyPath); + } + if (password) { + args.push("--remote-git-auth-password", password); + } + if (username) { + args.push("--remote-git-username", username); + } + } + } + + for (const key of Object.keys(this.remoteEnvVars ?? {})) { + const val = this.remoteEnvVars![key]; + if (typeof val === "string") { + args.push("--remote-env", `${key}=${val}`); + } else if ("secret" in val) { + args.push("--remote-env-secret", `${key}=${val.secret}`); + } else { + throw new Error(`unexpected env value '${val}' for key '${key}'`); + } + } + + for (const command of this.remotePreRunCommands ?? []) { + args.push("--remote-pre-run-command", command); + } + + return args; + } } /** @@ -685,6 +799,30 @@ export interface LocalWorkspaceOptions { * A map of Stack names and corresponding settings objects. */ stackSettings?: { [key: string]: StackSettings }; + /** + * Indicates that the workspace is a remote workspace. + * + * @internal + */ + remote?: boolean; + /** + * The remote Git source info. + * + * @internal + */ + remoteGitProgramArgs?: RemoteGitProgramArgs; + /** + * An optional list of arbitrary commands to run before a remote Pulumi operation is invoked. + * + * @internal + */ + remotePreRunCommands?: string[]; + /** + * The environment variables to pass along when running remote Pulumi operations. + * + * @internal + */ + remoteEnvVars?: { [key: string]: string | { secret: string } }; } /** diff --git a/sdk/nodejs/automation/remoteStack.ts b/sdk/nodejs/automation/remoteStack.ts new file mode 100644 index 000000000000..8261dda3c159 --- /dev/null +++ b/sdk/nodejs/automation/remoteStack.ts @@ -0,0 +1,170 @@ +// 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 { EngineEvent } from "./events"; +import { LocalWorkspace } from "./localWorkspace"; +import { + DestroyResult, + OutputMap, + PreviewResult, + RefreshResult, + Stack, + UpdateSummary, + UpResult, +} from "./stack"; +import { Deployment } from "./workspace"; + +/** + * RemoteStack is an isolated, independencly configurable instance of a Pulumi program that is + * operated on remotely (up/preview/refresh/destroy). + */ +export class RemoteStack { + /** @internal */ + static create(stack: Stack): RemoteStack { + return new RemoteStack(stack); + } + + private constructor(private readonly stack: Stack) { + const ws = stack.workspace; + if (!(ws instanceof LocalWorkspace)) { + throw new Error("expected workspace to be an instance of LocalWorkspace"); + } + } + + /** + * The name identifying the Stack. + */ + get name(): string { + return this.stack.name; + } + + /** + * Creates or updates the resources in a stack by executing the program in the Workspace. + * https://www.pulumi.com/docs/reference/cli/pulumi_up/ + * This operation runs remotely. + * + * @param opts Options to customize the behavior of the update. + */ + up(opts?: RemoteUpOptions): Promise { + return this.stack.up(opts); + } + + /** + * Performs a dry-run update to a stack, returning pending changes. + * https://www.pulumi.com/docs/reference/cli/pulumi_preview/ + * This operation runs remotely. + * + * @param opts Options to customize the behavior of the preview. + */ + preview(opts?: RemotePreviewOptions): Promise { + return this.stack.preview(opts); + } + + /** + * 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. + * This operation runs remotely. + * + * @param opts Options to customize the behavior of the refresh. + */ + refresh(opts?: RemoteRefreshOptions): Promise { + return this.stack.refresh(opts); + } + + /** + * Destroy deletes all resources in a stack, leaving all history and configuration intact. + * This operation runs remotely. + * + * @param opts Options to customize the behavior of the destroy. + */ + destroy(opts?: RemoteDestroyOptions): Promise { + return this.stack.destroy(opts); + } + + /** + * Gets the current set of Stack outputs from the last Stack.up(). + */ + outputs(): Promise { + return this.stack.outputs(); + } + + /** + * Returns a list summarizing all previous and current results from Stack lifecycle operations + * (up/preview/refresh/destroy). + */ + history(pageSize?: number, page?: number): Promise { + // TODO: Find a way to allow showSecrets as an option that doesn't require loading the project. + return this.stack.history(pageSize, page, false); + } + + /** + * 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. + */ + cancel(): Promise { + return this.stack.cancel(); + } + + /** + * exportStack exports the deployment state of the stack. + * This can be combined with Stack.importStack to edit a stack's state (such as recovery from failed deployments). + */ + exportStack(): Promise { + return this.stack.exportStack(); + } + + /** + * importStack imports the specified deployment state into a pre-existing stack. + * This can be combined with Stack.exportStack to edit a stack's state (such as recovery from failed deployments). + * + * @param state the stack state to import. + */ + importStack(state: Deployment): Promise { + return this.stack.importStack(state); + } +} + +/** + * Options controlling the behavior of a RemoteStack.up() operation. + */ +export interface RemoteUpOptions { + onOutput?: (out: string) => void; + onEvent?: (event: EngineEvent) => void; +} + +/** + * Options controlling the behavior of a RemoteStack.preview() operation. + */ +export interface RemotePreviewOptions { + onOutput?: (out: string) => void; + onEvent?: (event: EngineEvent) => void; +} + +/** + * Options controlling the behavior of a RemoteStack.refresh() operation. + */ +export interface RemoteRefreshOptions { + onOutput?: (out: string) => void; + onEvent?: (event: EngineEvent) => void; +} + +/** + * Options controlling the behavior of a RemoteStack.destroy() operation. + */ +export interface RemoteDestroyOptions { + onOutput?: (out: string) => void; + onEvent?: (event: EngineEvent) => void; +} diff --git a/sdk/nodejs/automation/remoteWorkspace.ts b/sdk/nodejs/automation/remoteWorkspace.ts new file mode 100644 index 000000000000..fa7c3e024805 --- /dev/null +++ b/sdk/nodejs/automation/remoteWorkspace.ts @@ -0,0 +1,186 @@ +// 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 { LocalWorkspace, LocalWorkspaceOptions } from "./localWorkspace"; +import { RemoteStack } from "./remoteStack"; +import { Stack } from "./stack"; + +/** + * RemoteWorkspace is the execution context containing a single remote Pulumi project. + */ +export class RemoteWorkspace { + /** + * 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. + * + * @param args A set of arguments to initialize a RemoteStack with a remote Pulumi program from a Git repository. + * @param opts Additional customizations to be applied to the Workspace. + */ + static async createStack(args: RemoteGitProgramArgs, opts?: RemoteWorkspaceOptions): Promise { + const ws = await createLocalWorkspace(args, opts); + const stack = await Stack.create(args.stackName, ws); + return RemoteStack.create(stack); + } + + /** + * PREVIEW: 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. + * + * @param args A set of arguments to initialize a RemoteStack with a remote Pulumi program from a Git repository. + * @param opts Additional customizations to be applied to the Workspace. + */ + static async selectStack(args: RemoteGitProgramArgs, opts?: RemoteWorkspaceOptions): Promise { + const ws = await createLocalWorkspace(args, opts); + const stack = await Stack.select(args.stackName, ws); + return RemoteStack.create(stack); + } + /** + * 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. + * + * @param args A set of arguments to initialize a RemoteStack with a remote Pulumi program from a Git repository. + * @param opts Additional customizations to be applied to the Workspace. + */ + static async createOrSelectStack(args: RemoteGitProgramArgs, opts?: RemoteWorkspaceOptions): Promise { + const ws = await createLocalWorkspace(args, opts); + const stack = await Stack.createOrSelect(args.stackName, ws); + return RemoteStack.create(stack); + } + + private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function +} + +/** + * Description of a stack backed by a remote Pulumi program in a Git repository. + */ +export interface RemoteGitProgramArgs { + /** + * The name of the associated Stack + */ + stackName: string; + + /** + * The URL of the repository. + */ + url: string; + + /** + * Optional path relative to the repo root specifying location of the Pulumi program. + */ + projectPath?: string; + + /** + * Optional branch to checkout. + */ + branch?: string; + + /** + * Optional commit to checkout. + */ + commitHash?: string; + + /** + * Authentication options for the repository. + */ + auth?: RemoteGitAuthArgs; +} + +/** + * 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. + */ +export interface RemoteGitAuthArgs { + /** + * The absolute path to a private key for access to the git repo. + */ + sshPrivateKeyPath?: string; + + /** + * The (contents) private key for access to the git repo. + */ + sshPrivateKey?: string; + + /** + * The password that pairs with a username or as part of an SSH Private Key. + */ + password?: string; + + /** + * PersonalAccessToken is a Git personal access token in replacement of your password. + */ + personalAccessToken?: string; + + /** + * Username is the username to use when authenticating to a git repository + */ + username?: string; +} + +/** + * Extensibility options to configure a RemoteWorkspace. + */ +export interface RemoteWorkspaceOptions { + /** + * Environment values scoped to the remote workspace. These will be passed to remote operations. + */ + envVars?: { [key: string]: string | { secret: string } }; + + /** + * An optional list of arbitrary commands to run before a remote Pulumi operation is invoked. + */ + preRunCommands?: string[]; +} + +async function createLocalWorkspace(args: RemoteGitProgramArgs, opts?: RemoteWorkspaceOptions): Promise { + if (!isFullyQualifiedStackName(args.stackName)) { + throw new Error(`"${args.stackName}" stack name must be fully qualified`); + } + + if (!args.url) { + throw new Error("url is required."); + } + if (args.commitHash && args.branch) { + throw new Error("commitHash and branch cannot both be specified."); + } + if (!args.commitHash && !args.branch) { + throw new Error("at least commitHash or branch are required."); + } + if (args.auth) { + if (args.auth.sshPrivateKey && args.auth.sshPrivateKeyPath) { + throw new Error("sshPrivateKey and sshPrivateKeyPath cannot both be specified."); + } + } + + const localOpts: LocalWorkspaceOptions = { + remote: true, + remoteGitProgramArgs: args, + remoteEnvVars: opts?.envVars, + remotePreRunCommands: opts?.preRunCommands, + }; + return await LocalWorkspace.create(localOpts); +} + +/** @internal exported only so it can be tested */ +export function isFullyQualifiedStackName(stackName: string): boolean { + if (!stackName) { + return false; + } + const split = stackName.split("/"); + return split.length === 3 && !!split[0] && !!split[1] && !!split[2]; +} diff --git a/sdk/nodejs/automation/stack.ts b/sdk/nodejs/automation/stack.ts index 806b1ffd86f4..9dbd021d6f01 100644 --- a/sdk/nodejs/automation/stack.ts +++ b/sdk/nodejs/automation/stack.ts @@ -1,4 +1,4 @@ -// Copyright 2016-2020, 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. @@ -28,6 +28,7 @@ import { StackAlreadyExistsError } from "./errors"; import { EngineEvent, SummaryEvent } from "./events"; import { LanguageServer, maxRPCMessageSize } from "./server"; import { Deployment, PulumiFn, Workspace } from "./workspace"; +import { LocalWorkspace } from "./localWorkspace"; const langrpc = require("../proto/language_grpc_pb.js"); @@ -152,6 +153,8 @@ Event: ${line}\n${e.toString()}`); let kind = execKind.local; let program = this.workspace.program; + args.push(...this.remoteArgs()); + if (opts) { if (opts.program) { program = opts.program; @@ -255,7 +258,9 @@ Event: ${line}\n${e.toString()}`); // TODO: do this in parallel after this is fixed https://github.com/pulumi/pulumi/issues/6050 const outputs = await this.outputs(); - const summary = await this.info(opts?.showSecrets); + // If it's a remote workspace, explicitly set showSecrets to false to prevent attempting to + // load the project file. + const summary = await this.info(!this.isRemote && opts?.showSecrets); return { stdout: upResult.stdout, @@ -275,6 +280,8 @@ Event: ${line}\n${e.toString()}`); let kind = execKind.local; let program = this.workspace.program; + args.push(...this.remoteArgs()); + if (opts) { if (opts.program) { program = opts.program; @@ -396,6 +403,8 @@ Event: ${line}\n${e.toString()}`); async refresh(opts?: RefreshOptions): Promise { const args = ["refresh", "--yes", "--skip-preview"]; + args.push(...this.remoteArgs()); + if (opts) { if (opts.message) { args.push("--message", opts.message); @@ -437,7 +446,9 @@ Event: ${line}\n${e.toString()}`); const [refResult, logResult] = await Promise.all([refPromise, logPromise]); await cleanUp(logFile, logResult); - const summary = await this.info(opts?.showSecrets); + // If it's a remote workspace, explicitly set showSecrets to false to prevent attempting to + // load the project file. + const summary = await this.info(!this.isRemote && opts?.showSecrets); return { stdout: refResult.stdout, stderr: refResult.stderr, @@ -452,6 +463,8 @@ Event: ${line}\n${e.toString()}`); async destroy(opts?: DestroyOptions): Promise { const args = ["destroy", "--yes", "--skip-preview"]; + args.push(...this.remoteArgs()); + if (opts) { if (opts.message) { args.push("--message", opts.message); @@ -493,7 +506,9 @@ Event: ${line}\n${e.toString()}`); const [desResult, logResult] = await Promise.all([desPromise, logPromise]); await cleanUp(logFile, logResult); - const summary = await this.info(opts?.showSecrets); + // If it's a remote workspace, explicitly set showSecrets to false to prevent attempting to + // load the project file. + const summary = await this.info(!this.isRemote && opts?.showSecrets); return { stdout: desResult.stdout, stderr: desResult.stderr, @@ -622,6 +637,9 @@ Event: ${line}\n${e.toString()}`); let envs: { [key: string]: string } = { "PULUMI_DEBUG_COMMANDS": "true", }; + if (this.isRemote) { + envs["PULUMI_EXPERIMENTAL"] = "true"; + } const pulumiHome = this.workspace.pulumiHome; if (pulumiHome) { envs["PULUMI_HOME"] = pulumiHome; @@ -633,6 +651,16 @@ Event: ${line}\n${e.toString()}`); await this.workspace.postCommandCallback(this.name); return result; } + + private get isRemote(): boolean { + const ws = this.workspace; + return ws instanceof LocalWorkspace ? ws.isRemote : false; + } + + private remoteArgs(): string[] { + const ws = this.workspace; + return ws instanceof LocalWorkspace ? ws.remoteArgs() : []; + } } function applyGlobalOpts(opts: GlobalOpts, args: string[]) { diff --git a/sdk/nodejs/tests/automation/remoteWorkspace.spec.ts b/sdk/nodejs/tests/automation/remoteWorkspace.spec.ts new file mode 100644 index 000000000000..8479cb64622d --- /dev/null +++ b/sdk/nodejs/tests/automation/remoteWorkspace.spec.ts @@ -0,0 +1,79 @@ +// 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 assert from "assert"; + +import { isFullyQualifiedStackName } from "../../automation"; + +describe("isFullyQualifiedStackName", () => { + const tests = [ + { + name: "fully qualified", + input: "owner/project/stack", + expected: true, + }, + { + name: "undefined", + input: undefined, + expected: false, + }, + { + name: "null", + input: null, + expected: false, + }, + { + name: "empty", + input: "", + expected: false, + }, + { + name: "name", + input: "name", + expected: false, + }, + { + name: "name & owner", + input: "owner/name", + expected: false, + }, + { + name: "sep", + input: "/", + expected: false, + }, + { + name: "two seps", + input: "//", + expected: false, + }, + { + name: "three seps", + input: "///", + expected: false, + }, + { + name: "invalid", + input: "owner/project/stack/wat", + expected: false, + }, + ]; + + tests.forEach(test => { + it(`${test.name}`, () => { + const actual = isFullyQualifiedStackName(test.input!); + assert.strictEqual(actual, test.expected); + }); + }); +}); diff --git a/sdk/nodejs/tsconfig.json b/sdk/nodejs/tsconfig.json index 0f7205a14fe4..8804972f62b0 100644 --- a/sdk/nodejs/tsconfig.json +++ b/sdk/nodejs/tsconfig.json @@ -78,6 +78,8 @@ "automation/localWorkspace.ts", "automation/minimumVersion.ts", "automation/projectSettings.ts", + "automation/remoteStack.ts", + "automation/remoteWorkspace.ts", "automation/stackSettings.ts", "automation/server.ts", "automation/stack.ts", @@ -105,6 +107,7 @@ "tests/automation/cmd.spec.ts", "tests/automation/localWorkspace.spec.ts", + "tests/automation/remoteWorkspace.spec.ts", "tests_with_mocks/mocks.spec.ts" ] 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..5b86b0e6fa55 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,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]) @@ -373,6 +400,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 +413,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..53cbe58289d3 --- /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.create_or_select(stack_name, ws) + 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.select(stack_name, ws) + 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..c2a63378ee6b 100644 --- a/sdk/python/lib/pulumi/automation/_stack.py +++ b/sdk/python/lib/pulumi/automation/_stack.py @@ -257,6 +257,8 @@ def up( args.append("--plan") args.append(plan) + args.extend(self._remote_args()) + kind = ExecKind.LOCAL.value on_exit = None @@ -299,7 +301,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._remote) assert summary is not None finally: _cleanup(temp_dir, log_watcher_thread, on_exit) @@ -370,6 +374,8 @@ def preview( args.append("--save-plan") args.append(plan) + args.extend(self._remote_args()) + kind = ExecKind.LOCAL.value on_exit = None @@ -470,6 +476,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 +496,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._remote) assert summary is not None return RefreshResult( stdout=refresh_result.stdout, stderr=refresh_result.stderr, summary=summary @@ -535,6 +545,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 +565,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._remote) assert summary is not None return DestroyResult( stdout=destroy_result.stdout, stderr=destroy_result.stderr, summary=summary @@ -715,6 +729,8 @@ def _run_pulumi_cmd_sync( self, args: List[str], on_output: Optional[OnOutput] = None ) -> CommandResult: envs = {"PULUMI_DEBUG_COMMANDS": "true"} + if self._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 +742,27 @@ def _run_pulumi_cmd_sync( self.workspace.post_command_callback(self.name) return result + @property + def _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