pulumi/pkg/cmd/pulumi/state.go

267 lines
9.2 KiB
Go

// Copyright 2016-2023, 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 main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
survey "github.com/AlecAivazis/survey/v2"
surveycore "github.com/AlecAivazis/survey/v2/core"
"github.com/spf13/cobra"
"github.com/pulumi/pulumi/pkg/v3/backend"
"github.com/pulumi/pulumi/pkg/v3/backend/display"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/resource/edit"
"github.com/pulumi/pulumi/pkg/v3/resource/stack"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/result"
)
func newStateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "state",
Short: "Edit the current stack's state",
Long: `Edit the current stack's state
Subcommands of this command can be used to surgically edit parts of a stack's state. These can be useful when
troubleshooting a stack or when performing specific edits that otherwise would require editing the state file by hand.`,
Args: cmdutil.NoArgs,
}
cmd.AddCommand(newStateEditCommand())
cmd.AddCommand(newStateDeleteCommand())
cmd.AddCommand(newStateUnprotectCommand())
cmd.AddCommand(newStateRenameCommand())
cmd.AddCommand(newStateUpgradeCommand())
return cmd
}
// locateStackResource attempts to find a unique resource associated with the given URN in the given snapshot. If the
// given URN is ambiguous and this is an interactive terminal, it prompts the user to select one of the resources in
// the list of resources with identical URNs to operate upon.
func locateStackResource(opts display.Options, snap *deploy.Snapshot, urn resource.URN) (*resource.State, error) {
candidateResources := edit.LocateResource(snap, urn)
switch {
case len(candidateResources) == 0: // resource was not found
return nil, fmt.Errorf("No such resource %q exists in the current state", urn)
case len(candidateResources) == 1: // resource was unambiguously found
return candidateResources[0], nil
}
// If there exist multiple resources that have the requested URN, prompt the user to select one if we're running
// interactively. If we're not, early exit.
if !cmdutil.Interactive() {
errorMsg := "Resource URN ambiguously referred to multiple resources. Did you mean:\n"
for _, res := range candidateResources {
errorMsg += fmt.Sprintf(" %s\n", res.ID)
}
return nil, errors.New(errorMsg)
}
// Note: this is done to adhere to the same color scheme as the `pulumi new` picker, which also does this.
surveycore.DisableColor = true
prompt := "Multiple resources with the given URN exist, please select the one to edit:"
prompt = opts.Color.Colorize(colors.SpecPrompt + prompt + colors.Reset)
options := slice.Prealloc[string](len(candidateResources))
optionMap := make(map[string]*resource.State)
for _, ambiguousResource := range candidateResources {
// Prompt the user to select from a list of IDs, since these resources are known to all have the same URN.
message := fmt.Sprintf("%q", ambiguousResource.ID)
if ambiguousResource.Protect {
message += " (Protected)"
}
if ambiguousResource.Delete {
message += " (Pending Deletion)"
}
options = append(options, message)
optionMap[message] = ambiguousResource
}
var option string
if err := survey.AskOne(&survey.Select{
Message: prompt,
Options: options,
PageSize: optimalPageSize(optimalPageSizeOpts{nopts: len(options)}),
}, &option, surveyIcons(opts.Color)); err != nil {
return nil, errors.New("no resource selected")
}
return optionMap[option], nil
}
// runStateEdit runs the given state edit function on a resource with the given URN in a given stack.
func runStateEdit(
ctx context.Context, stackName string, showPrompt bool,
urn resource.URN, operation edit.OperationFunc,
) error {
return runTotalStateEdit(ctx, stackName, showPrompt, func(opts display.Options, snap *deploy.Snapshot) error {
res, err := locateStackResource(opts, snap, urn)
if err != nil {
return err
}
return operation(snap, res)
})
}
// runTotalStateEdit runs a snapshot-mutating function on the entirety of the given stack's snapshot.
// Before mutating, the user may be prompted to for confirmation if the current session is interactive.
func runTotalStateEdit(
ctx context.Context, stackName string, showPrompt bool,
operation func(opts display.Options, snap *deploy.Snapshot) error,
) error {
opts := display.Options{
Color: cmdutil.GetGlobalColorization(),
}
s, err := requireStack(ctx, stackName, stackOfferNew, opts)
if err != nil {
return err
}
return totalStateEdit(ctx, s, showPrompt, opts, operation)
}
func totalStateEdit(ctx context.Context, s backend.Stack, showPrompt bool, opts display.Options,
operation func(opts display.Options, snap *deploy.Snapshot) error,
) error {
snap, err := s.Snapshot(ctx, stack.DefaultSecretsProvider)
if err != nil {
return err
} else if snap == nil {
return nil
}
if showPrompt && cmdutil.Interactive() {
confirm := false
surveycore.DisableColor = true
prompt := opts.Color.Colorize(colors.Yellow + "warning" + colors.Reset + ": ")
prompt += "This command will edit your stack's state directly. Confirm?"
if err = survey.AskOne(&survey.Confirm{
Message: prompt,
}, &confirm, surveyIcons(opts.Color)); err != nil || !confirm {
return result.FprintBailf(os.Stdout, "confirmation declined")
}
}
// The `operation` callback will mutate `snap` in-place. In order to validate the correctness of the transformation
// that we are doing here, we verify the integrity of the snapshot before the mutation. If the snapshot was valid
// before we mutated it, we'll assert that we didn't make it invalid by mutating it.
stackIsAlreadyHosed := snap.VerifyIntegrity() != nil
if err = operation(opts, snap); err != nil {
return err
}
// If the stack is already broken, don't bother verifying the integrity here.
if !stackIsAlreadyHosed {
contract.AssertNoErrorf(snap.VerifyIntegrity(), "state edit produced an invalid snapshot")
}
sdep, err := stack.SerializeDeployment(snap, snap.SecretsManager, false /* showSecrets */)
if err != nil {
return fmt.Errorf("serializing deployment: %w", err)
}
// Once we've mutated the snapshot, import it back into the backend so that it can be persisted.
bytes, err := json.Marshal(sdep)
if err != nil {
return err
}
dep := apitype.UntypedDeployment{
Version: apitype.DeploymentSchemaVersionCurrent,
Deployment: bytes,
}
return s.ImportDeployment(ctx, &dep)
}
// Prompt the user to select a URN from the passed in state.
//
// stackName is the name of the current stack.
//
// snap is the snapshot of the current stack. If (*snap) is not nil, it will be set to
// the retrieved snapshot value. This allows caching between calls.
//
// Prompt is displayed to the user when selecting the URN.
func getURNFromState(
ctx context.Context, stackName string, snap **deploy.Snapshot, prompt string,
) (resource.URN, error) {
if snap == nil {
// This means we won't cache the value.
snap = new(*deploy.Snapshot)
}
if *snap == nil {
opts := display.Options{
Color: cmdutil.GetGlobalColorization(),
}
s, err := requireStack(ctx, stackName, stackLoadOnly, opts)
if err != nil {
return "", err
}
*snap, err = s.Snapshot(ctx, stack.DefaultSecretsProvider)
if err != nil {
return "", err
}
}
urnList := make([]string, len((*snap).Resources))
for i, r := range (*snap).Resources {
urnList[i] = string(r.URN)
}
var urn string
err := survey.AskOne(&survey.Select{
Message: prompt,
Options: urnList,
}, &urn, survey.WithValidator(survey.Required), surveyIcons(cmdutil.GetGlobalColorization()))
if err != nil {
return "", err
}
result := resource.URN(urn)
contract.Assertf(result.IsValid(),
"Because we chose from an existing URN, it must be valid")
return result, nil
}
// Ask the user for a resource name.
func getNewResourceName() (tokens.QName, error) {
var resourceName string
err := survey.AskOne(&survey.Input{
Message: "Choose a new resource name:",
}, &resourceName, surveyIcons(cmdutil.GetGlobalColorization()),
survey.WithValidator(func(ans interface{}) error {
if tokens.IsQName(ans.(string)) {
return nil
}
return fmt.Errorf("resource names may only contain alphanumerics, underscores, hyphens, dots, and slashes")
}))
if err != nil {
return "", err
}
contract.Assertf(tokens.IsQName(resourceName),
"Survey validated that resourceName %q is a QName", resourceName)
return tokens.QName(resourceName), nil
}