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)).
|
- 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.
|
- Explicitly setting `deleteBeforeReplace` to `false` now overrides the provider's decision.
|
||||||
[#3118](https://github.com/pulumi/pulumi/pull/3118)
|
[#3118](https://github.com/pulumi/pulumi/pull/3118)
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
@ -26,21 +27,51 @@ import (
|
||||||
"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/util/cmdutil"
|
"github.com/pulumi/pulumi/pkg/util/cmdutil"
|
||||||
"github.com/pulumi/pulumi/pkg/workspace"
|
"github.com/pulumi/pulumi/pkg/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newStackLsCmd() *cobra.Command {
|
func newStackLsCmd() *cobra.Command {
|
||||||
var allStacks bool
|
|
||||||
var jsonOut bool
|
var jsonOut bool
|
||||||
|
var allStacks bool
|
||||||
|
var orgFilter string
|
||||||
|
var projFilter string
|
||||||
|
var tagFilter string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Short: "List all known stacks",
|
Short: "List stacks",
|
||||||
Args: cmdutil.NoArgs,
|
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 {
|
Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error {
|
||||||
var packageFilter *tokens.PackageName
|
// Build up the stack filters. We do not support accepting empty strings as filters
|
||||||
if !allStacks {
|
// 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.
|
// Ensure we are in a project; if not, we will fail.
|
||||||
projPath, err := workspace.DetectProjectPath()
|
projPath, err := workspace.DetectProjectPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -53,7 +84,8 @@ func newStackLsCmd() *cobra.Command {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "could not load current project")
|
return errors.Wrap(err, "could not load current project")
|
||||||
}
|
}
|
||||||
packageFilter = &proj.Name
|
projName := string(proj.Name)
|
||||||
|
filter.Project = &projName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current backend.
|
// Get the current backend.
|
||||||
|
@ -70,7 +102,7 @@ func newStackLsCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all of the stacks available.
|
// List all of the stacks available.
|
||||||
stackSummaries, err := b.ListStacks(commandContext(), packageFilter)
|
stackSummaries, err := b.ListStacks(commandContext(), filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -88,12 +120,31 @@ func newStackLsCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
cmd.PersistentFlags().BoolVarP(
|
cmd.PersistentFlags().BoolVarP(
|
||||||
&jsonOut, "json", "j", false, "Emit output as JSON")
|
&jsonOut, "json", "j", false, "Emit output as JSON")
|
||||||
|
|
||||||
cmd.PersistentFlags().BoolVarP(
|
cmd.PersistentFlags().BoolVarP(
|
||||||
&allStacks, "all", "a", false, "List all stacks instead of just stacks for the current project")
|
&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
|
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
|
// 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
|
// of stackSummaryJSON objects. While we can add fields to this structure in the future, we should not change
|
||||||
// existing fields.
|
// 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.
|
// List stacks as available options.
|
||||||
var options []string
|
project := string(proj.Name)
|
||||||
summaries, err := b.ListStacks(commandContext(), &proj.Name)
|
summaries, err := b.ListStacks(commandContext(), backend.ListStacksFilter{Project: &project})
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var options []string
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
name := summary.Name().String()
|
name := summary.Name().String()
|
||||||
options = append(options, name)
|
options = append(options, name)
|
||||||
|
|
|
@ -88,6 +88,14 @@ type StackSummary interface {
|
||||||
ResourceCount() *int
|
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.
|
// 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 {
|
||||||
|
@ -114,7 +122,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) ([]StackSummary, error)
|
ListStacks(ctx context.Context, filter ListStacksFilter) ([]StackSummary, error)
|
||||||
|
|
||||||
RenameStack(ctx context.Context, stackRef StackReference, newName tokens.QName) 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)
|
GetStackF func(context.Context, StackReference) (Stack, error)
|
||||||
CreateStackF func(context.Context, StackReference, interface{}) (Stack, error)
|
CreateStackF func(context.Context, StackReference, interface{}) (Stack, error)
|
||||||
RemoveStackF func(context.Context, StackReference, bool) (bool, 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
|
RenameStackF func(context.Context, StackReference, tokens.QName) error
|
||||||
GetStackCrypterF func(StackReference) (config.Crypter, error)
|
GetStackCrypterF func(StackReference) (config.Crypter, error)
|
||||||
QueryF func(context.Context, StackReference, UpdateOperation) result.Result
|
QueryF func(context.Context, StackReference, UpdateOperation) result.Result
|
||||||
|
@ -219,9 +219,9 @@ func (be *mockBackend) RemoveStack(ctx context.Context, stackRef StackReference,
|
||||||
panic("not implemented")
|
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 {
|
if be.ListStacksF != nil {
|
||||||
return be.ListStacksF(ctx, projectFilter)
|
return be.ListStacksF(ctx, filter)
|
||||||
}
|
}
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
|
|
|
@ -268,12 +268,14 @@ func (b *localBackend) GetStack(ctx context.Context, stackRef backend.StackRefer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *localBackend) ListStacks(
|
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()
|
stacks, err := b.getLocalStacks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
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})
|
||||||
|
|
|
@ -525,15 +525,24 @@ func (b *cloudBackend) CreateStack(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *cloudBackend) ListStacks(
|
func (b *cloudBackend) ListStacks(
|
||||||
ctx context.Context, projectFilter *tokens.PackageName) ([]backend.StackSummary, error) {
|
ctx context.Context, filter backend.ListStacksFilter) ([]backend.StackSummary, error) {
|
||||||
|
// Sanitize the project name as needed, so when communicating with the Pulumi Service we
|
||||||
var cleanedProjectName *string
|
// always use the name the service expects. (So that a similar, but not technically valid
|
||||||
if projectFilter != nil {
|
// name may be put in Pulumi.yaml without causing problems.)
|
||||||
clean := cleanProjectName(string(*projectFilter))
|
if filter.Project != nil {
|
||||||
cleanedProjectName = &clean
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -160,17 +160,30 @@ func (pc *Client) GetCLIVersionInfo(ctx context.Context) (semver.Version, semver
|
||||||
return latestSem, oldestSem, nil
|
return latestSem, oldestSem, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListStacks lists all stacks the current user has access to, optionally filtered by project.
|
// ListStacksFilter describes optional filters when listing stacks.
|
||||||
func (pc *Client) ListStacks(ctx context.Context, projectFilter *string) ([]apitype.StackSummary, error) {
|
type ListStacksFilter struct {
|
||||||
|
Project *string
|
||||||
|
Organization *string
|
||||||
|
TagName *string
|
||||||
|
TagValue *string
|
||||||
|
}
|
||||||
|
|
||||||
var resp apitype.ListStacksResponse
|
// ListStacks lists all stacks the current user has access to, optionally filtered by project.
|
||||||
var queryFilter interface{}
|
func (pc *Client) ListStacks(
|
||||||
if projectFilter != nil {
|
ctx context.Context, filter ListStacksFilter) ([]apitype.StackSummary, error) {
|
||||||
queryFilter = struct {
|
queryFilter := struct {
|
||||||
ProjectFilter string `url:"project"`
|
Project *string `url:"project,omitempty"`
|
||||||
}{ProjectFilter: *projectFilter}
|
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 {
|
if err := pc.restCall(ctx, "GET", "/api/user/stacks", queryFilter, nil, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue