Add support for filtering stacks by organization, tag (#3108)

* Add support for filtering stacks by organization, tag

* Update CHANGELOG.md

* Address PR feedback

* Address even more PR feedback

* Support empty-string filters
This commit is contained in:
Chris Smith 2019-08-22 13:56:43 -07:00 committed by GitHub
parent e349f3c094
commit eb0934970c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 181 additions and 30 deletions

View file

@ -20,6 +20,9 @@ CHANGELOG
- Fix intermittet "NoSuchKey" issues when using the S3 based backend. (fixes [#2714](https://github.com/pulumi/pulumi/issues/2714)).
- Support filting stacks by organization or tags when using `pulumi stack ls`. (fixes [#2712](https://github.com/pulumi/pulumi/issues/),
[#2769](https://github.com/pulumi/pulumi/issues/2769)
- Explicitly setting `deleteBeforeReplace` to `false` now overrides the provider's decision.
[#3118](https://github.com/pulumi/pulumi/pull/3118)

View file

@ -17,6 +17,7 @@ package cmd
import (
"sort"
"strconv"
"strings"
"github.com/dustin/go-humanize"
"github.com/pkg/errors"
@ -26,21 +27,51 @@ import (
"github.com/pulumi/pulumi/pkg/backend/display"
"github.com/pulumi/pulumi/pkg/backend/httpstate"
"github.com/pulumi/pulumi/pkg/backend/state"
"github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/cmdutil"
"github.com/pulumi/pulumi/pkg/workspace"
)
func newStackLsCmd() *cobra.Command {
var allStacks bool
var jsonOut bool
var allStacks bool
var orgFilter string
var projFilter string
var tagFilter string
cmd := &cobra.Command{
Use: "ls",
Short: "List all known stacks",
Args: cmdutil.NoArgs,
Short: "List stacks",
Long: "List stacks\n" +
"\n" +
"This command lists stacks. By default only stacks with the same project name as the\n" +
"current workspace will be returned. By passing --all, all stacks you have access to\n" +
"will be listed.\n" +
"\n" +
"Results may be further filtered by passing additional flags. Tag filters may include\n" +
"the tag name as well as the tag value, separated by an equals sign. For example\n" +
"'environment=production' or just 'gcp:project'.",
Args: cmdutil.NoArgs,
Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error {
var packageFilter *tokens.PackageName
if !allStacks {
// Build up the stack filters. We do not support accepting empty strings as filters
// from command-line arguments, though the API technically supports it.
strPtrIfSet := func(s string) *string {
if s != "" {
return &s
}
return nil
}
filter := backend.ListStacksFilter{
Organization: strPtrIfSet(orgFilter),
Project: strPtrIfSet(projFilter),
}
if tagFilter != "" {
tagName, tagValue := parseTagFilter(tagFilter)
filter.TagName = &tagName
filter.TagValue = tagValue
}
// If --all is not specified, default to filtering to just the current project.
if !allStacks && projFilter == "" {
// Ensure we are in a project; if not, we will fail.
projPath, err := workspace.DetectProjectPath()
if err != nil {
@ -53,7 +84,8 @@ func newStackLsCmd() *cobra.Command {
if err != nil {
return errors.Wrap(err, "could not load current project")
}
packageFilter = &proj.Name
projName := string(proj.Name)
filter.Project = &projName
}
// Get the current backend.
@ -70,7 +102,7 @@ func newStackLsCmd() *cobra.Command {
}
// List all of the stacks available.
stackSummaries, err := b.ListStacks(commandContext(), packageFilter)
stackSummaries, err := b.ListStacks(commandContext(), filter)
if err != nil {
return err
}
@ -88,12 +120,31 @@ func newStackLsCmd() *cobra.Command {
}
cmd.PersistentFlags().BoolVarP(
&jsonOut, "json", "j", false, "Emit output as JSON")
cmd.PersistentFlags().BoolVarP(
&allStacks, "all", "a", false, "List all stacks instead of just stacks for the current project")
cmd.PersistentFlags().StringVarP(
&orgFilter, "organization", "o", "", "Filter returned stacks to those in a specific organization")
cmd.PersistentFlags().StringVarP(
&projFilter, "project", "p", "", "Filter returned stacks to those with a specific project name")
cmd.PersistentFlags().StringVarP(
&tagFilter, "tag", "t", "", "Filter returned stacks to those in a specific tag (tag-name or tag-name=tag-value)")
return cmd
}
// parseTagFilter parses a tag filter into its separate name and value parts, separatedby an equal sign.
// If no "value" is provided, the second return parameter will be `nil`. Either the tag name or value can
// be omitted. e.g. "=x" returns ("", "x") and "=" returns ("", "").
func parseTagFilter(t string) (string, *string) {
parts := strings.SplitN(t, "=", 2)
if len(parts) == 1 {
return parts[0], nil
}
return parts[0], &parts[1]
}
// stackSummaryJSON is the shape of the --json output of this command. When --json is passed, we print an array
// of stackSummaryJSON objects. While we can add fields to this structure in the future, we should not change
// existing fields.

63
cmd/stack_ls_test.go Normal file
View file

@ -0,0 +1,63 @@
// Copyright 2016-2018, 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 cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseTagFilter(t *testing.T) {
p := func(s string) *string {
return &s
}
tests := []struct {
Filter string
WantName string
WantValue *string
}{
// Just tag name
{Filter: "", WantName: ""},
{Filter: ":", WantName: ":"},
{Filter: "just tag name", WantName: "just tag name"},
{Filter: "tag-name123", WantName: "tag-name123"},
// Tag name and value
{Filter: "tag-name123=tag value", WantName: "tag-name123", WantValue: p("tag value")},
{Filter: "tag-name123=tag value:with-colon", WantName: "tag-name123", WantValue: p("tag value:with-colon")},
{Filter: "tag-name123=tag value=with-equal", WantName: "tag-name123", WantValue: p("tag value=with-equal")},
// Degenerate cases
{Filter: "=", WantName: "", WantValue: p("")},
{Filter: "no tag value=", WantName: "no tag value", WantValue: p("")},
{Filter: "=no tag name", WantName: "", WantValue: p("no tag name")},
}
for _, test := range tests {
name, value := parseTagFilter(test.Filter)
assert.Equal(t, test.WantName, name, "parseTagFilter(%q) name", test.Filter)
if test.WantValue == nil {
assert.Nil(t, value, "parseTagFilter(%q) value", test.Filter)
} else {
if value == nil {
t.Errorf("parseTagFilter(%q) expected %q tag name, but got nil", test.Filter, *test.WantValue)
} else {
assert.Equal(t, *test.WantValue, *value)
}
}
}
}

View file

@ -211,11 +211,13 @@ func chooseStack(
}
// List stacks as available options.
var options []string
summaries, err := b.ListStacks(commandContext(), &proj.Name)
project := string(proj.Name)
summaries, err := b.ListStacks(commandContext(), backend.ListStacksFilter{Project: &project})
if err != nil {
return nil, errors.Wrapf(err, "could not query backend for stacks")
}
var options []string
for _, summary := range summaries {
name := summary.Name().String()
options = append(options, name)

View file

@ -88,6 +88,14 @@ type StackSummary interface {
ResourceCount() *int
}
// ListStacksFilter describes optional filters when listing stacks.
type ListStacksFilter struct {
Organization *string
Project *string
TagName *string
TagValue *string
}
// 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.
type Backend interface {
@ -114,7 +122,7 @@ type Backend interface {
// first boolean return value will be set to true.
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(ctx context.Context, projectFilter *tokens.PackageName) ([]StackSummary, error)
ListStacks(ctx context.Context, filter ListStacksFilter) ([]StackSummary, error)
RenameStack(ctx context.Context, stackRef StackReference, newName tokens.QName) error

View file

@ -135,7 +135,7 @@ type mockBackend struct {
GetStackF func(context.Context, StackReference) (Stack, error)
CreateStackF func(context.Context, StackReference, interface{}) (Stack, error)
RemoveStackF func(context.Context, StackReference, bool) (bool, error)
ListStacksF func(context.Context, *tokens.PackageName) ([]StackSummary, error)
ListStacksF func(context.Context, ListStacksFilter) ([]StackSummary, error)
RenameStackF func(context.Context, StackReference, tokens.QName) error
GetStackCrypterF func(StackReference) (config.Crypter, error)
QueryF func(context.Context, StackReference, UpdateOperation) result.Result
@ -219,9 +219,9 @@ func (be *mockBackend) RemoveStack(ctx context.Context, stackRef StackReference,
panic("not implemented")
}
func (be *mockBackend) ListStacks(ctx context.Context, projectFilter *tokens.PackageName) ([]StackSummary, error) {
func (be *mockBackend) ListStacks(ctx context.Context, filter ListStacksFilter) ([]StackSummary, error) {
if be.ListStacksF != nil {
return be.ListStacksF(ctx, projectFilter)
return be.ListStacksF(ctx, filter)
}
panic("not implemented")
}

View file

@ -268,12 +268,14 @@ func (b *localBackend) GetStack(ctx context.Context, stackRef backend.StackRefer
}
func (b *localBackend) ListStacks(
ctx context.Context, projectFilter *tokens.PackageName) ([]backend.StackSummary, error) {
ctx context.Context, _ backend.ListStacksFilter) ([]backend.StackSummary, error) {
stacks, err := b.getLocalStacks()
if err != nil {
return nil, err
}
// Note that the provided stack filter is not honored, since fields like
// organizations and tags aren't persisted in the local backend.
var results []backend.StackSummary
for _, stackName := range stacks {
stack, err := b.GetStack(ctx, localBackendReference{name: stackName})

View file

@ -525,15 +525,24 @@ func (b *cloudBackend) CreateStack(
}
func (b *cloudBackend) ListStacks(
ctx context.Context, projectFilter *tokens.PackageName) ([]backend.StackSummary, error) {
var cleanedProjectName *string
if projectFilter != nil {
clean := cleanProjectName(string(*projectFilter))
cleanedProjectName = &clean
ctx context.Context, filter backend.ListStacksFilter) ([]backend.StackSummary, error) {
// Sanitize the project name as needed, so when communicating with the Pulumi Service we
// always use the name the service expects. (So that a similar, but not technically valid
// name may be put in Pulumi.yaml without causing problems.)
if filter.Project != nil {
cleanedProj := cleanProjectName(*filter.Project)
filter.Project = &cleanedProj
}
apiSummaries, err := b.client.ListStacks(ctx, cleanedProjectName)
// Duplicate type to avoid circular dependency.
clientFilter := client.ListStacksFilter{
Organization: filter.Organization,
Project: filter.Project,
TagName: filter.TagName,
TagValue: filter.TagValue,
}
apiSummaries, err := b.client.ListStacks(ctx, clientFilter)
if err != nil {
return nil, err
}

View file

@ -160,17 +160,30 @@ func (pc *Client) GetCLIVersionInfo(ctx context.Context) (semver.Version, semver
return latestSem, oldestSem, nil
}
// ListStacks lists all stacks the current user has access to, optionally filtered by project.
func (pc *Client) ListStacks(ctx context.Context, projectFilter *string) ([]apitype.StackSummary, error) {
// ListStacksFilter describes optional filters when listing stacks.
type ListStacksFilter struct {
Project *string
Organization *string
TagName *string
TagValue *string
}
var resp apitype.ListStacksResponse
var queryFilter interface{}
if projectFilter != nil {
queryFilter = struct {
ProjectFilter string `url:"project"`
}{ProjectFilter: *projectFilter}
// ListStacks lists all stacks the current user has access to, optionally filtered by project.
func (pc *Client) ListStacks(
ctx context.Context, filter ListStacksFilter) ([]apitype.StackSummary, error) {
queryFilter := struct {
Project *string `url:"project,omitempty"`
Organization *string `url:"organization,omitempty"`
TagName *string `url:"tagName,omitempty"`
TagValue *string `url:"tagValue,omitempty"`
}{
Project: filter.Project,
Organization: filter.Organization,
TagName: filter.TagName,
TagValue: filter.TagValue,
}
var resp apitype.ListStacksResponse
if err := pc.restCall(ctx, "GET", "/api/user/stacks", queryFilter, nil, &resp); err != nil {
return nil, err
}