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