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/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