Skip to content

Commit

Permalink
cli(state upgrade): Prompt for project names for detached stacks
Browse files Browse the repository at this point in the history
When running 'pulumi state upgrade', supply the
ProjectsForDetachedStacks option to the file state backend so that we
get asked to fill in project names for stacks where we could not guess
them automatically.

The implementation of the prompt is straightforward:
For each stack, ask a question with the survey package
and feed the result back to the filestate backend.

Testing this is a bit complicated because terminals are involved.
The test for this uses the go-expect and vt10x libraries
recommended in the documentation for survey.
It uses them to simulate a terminal emulator and acts on the output.
The pty library is used to create a compatible pseduo-terminal.
Unfortunately, these test libraries rely on Unix APIs and are not
available on Windows, so the test will not run on Windows machines.

Resolves #12600
  • Loading branch information
abhinav committed Jun 13, 2023
1 parent 3e6cc0d commit 9841557
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 4 deletions.
@@ -0,0 +1,4 @@
changes:
- type: feat
scope: cli/state
description: The upgrade command now prompts the user to supply project names for stacks for which the project name could not be automatically guessed.
88 changes: 87 additions & 1 deletion pkg/cmd/pulumi/state_upgrade.go
Expand Up @@ -20,10 +20,14 @@ import (
"io"
"os"

survey "github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/pulumi/pulumi/pkg/v3/backend"
"github.com/pulumi/pulumi/pkg/v3/backend/display"
"github.com/pulumi/pulumi/pkg/v3/backend/filestate"
"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"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"

Expand Down Expand Up @@ -54,6 +58,7 @@ This only has an effect on self-managed backends.
type stateUpgradeCmd struct {
Stdin io.Reader // defaults to os.Stdin
Stdout io.Writer // defaults to os.Stdout
Stderr io.Writer // defaults to os.Stderr

// Used to mock out the currentBackend function for testing.
// Defaults to currentBackend function.
Expand All @@ -67,6 +72,9 @@ func (cmd *stateUpgradeCmd) Run(ctx context.Context) error {
if cmd.Stdin == nil {
cmd.Stdin = os.Stdin
}
if cmd.Stderr == nil {
cmd.Stderr = os.Stderr
}

if cmd.currentBackend == nil {
cmd.currentBackend = currentBackend
Expand Down Expand Up @@ -101,5 +109,83 @@ func (cmd *stateUpgradeCmd) Run(ctx context.Context) error {
return nil
}

return lb.Upgrade(ctx, nil /*opts*/)
var opts filestate.UpgradeOptions
// If we're in interactive mode, prompt for the project name
// for each stack that doesn't have one.
if cmdutil.Interactive() {
opts.ProjectsForDetachedStacks = cmd.projectsForDetachedStacks
}
return lb.Upgrade(ctx, &opts)
}

func (cmd *stateUpgradeCmd) projectsForDetachedStacks(stacks []tokens.Name) ([]tokens.Name, error) {
projects := make([]tokens.Name, len(stacks))
err := (&stateUpgradeProjectNameWidget{
Stdin: cmd.Stdin,
Stdout: cmd.Stdout,
Stderr: cmd.Stderr,
}).Prompt(stacks, projects)
return projects, err
}

// stateUpgradeProjectNameWidget is a widget that prompts the user
// for a project name for every stack that doesn't have one.
//
// It is used by the 'pulumi state upgrade' command
// when it encounters stacks without a project name.
type stateUpgradeProjectNameWidget struct {
Stdin io.Reader // required
Stdout io.Writer // required
Stderr io.Writer // required
}

// Prompt prompts the user for a project name for each stack
// and stores the result in the corresponding index of projects.
//
// The length of projects must be equal to the length of stacks.
func (w *stateUpgradeProjectNameWidget) Prompt(stacks, projects []tokens.Name) error {
contract.Assertf(len(stacks) == len(projects),
"length of stacks (%d) must equal length of projects (%d)", len(stacks), len(projects))

if len(stacks) == 0 {
// Nothing to prompt for.
return nil
}

stdin, ok1 := w.Stdin.(terminal.FileReader)
stdout, ok2 := w.Stdout.(terminal.FileWriter)
if !ok1 || !ok2 {
// We're not using a real terminal, so we can't prompt.
// Pretend we're in non-interactive mode.
return nil
}

fmt.Fprintln(stdout, "Found stacks without a project name.")
fmt.Fprintln(stdout, "Please enter a project name for each stack, or enter to skip that stack.")
for i, stack := range stacks {
var project string
err := survey.AskOne(
&survey.Input{
Message: fmt.Sprintf("Stack %s", stack),
Help: "Enter a name for the project, or press enter to skip",
},
&project,
survey.WithStdio(stdin, stdout, w.Stderr),
survey.WithValidator(w.validateProject),
)
if err != nil {
return fmt.Errorf("prompt for %q: %w", stack, err)
}

projects[i] = tokens.Name(project)
}

return nil
}

func (w *stateUpgradeProjectNameWidget) validateProject(ans any) error {
proj, ok := ans.(string)
contract.Assertf(ok, "widget should have a string output, got %T", ans)

return tokens.ValidateProjectName(proj)
}
151 changes: 151 additions & 0 deletions pkg/cmd/pulumi/state_upgrade_test.go
Expand Up @@ -5,12 +5,18 @@ import (
"context"
"errors"
"io"
"runtime"
"strings"
"testing"

"github.com/Netflix/go-expect"
"github.com/creack/pty"
"github.com/hinshun/vt10x"
"github.com/pulumi/pulumi/pkg/v3/backend"
"github.com/pulumi/pulumi/pkg/v3/backend/display"
"github.com/pulumi/pulumi/pkg/v3/backend/filestate"
"github.com/pulumi/pulumi/sdk/v3/go/common/testing/iotest"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -155,6 +161,151 @@ func TestStateUpgradeCmd_Run_backendError(t *testing.T) {
assert.ErrorIs(t, err, giveErr)
}

//nolint:paralleltest // subtests have shared state
func TestStateUpgradeProjectNameWidget(t *testing.T) {
t.Parallel()

// Checks the behavior of the prompt for project names
// when they're missing.

if runtime.GOOS == "windows" {
t.Skip("Skipping: Cannot create pseudo-terminal on Windows")
}

// This is difficult to test because of how terminal-based this is.
// To test this:
//
// - We set up a pseduo-terminal (with the pty package).
// This will tell survey that it's running in an interactive terminal.
// - We connect that to the expect package,
// which lets us simulate user input and read the output.
// - Lastly, expect doesn't actually interpret terminal escape sequences,
// so we pass the output of survey through a vt100 terminal emulator
// (with the vt10x package), allowing expect to operate on plain text.

ptty, tty, err := pty.Open()
require.NoError(t, err, "creating pseudo-terminal")

console, err := expect.NewConsole(
expect.WithStdin(ptty),
expect.WithStdout(vt10x.New(vt10x.WithWriter(tty))),
expect.WithCloser(ptty, tty),
)
require.NoError(t, err, "creating console")
defer func() {
assert.NoError(t, console.Close(), "close console")
}()

expect := func(t *testing.T, s string) {
t.Helper()

t.Logf("expect(%q)", s)
_, err := console.ExpectString(s)
require.NoError(t, err)
}

sendLine := func(t *testing.T, s string) {
t.Helper()

t.Logf("send(%q)", s)
_, err := console.SendLine(s)
require.NoError(t, err)
}

donec := make(chan struct{})
defer func() { <-donec }()
go func() {
defer close(donec)

stacks := []tokens.Name{"foo", "bar", "baz"}
projects := make([]tokens.Name, len(stacks))

err := (&stateUpgradeProjectNameWidget{
Stdin: console.Tty(),
Stdout: console.Tty(),
Stderr: iotest.LogWriterPrefixed(t, "[stderr] "),
}).Prompt(stacks, projects)
assert.NoError(t, err, "prompt failed")
assert.Equal(t, []tokens.Name{"foo-project", "", "baz-project"}, projects)

// We need to close the TTY after we're done here
// so that ExpectEOF unblocks.
assert.NoError(t, console.Tty().Close(), "close tty")
}()

expect(t, "Found stacks without a project name")

// Subtests must be run serially, in-order
// because they share the same console.

t.Run("valid name", func(t *testing.T) {
expect(t, "Stack foo")
sendLine(t, "foo-project")
})

t.Run("bad name", func(t *testing.T) {
expect(t, "Stack bar")
sendLine(t, "not a valid project name")
expect(t, "project names may only contain alphanumerics")
})

t.Run("skip", func(t *testing.T) {
expect(t, "Stack bar")
sendLine(t, "")
})

t.Run("long name", func(t *testing.T) {
expect(t, "Stack baz")
sendLine(t, strings.Repeat("a", 101)) // max length is 100
expect(t, "project names are limited to 100 characters")
})

t.Run("recovery after bad name", func(t *testing.T) {
expect(t, "Stack baz")
sendLine(t, "baz-project")
})

// ExpectEOF blocks until the console reaches EOF on its input.
// This will happen when the widget exits and closes the TTY.
_, err = console.ExpectEOF()
assert.NoError(t, err, "expect EOF")
}

func TestStateUpgradeProjectNameWidget_noStacks(t *testing.T) {
t.Parallel()

ptty, tty, err := pty.Open()
require.NoError(t, err, "creating pseudo-terminal")
defer func() {
assert.NoError(t, ptty.Close())
assert.NoError(t, tty.Close())
}()

err = (&stateUpgradeProjectNameWidget{
Stdin: tty,
Stdout: tty,
Stderr: iotest.LogWriterPrefixed(t, "[stderr] "),
}).Prompt([]tokens.Name{}, []tokens.Name{})
require.NoError(t, err)
}

func TestStateUpgradeProjectNameWidget_notATerminal(t *testing.T) {
t.Parallel()

stacks := []tokens.Name{"foo", "bar", "baz"}
projects := make([]tokens.Name, len(stacks))

err := (&stateUpgradeProjectNameWidget{
Stdin: bytes.NewReader(nil),
Stdout: bytes.NewBuffer(nil),
Stderr: iotest.LogWriterPrefixed(t, "[stderr] "),
}).Prompt(stacks, projects)
require.NoError(t, err)

// No change expected.
assert.Equal(t, []tokens.Name{"", "", ""}, projects)
}

type stubFileBackend struct {
filestate.Backend

Expand Down
6 changes: 3 additions & 3 deletions pkg/go.mod
Expand Up @@ -62,14 +62,17 @@ require (
require (
github.com/AlecAivazis/survey/v2 v2.0.5
github.com/BurntSushi/toml v1.2.1
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
github.com/aws/aws-sdk-go-v2 v1.17.3
github.com/aws/aws-sdk-go-v2/config v1.15.15
github.com/aws/aws-sdk-go-v2/service/iam v1.19.0
github.com/aws/aws-sdk-go-v2/service/kms v1.18.1
github.com/aws/aws-sdk-go-v2/service/sts v1.16.10
github.com/creack/pty v1.1.17
github.com/edsrzf/mmap-go v1.1.0
github.com/go-git/go-git/v5 v5.6.0
github.com/hexops/gotextdiff v1.0.3
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
github.com/json-iterator/go v1.1.12
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/muesli/cancelreader v0.2.2
Expand Down Expand Up @@ -111,7 +114,6 @@ require (
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
Expand All @@ -138,7 +140,6 @@ require (
github.com/cheggaaa/pb v1.0.29 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/creack/pty v1.1.17 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/ettle/strcase v0.1.1 // indirect
Expand Down Expand Up @@ -175,7 +176,6 @@ require (
github.com/hashicorp/vault/api v1.8.2 // indirect
github.com/hashicorp/vault/sdk v0.6.1 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
Expand Down

0 comments on commit 9841557

Please sign in to comment.