pulumi/pkg/cmd/pulumi/state_edit.go

276 lines
7.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"
"errors"
"fmt"
"io"
"os"
"os/exec"
"reflect"
"github.com/google/shlex"
"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/stack"
pkgWorkspace "github.com/pulumi/pulumi/pkg/v3/workspace"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/spf13/cobra"
)
func newStateEditCommand() *cobra.Command {
var stackName string
stateEdit := &stateEditCmd{
Colorizer: cmdutil.GetGlobalColorization(),
}
cmd := &cobra.Command{
Use: "edit",
// TODO(dixler) Add test for unicode round-tripping before unhiding.
// TODO(fraser) This needs tests _in general_ it is currently basically untested.
Hidden: !hasExperimentalCommands(),
Short: "Edit the current stack's state in your EDITOR",
Long: `[EXPERIMENTAL] Edit the current stack's state in your EDITOR
This command can be used to surgically edit a stack's state in the editor
specified by the EDITOR environment variable and will provide the user with
a preview showing a diff of the altered state.`,
Args: cmdutil.NoArgs,
Run: runCmdFunc(func(cmd *cobra.Command, args []string) error {
if !cmdutil.Interactive() {
return errors.New("pulumi state edit must be run in interactive mode")
}
ctx := cmd.Context()
ws := pkgWorkspace.Instance
s, err := requireStack(ctx, ws, DefaultLoginManager, stackName, stackLoadOnly, display.Options{
Color: cmdutil.GetGlobalColorization(),
IsInteractive: true,
})
if err != nil {
return err
}
if err := stateEdit.Run(ctx, s); err != nil {
return err
}
return nil
}),
}
cmd.PersistentFlags().StringVar(
&stackName, "stack", "",
"The name of the stack to operate on. Defaults to the current stack")
return cmd
}
type stateEditCmd struct {
Stdin io.Reader
Stdout io.Writer
Colorizer colors.Colorization
}
type snapshotBuffer struct {
Name func() string
Snapshot func(ctx context.Context) (*deploy.Snapshot, error)
Reset func() error
Cleanup func()
originalText snapshotText
}
func newSnapshotBuffer(fileExt string, sf snapshotEncoder, snap *deploy.Snapshot) (*snapshotBuffer, error) {
tempFile, err := os.CreateTemp("", "pulumi-state-edit-*"+fileExt)
if err != nil {
return nil, err
}
tempFile.Close()
originalText, err := sf.SnapshotToText(snap)
if err != nil {
// Warn that the snapshot is already hosed.
cmdutil.Diag().Errorf(diag.RawMessage("", fmt.Sprintf("initial state unable to be serialized: %v", err)))
}
t := &snapshotBuffer{
Name: func() string { return tempFile.Name() },
Snapshot: func(ctx context.Context) (*deploy.Snapshot, error) {
b, err := os.ReadFile(tempFile.Name())
if err != nil {
return nil, err
}
return sf.TextToSnapshot(ctx, snapshotText(b))
},
Reset: func() error {
return os.WriteFile(tempFile.Name(), originalText, 0o600)
},
Cleanup: func() {
os.Remove(tempFile.Name())
},
originalText: originalText,
}
if err := t.Reset(); err != nil {
t.Cleanup()
return nil, err
}
return t, nil
}
func (cmd *stateEditCmd) Run(ctx context.Context, s backend.Stack) error {
contract.Requiref(ctx != nil, "ctx", "must not be nil")
if cmd.Stdin == nil {
cmd.Stdin = os.Stdin
}
if cmd.Stdout == nil {
cmd.Stdout = os.Stdout
}
snap, err := s.Snapshot(ctx, stack.DefaultSecretsProvider)
if err != nil {
return err
}
if snap == nil {
return errors.New("old snapshot expected to be non-nil")
}
sf := &jsonSnapshotEncoder{}
f, err := newSnapshotBuffer(".json", sf, snap)
if err != nil {
return err
}
defer f.Cleanup()
for {
err = openInEditor(f.Name())
if err != nil {
return err
}
fmt.Fprintf(cmd.Stdout, cmd.Colorizer.Colorize(
colors.SpecHeadline+"Previewing state edit (%s)"+colors.Reset+"\n\n"), s.Ref().FullyQualifiedName())
accept := "accept"
edit := "edit"
reset := "reset"
cancel := "cancel"
var msg string
var options []string
news, err := cmd.validateAndPrintState(ctx, f)
if errors.Is(err, errNoStateChange) {
cmdutil.Diag().Warningf(diag.Message("", "provided state was not changed"))
return nil
} else if err != nil {
cmdutil.Diag().Errorf(diag.Message("", "provided state is not valid: %v"), err)
msg = "Received invalid state. What would you like to do?"
options = []string{
// No accept option as the state is invalid.
edit,
reset,
cancel,
}
} else {
msg = "Do you want to perform this edit?"
options = []string{
accept,
edit,
reset,
cancel,
}
}
switch response := promptUser(msg, options, edit, cmd.Colorizer); response {
case accept:
return saveSnapshot(ctx, s, news, false /* force */)
case edit:
continue
case reset:
if err := f.Reset(); err != nil {
return err
}
continue
default:
return errors.New("confirmation cancelled, not proceeding with the state edit")
}
}
}
var errNoStateChange = errors.New("No state change")
func (cmd *stateEditCmd) validateAndPrintState(ctx context.Context, f *snapshotBuffer) (*deploy.Snapshot, error) {
contract.Requiref(ctx != nil, "ctx", "must not be nil")
news, err := f.Snapshot(ctx)
if err != nil {
return nil, err
}
if !backend.DisableIntegrityChecking {
err = news.VerifyIntegrity()
if err != nil {
return nil, err
}
}
// Display state in JSON to match JSON-like diffs in the update display.
json := &jsonSnapshotEncoder{}
previewText, err := json.SnapshotToText(news)
if err != nil {
// This should not fail as we have already verified the integrity of the snapshot.
return nil, err
}
if reflect.DeepEqual(f.originalText, previewText) {
return nil, errNoStateChange
}
fmt.Fprint(cmd.Stdout, cmd.Colorizer.Colorize(
colors.SpecHeadline+"New state:"+colors.Reset+"\n"))
fmt.Fprintln(cmd.Stdout, string(previewText))
return news, nil
}
func openInEditor(filename string) error {
editor := os.Getenv("EDITOR")
if editor == "" {
return errors.New("no EDITOR environment variable set")
}
return openInEditorInternal(editor, filename)
}
func openInEditorInternal(editor, filename string) error {
contract.Requiref(editor != "", "editor", "must not be empty")
args, err := shlex.Split(editor)
if err != nil {
return err
}
args = append(args, filename)
cmd := exec.Command(args[0], args[1:]...) //nolint:gosec
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(cmd.Stdout, "Failed to exec EDITOR: %v\n", err)
return err
}
return nil
}