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:
parent
e349f3c094
commit
eb0934970c
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
63
cmd/stack_ls_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue