Change backend.ListStacks to return a new StackSummary interface (#1931)

* Have backend.ListStacks return a new StackSummary interface

* Update filestake backend to use new type

* Update httpstate backend to use new type

* Update commands to use new type

* lint

* Address PR feedback

* Lint
This commit is contained in:
Chris Smith 2018-09-13 20:54:42 -07:00 committed by GitHub
parent 3642cca98f
commit 792c316e5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 159 additions and 89 deletions

View file

@ -23,13 +23,11 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/pulumi/pulumi/pkg/backend"
"github.com/pulumi/pulumi/pkg/backend/display" "github.com/pulumi/pulumi/pkg/backend/display"
"github.com/pulumi/pulumi/pkg/backend/httpstate" "github.com/pulumi/pulumi/pkg/backend/httpstate"
"github.com/pulumi/pulumi/pkg/backend/state" "github.com/pulumi/pulumi/pkg/backend/state"
"github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/cmdutil" "github.com/pulumi/pulumi/pkg/util/cmdutil"
"github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pulumi/pulumi/pkg/workspace" "github.com/pulumi/pulumi/pkg/workspace"
) )
@ -57,7 +55,7 @@ func newStackLsCmd() *cobra.Command {
Color: cmdutil.GetGlobalColorization(), Color: cmdutil.GetGlobalColorization(),
} }
// Get a list of all known backends, as we will query them all. // Get the current backend.
b, err := currentBackend(opts) b, err := currentBackend(opts)
if err != nil { if err != nil {
return err return err
@ -75,93 +73,77 @@ func newStackLsCmd() *cobra.Command {
packageFilter = &proj.Name packageFilter = &proj.Name
} }
// Now produce a list of summaries, and enumerate them sorted by name. // List all of the stacks available.
var result error stackSummaries, err := b.ListStacks(commandContext(), packageFilter)
var stackNames []string
stacks := make(map[string]backend.Stack)
bs, err := b.ListStacks(commandContext(), packageFilter)
if err != nil { if err != nil {
return err return err
} }
// Sort by stack name.
sort.Slice(stackSummaries, func(i, j int) bool {
return stackSummaries[i].Name().String() < stackSummaries[j].Name().String()
})
_, showURLColumn := b.(httpstate.Backend) _, showURLColumn := b.(httpstate.Backend)
for _, stack := range bs {
name := stack.Ref().String()
stacks[name] = stack
stackNames = append(stackNames, name)
}
sort.Strings(stackNames)
// Devote 48 characters to the name width, unless there is a longer name. // Devote 48 characters to the name width, unless there is a longer name.
maxname := 48 maxName := 47
for _, name := range stackNames { for _, summary := range stackSummaries {
if len(name) > maxname { name := summary.Name().String()
maxname = len(name) if len(name) > maxName {
maxName = len(name)
} }
} }
maxName++ // Account for adding the '*' to the currently selected stack.
// We have to fault in snapshots for all the stacks we are going to list here, because that's the easiest // Header string and formatting options to align columns.
// way to get the last update time and the resource count. Since this is an expensive operation, we'll formatDirective := "%-" + strconv.Itoa(maxName) + "s %-24s %-18s"
// do it before printing any output so the latency happens all at once instead of line by line.
//
// TODO[pulumi/pulumi-service#1530]: We need a lighterweight way of fetching just the specific information
// we want to display here.
for _, name := range stackNames {
stack := stacks[name]
_, err := stack.Snapshot(commandContext())
contract.IgnoreError(err) // If we couldn't get snapshot for the stack don't fail the overall listing.
}
formatDirective := "%-" + strconv.Itoa(maxname) + "s %-24s %-18s"
headers := []interface{}{"NAME", "LAST UPDATE", "RESOURCE COUNT"} headers := []interface{}{"NAME", "LAST UPDATE", "RESOURCE COUNT"}
if showURLColumn { if showURLColumn {
formatDirective += " %s" formatDirective += " %s"
headers = append(headers, "URL") headers = append(headers, "URL")
} }
formatDirective = formatDirective + "\n" formatDirective = formatDirective + "\n"
fmt.Printf(formatDirective, headers...) fmt.Printf(formatDirective, headers...)
for _, name := range stackNames { for _, summary := range stackSummaries {
// Mark the name as current '*' if we've selected it. const none = "n/a"
stack := stacks[name]
// Name column
name := summary.Name().String()
if name == current { if name == current {
name += "*" name += "*"
} }
// Get last deployment info, provided that it exists. // Last update column
none := "n/a"
lastUpdate := none lastUpdate := none
resourceCount := none if stackLastUpdate := summary.LastUpdate(); stackLastUpdate != nil {
snap, err := stack.Snapshot(commandContext()) lastUpdate = humanize.Time(*stackLastUpdate)
contract.IgnoreError(err) // If we couldn't get snapshot for the stack don't fail the overall listing.
if snap != nil {
if t := snap.Manifest.Time; !t.IsZero() {
lastUpdate = humanize.Time(t)
}
resourceCount = strconv.Itoa(len(snap.Resources))
} }
// ResourceCount column
resourceCount := none
if stackResourceCount := summary.ResourceCount(); stackResourceCount != nil {
resourceCount = strconv.Itoa(*stackResourceCount)
}
// Render the columns.
values := []interface{}{name, lastUpdate, resourceCount} values := []interface{}{name, lastUpdate, resourceCount}
if showURLColumn { if showURLColumn {
var url string var url string
if cs, ok := stack.(httpstate.Stack); ok { if httpBackend, ok := b.(httpstate.Backend); ok {
if u, urlErr := cs.ConsoleURL(); urlErr == nil { if nameSuffix, err := httpBackend.StackConsoleURL(summary.Name()); err != nil {
url = u url = none
} else {
url = fmt.Sprintf("%s/%s", httpBackend.CloudURL(), nameSuffix)
} }
} }
if url == "" {
url = none
}
values = append(values, url) values = append(values, url)
} }
fmt.Printf(formatDirective, values...) fmt.Printf(formatDirective, values...)
} }
return result return nil
}), }),
} }
cmd.PersistentFlags().BoolVarP( cmd.PersistentFlags().BoolVarP(

View file

@ -163,7 +163,7 @@ func requireCurrentStack(offerNew bool, opts display.Options, setCurrent bool) (
return chooseStack(b, offerNew, opts, setCurrent) return chooseStack(b, offerNew, opts, setCurrent)
} }
// chooseStack will prompt the user to choose amongst the full set of stacks in the given backends. If offerNew is // chooseStack will prompt the user to choose amongst the full set of stacks in the given backend. If offerNew is
// true, then the option to create an entirely new stack is provided and will create one as desired. // true, then the option to create an entirely new stack is provided and will create one as desired.
func chooseStack( func chooseStack(
b backend.Backend, offerNew bool, opts display.Options, setCurrent bool) (backend.Stack, error) { b backend.Backend, offerNew bool, opts display.Options, setCurrent bool) (backend.Stack, error) {
@ -183,22 +183,20 @@ func chooseStack(
return nil, err return nil, err
} }
// First create a list and map of stack names. // List stacks as available options.
var options []string var options []string
stacks := make(map[string]backend.Stack) summaries, err := b.ListStacks(commandContext(), &proj.Name)
allStacks, err := b.ListStacks(commandContext(), &proj.Name)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "could not query backend for stacks") return nil, errors.Wrapf(err, "could not query backend for stacks")
} }
for _, stack := range allStacks { for _, summary := range summaries {
name := stack.Ref().String() name := summary.Name().String()
options = append(options, name) options = append(options, name)
stacks[name] = stack
} }
sort.Strings(options) sort.Strings(options)
// If we are offering to create a new stack, add that to the end of the list. // If we are offering to create a new stack, add that to the end of the list.
newOption := "<create a new stack>" const newOption = "<create a new stack>"
if offerNew { if offerNew {
options = append(options, newOption) options = append(options, newOption)
} else if len(options) == 0 { } else if len(options) == 0 {
@ -227,7 +225,7 @@ func chooseStack(
message = opts.Color.Colorize(colors.BrightWhite + message + colors.Reset) message = opts.Color.Colorize(colors.BrightWhite + message + colors.Reset)
var option string var option string
if err := survey.AskOne(&survey.Select{ if err = survey.AskOne(&survey.Select{
Message: message, Message: message,
Options: options, Options: options,
Default: current, Default: current,
@ -236,20 +234,30 @@ func chooseStack(
} }
if option == newOption { if option == newOption {
stackName, err := cmdutil.ReadConsole("Please enter your desired stack name") stackName, readErr := cmdutil.ReadConsole("Please enter your desired stack name")
if err != nil { if readErr != nil {
return nil, err return nil, readErr
} }
stackRef, err := b.ParseStackReference(stackName) stackRef, parseErr := b.ParseStackReference(stackName)
if err != nil { if parseErr != nil {
return nil, err return nil, parseErr
} }
return createStack(b, stackRef, nil, setCurrent) return createStack(b, stackRef, nil, setCurrent)
} }
return stacks[option], nil // With the stack name selected, look it up from the backend.
stackRef, err := b.ParseStackReference(option)
if err != nil {
return nil, errors.Wrap(err, "parsing selected stack")
}
stack, err := b.GetStack(commandContext(), stackRef)
if err != nil {
return nil, errors.Wrap(err, "getting selected stack")
}
return stack, nil
} }
// readProject attempts to detect and read the project for the current workspace. If an error occurs, it will be // readProject attempts to detect and read the project for the current workspace. If an error occurs, it will be

View file

@ -18,6 +18,7 @@ package backend
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -58,6 +59,16 @@ type StackReference interface {
Name() tokens.QName Name() tokens.QName
} }
// StackSummary provides a basic description of a stack, without the ability to inspect its resources or make changes.
type StackSummary interface {
Name() StackReference
// LastUpdate returns when the stack was last updated, as applicable.
LastUpdate() *time.Time
// ResourceCount returns the stack's resource count, as applicable.
ResourceCount() *int
}
// Backend is an interface that represents actions the engine will interact with to manage stacks of cloud resources. // Backend is an interface that represents actions the engine will interact with to manage stacks of cloud resources.
// It can be implemented any number of ways to provide pluggable backend implementations of the Pulumi Cloud. // It can be implemented any number of ways to provide pluggable backend implementations of the Pulumi Cloud.
type Backend interface { type Backend interface {
@ -79,7 +90,7 @@ type Backend interface {
// first boolean return value will be set to true. // first boolean return value will be set to true.
RemoveStack(ctx context.Context, stackRef StackReference, force bool) (bool, error) RemoveStack(ctx context.Context, stackRef StackReference, force bool) (bool, error)
// ListStacks returns a list of stack summaries for all known stacks in the target backend. // ListStacks returns a list of stack summaries for all known stacks in the target backend.
ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]Stack, error) ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]StackSummary, error)
// GetStackCrypter returns an encrypter/decrypter for the given stack's secret config values. // GetStackCrypter returns an encrypter/decrypter for the given stack's secret config values.
GetStackCrypter(stackRef StackReference) (config.Crypter, error) GetStackCrypter(stackRef StackReference) (config.Crypter, error)

View file

@ -176,19 +176,22 @@ func (b *localBackend) GetStack(ctx context.Context, stackRef backend.StackRefer
} }
} }
func (b *localBackend) ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]backend.Stack, error) { func (b *localBackend) ListStacks(
ctx context.Context, projectFilter *tokens.PackageName) ([]backend.StackSummary, error) {
stacks, err := b.getLocalStacks() stacks, err := b.getLocalStacks()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var results []backend.Stack var results []backend.StackSummary
for _, stackName := range stacks { for _, stackName := range stacks {
stack, err := b.GetStack(ctx, localBackendReference{name: stackName}) stack, err := b.GetStack(ctx, localBackendReference{name: stackName})
if err != nil { if err != nil {
return nil, err return nil, err
} }
results = append(results, stack) localStack, ok := stack.(*localStack)
contract.Assertf(ok, "localBackend GetStack returned non-localStack")
results = append(results, newLocalStackSummary(localStack))
} }
return results, nil return results, nil

View file

@ -16,6 +16,7 @@ package filestate
import ( import (
"context" "context"
"time"
"github.com/pulumi/pulumi/pkg/apitype" "github.com/pulumi/pulumi/pkg/apitype"
"github.com/pulumi/pulumi/pkg/backend" "github.com/pulumi/pulumi/pkg/backend"
@ -88,3 +89,34 @@ func (s *localStack) ExportDeployment(ctx context.Context) (*apitype.UntypedDepl
func (s *localStack) ImportDeployment(ctx context.Context, deployment *apitype.UntypedDeployment) error { func (s *localStack) ImportDeployment(ctx context.Context, deployment *apitype.UntypedDeployment) error {
return backend.ImportStackDeployment(ctx, s, deployment) return backend.ImportStackDeployment(ctx, s, deployment)
} }
type localStackSummary struct {
s *localStack
}
func newLocalStackSummary(s *localStack) localStackSummary {
return localStackSummary{s}
}
func (lss localStackSummary) Name() backend.StackReference {
return lss.s.Ref()
}
func (lss localStackSummary) LastUpdate() *time.Time {
snap := lss.s.snapshot
if snap != nil {
if t := snap.Manifest.Time; !t.IsZero() {
return &t
}
}
return nil
}
func (lss localStackSummary) ResourceCount() *int {
snap := lss.s.snapshot
if snap != nil {
count := len(snap.Resources)
return &count
}
return nil
}

View file

@ -416,7 +416,7 @@ func serveBrowserLoginServer(l net.Listener, expectedNonce string, destinationUR
} }
// CloudConsoleStackPath returns the stack path components for getting to a stack in the cloud console. This path // CloudConsoleStackPath returns the stack path components for getting to a stack in the cloud console. This path
// must, of coursee, be combined with the actual console base URL by way of the CloudConsoleURL function above. // must, of course, be combined with the actual console base URL by way of the CloudConsoleURL function above.
func (b *cloudBackend) cloudConsoleStackPath(stackID client.StackIdentifier) string { func (b *cloudBackend) cloudConsoleStackPath(stackID client.StackIdentifier) string {
return path.Join(stackID.Owner, stackID.Stack) return path.Join(stackID.Owner, stackID.Stack)
} }
@ -529,19 +529,24 @@ func (b *cloudBackend) CreateStack(ctx context.Context, stackRef backend.StackRe
return stack, nil return stack, nil
} }
func (b *cloudBackend) ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]backend.Stack, error) { func (b *cloudBackend) ListStacks(
stacks, err := b.client.ListStacks(ctx, projectFilter) ctx context.Context, projectFilter *tokens.PackageName) ([]backend.StackSummary, error) {
apiSummaries, err := b.client.ListStacks(ctx, projectFilter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Map to a summary slice. // Convert []apitype.StackSummary into []backend.StackSummary.
var results []backend.Stack var backendSummaries []backend.StackSummary
for _, stack := range stacks { for _, apiSummary := range apiSummaries {
results = append(results, newStack(stack, b)) backendSummary := cloudStackSummary{
summary: apiSummary,
b: b,
}
backendSummaries = append(backendSummaries, backendSummary)
} }
return results, nil return backendSummaries, nil
} }
func (b *cloudBackend) RemoveStack(ctx context.Context, stackRef backend.StackReference, force bool) (bool, error) { func (b *cloudBackend) RemoveStack(ctx context.Context, stackRef backend.StackReference, force bool) (bool, error) {

View file

@ -124,6 +124,8 @@ func pulumiAPICall(ctx context.Context, cloudAPI, method, path string, body []by
// backwards compatibility. // backwards compatibility.
userAgent := fmt.Sprintf("pulumi-cli/1 (%s; %s)", version.Version, runtime.GOOS) userAgent := fmt.Sprintf("pulumi-cli/1 (%s; %s)", version.Version, runtime.GOOS)
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
// Specify the specific API version we accept.
req.Header.Set("Accept", "application/vnd.pulumi+1")
// Apply credentials if provided. // Apply credentials if provided.
if tok.String() != "" { if tok.String() != "" {

View file

@ -149,11 +149,10 @@ func (pc *Client) GetCLIVersionInfo(ctx context.Context) (semver.Version, semver
return latestSem, oldestSem, nil return latestSem, oldestSem, nil
} }
// ListStacks lists all stacks for the indicated project. // ListStacks lists all stacks the current user has access to, optionally filtered by project.
func (pc *Client) ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]apitype.Stack, error) { func (pc *Client) ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]apitype.StackSummary, error) {
// Query all stacks for the project on Pulumi. var resp apitype.ListStacksResponse
var stacks []apitype.Stack
var queryFilter interface{} var queryFilter interface{}
if projectFilter != nil { if projectFilter != nil {
queryFilter = struct { queryFilter = struct {
@ -161,11 +160,11 @@ func (pc *Client) ListStacks(ctx context.Context, projectFilter *tokens.PackageN
}{ProjectFilter: string(*projectFilter)} }{ProjectFilter: string(*projectFilter)}
} }
if err := pc.restCall(ctx, "GET", "/api/user/stacks", queryFilter, nil, &stacks); err != nil { if err := pc.restCall(ctx, "GET", "/api/user/stacks", queryFilter, nil, &resp); err != nil {
return nil, err return nil, err
} }
return stacks, nil return resp.Stacks, nil
} }
// GetLatestConfiguration returns the configuration for the latest deployment of a given stack. // GetLatestConfiguration returns the configuration for the latest deployment of a given stack.

View file

@ -17,6 +17,7 @@ package httpstate
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/pulumi/pulumi/pkg/apitype" "github.com/pulumi/pulumi/pkg/apitype"
@ -153,3 +154,30 @@ func (s *cloudStack) ConsoleURL() (string, error) {
} }
return url, nil return url, nil
} }
// cloudStackSummary implements the backend.StackSummary interface, by wrapping
// an apitype.StackSummary struct.
type cloudStackSummary struct {
summary apitype.StackSummary
b *cloudBackend
}
func (css cloudStackSummary) Name() backend.StackReference {
return cloudBackendReference{
owner: css.summary.OrgName,
name: tokens.QName(css.summary.StackName),
b: css.b,
}
}
func (css cloudStackSummary) LastUpdate() *time.Time {
if css.summary.LastUpdate == nil {
return nil
}
t := time.Unix(*css.summary.LastUpdate, 0)
return &t
}
func (css cloudStackSummary) ResourceCount() *int {
return css.summary.ResourceCount
}