diff --git a/pkg/cmd/libraryvariableset/libraryvariableset.go b/pkg/cmd/libraryvariableset/libraryvariableset.go new file mode 100644 index 00000000..5840cf8b --- /dev/null +++ b/pkg/cmd/libraryvariableset/libraryvariableset.go @@ -0,0 +1,32 @@ +package libraryvariableset + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdList "github.com/OctopusDeploy/cli/pkg/cmd/libraryvariableset/list" + cmdView "github.com/OctopusDeploy/cli/pkg/cmd/libraryvariableset/view" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/constants/annotations" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdLibraryVariableSet(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "library-variable-set ", + Aliases: []string{"library-variable-sets", "lvs"}, + Short: "Manage library variable sets", + Long: "Manage library variable sets in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s library-variable-set list + %[1]s library-variable-set view "Slack Variables" + `, constants.ExecutableName), + Annotations: map[string]string{ + annotations.IsLibrary: "true", + }, + } + + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdView.NewCmdView(f)) + + return cmd +} diff --git a/pkg/cmd/libraryvariableset/list/list.go b/pkg/cmd/libraryvariableset/list/list.go new file mode 100644 index 00000000..3a0c12ea --- /dev/null +++ b/pkg/cmd/libraryvariableset/list/list.go @@ -0,0 +1,105 @@ +package list + +import ( + "sort" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + sharedVariable "github.com/OctopusDeploy/cli/pkg/question/shared/variables" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/spf13/cobra" +) + +const ( + FlagFilter = "filter" +) + +type ListFlags struct { + Filter *flag.Flag[string] +} + +func NewListFlags() *ListFlags { + return &ListFlags{ + Filter: flag.New[string](FlagFilter, false), + } +} + +type LibraryVariableSetViewModel struct { + ID string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + VariableSetID string `json:"VariableSetId"` +} + +func NewCmdList(f factory.Factory) *cobra.Command { + listFlags := NewListFlags() + cmd := &cobra.Command{ + Use: "list", + Short: "List library variable sets", + Long: "List library variable sets in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s library-variable-set list + %[1]s library-variable-set ls --filter Slack + %[1]s library-variable-set ls -q Slack -f json + `, constants.ExecutableName), + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + return listRun(cmd, f, listFlags) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&listFlags.Filter.Value, listFlags.Filter.Name, "q", "", "Filter library variable sets to match only ones with a name containing the given string") + return cmd +} + +func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error { + octopus, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + // script modules share the libraryvariablesets endpoint; this command only deals + // with variable sets, and GetAllLibraryVariableSets filters them out for us. + allSets, err := sharedVariable.GetAllLibraryVariableSets(octopus) + if err != nil { + return err + } + + filter := strings.ToLower(flags.Filter.Value) + viewModels := make([]LibraryVariableSetViewModel, 0, len(allSets)) + for _, s := range allSets { + if filter != "" && !strings.Contains(strings.ToLower(s.Name), filter) { + continue + } + viewModels = append(viewModels, LibraryVariableSetViewModel{ + ID: s.GetID(), + Name: s.Name, + Description: s.Description, + VariableSetID: s.VariableSetID, + }) + } + + sort.SliceStable(viewModels, func(i, j int) bool { + return strings.ToLower(viewModels[i].Name) < strings.ToLower(viewModels[j].Name) + }) + + return output.PrintArray(viewModels, cmd, output.Mappers[LibraryVariableSetViewModel]{ + Json: func(item LibraryVariableSetViewModel) any { + return item + }, + Table: output.TableDefinition[LibraryVariableSetViewModel]{ + Header: []string{"NAME", "DESCRIPTION", "ID"}, + Row: func(item LibraryVariableSetViewModel) []string { + return []string{output.Bold(item.Name), item.Description, output.Dim(item.ID)} + }, + }, + Basic: func(item LibraryVariableSetViewModel) string { + return item.Name + }, + }) +} diff --git a/pkg/cmd/libraryvariableset/list/list_test.go b/pkg/cmd/libraryvariableset/list/list_test.go new file mode 100644 index 00000000..6f052f94 --- /dev/null +++ b/pkg/cmd/libraryvariableset/list/list_test.go @@ -0,0 +1,151 @@ +package list_test + +import ( + "bytes" + "testing" + + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestLibraryVariableSetList(t *testing.T) { + const spaceID = "Spaces-1" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + slackSet := fixtures.NewLibraryVariableSet(spaceID, "LibraryVariableSets-1", "Slack Variables") + slackSet.Description = "Slack webhooks" + + sharedSet := fixtures.NewLibraryVariableSet(spaceID, "LibraryVariableSets-2", "Global Variables") + + scriptModule := fixtures.NewLibraryVariableSet(spaceID, "LibraryVariableSets-3", "Helper Functions") + scriptModule.ContentType = "ScriptModule" + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"library variable set list prints the sets, sorted, excluding script modules", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "list", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, scriptModule, sharedSet}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME DESCRIPTION ID + Global Variables LibraryVariableSets-2 + Slack Variables Slack webhooks LibraryVariableSets-1 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"library variable set list filters by name", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "ls", "-q", "slack", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME DESCRIPTION ID + Slack Variables Slack webhooks LibraryVariableSets-1 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"outputFormat json", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "list", "--output-format", "json", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type x struct { + ID string + Name string + Description string + VariableSetID string + } + parsedStdout, err := testutil.ParseJsonStrict[[]x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, []x{ + {ID: "LibraryVariableSets-2", Name: "Global Variables", Description: "", VariableSetID: "variableset-LibraryVariableSets-2"}, + {ID: "LibraryVariableSets-1", Name: "Slack Variables", Description: "Slack webhooks", VariableSetID: "variableset-LibraryVariableSets-1"}, + }, parsedStdout) + assert.Equal(t, "", stdErr.String()) + }}, + + {"outputFormat basic", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "list", "--output-format", "basic", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Global Variables + Slack Variables + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/libraryvariableset/shared/shared.go b/pkg/cmd/libraryvariableset/shared/shared.go new file mode 100644 index 00000000..f6992894 --- /dev/null +++ b/pkg/cmd/libraryvariableset/shared/shared.go @@ -0,0 +1,228 @@ +package shared + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/OctopusDeploy/cli/pkg/question" + sharedVariable "github.com/OctopusDeploy/cli/pkg/question/shared/variables" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" +) + +const ( + // Unscoped labels a value that applies wherever no more specific value matches. + Unscoped = "(unscoped)" + // SensitiveValue stands in for a sensitive value, which the server never returns. + SensitiveValue = "***" +) + +// ResolveLibraryVariableSet finds the library variable set a command should operate +// on: looking it up when the caller named one, prompting for it in interactive mode +// when they didn't, and failing in automation mode where there is nobody to ask. +// +// The whole (space-scoped) list is fetched either way, so a caller-supplied name or +// ID is matched client-side rather than costing a second round trip. +func ResolveLibraryVariableSet(octopus *octopusApiClient.Client, ask question.Asker, promptEnabled bool, questionText string, idOrName string) (*variables.LibraryVariableSet, error) { + if idOrName == "" && !promptEnabled { + return nil, errors.New("library variable set must be specified") + } + + allSets, err := sharedVariable.GetAllLibraryVariableSets(octopus) + if err != nil { + return nil, err + } + + if idOrName == "" { + if len(allSets) == 0 { + return nil, errors.New("no library variable sets found") + } + return question.SelectMap(ask, questionText, allSets, func(s *variables.LibraryVariableSet) string { return s.Name }) + } + + for _, s := range allSets { + if strings.EqualFold(s.GetID(), idOrName) || strings.EqualFold(s.Name, idOrName) { + return s, nil + } + } + return nil, fmt.Errorf("cannot find library variable set '%s'", idOrName) +} + +// ScopeAsJson mirrors the property names the Octopus API uses for a variable scope, +// but holds display names instead of IDs, falling back to the ID when a scope value +// isn't in the set's ScopeValues lookup. +type ScopeAsJson struct { + Environments []string `json:"Environment,omitempty"` + Roles []string `json:"Role,omitempty"` + Machines []string `json:"Machine,omitempty"` + TenantTags []string `json:"TenantTag,omitempty"` + Channels []string `json:"Channel,omitempty"` + Actions []string `json:"Action,omitempty"` + Processes []string `json:"ProcessOwner,omitempty"` +} + +// VariableValue is a single stored value of a variable, with its scope resolved. +type VariableValue struct { + Id string `json:"Id"` + Value string `json:"Value"` + IsSensitive bool `json:"IsSensitive"` + Type string `json:"Type,omitempty"` + Description string `json:"Description,omitempty"` + IsScoped bool `json:"IsScoped"` + Scope *ScopeAsJson `json:"Scope,omitempty"` + ScopeSummary string `json:"ScopeSummary"` + Prompt *variables.VariablePromptOptions `json:"Prompt,omitempty"` +} + +// DisplayValue is the value as it should be shown to a human. +func (v *VariableValue) DisplayValue() string { + if v.IsSensitive { + return SensitiveValue + } + return v.Value +} + +// VariableGroup collects every value stored under one variable name. The API returns +// each scoped value as its own entry; grouping them is what makes a variable set with +// many scopes readable. +type VariableGroup struct { + Name string `json:"Name"` + Values []*VariableValue `json:"Values"` +} + +// GroupVariables collapses a variable set's flat list into one group per variable +// name. Groups are ordered by name and, within a group, the unscoped value comes +// first because it is the fallback the scoped ones override. +func GroupVariables(variableSet *variables.VariableSet) []*VariableGroup { + byName := map[string]*VariableGroup{} + groups := []*VariableGroup{} + + for _, v := range variableSet.Variables { + key := strings.ToLower(v.Name) + group, ok := byName[key] + if !ok { + group = &VariableGroup{Name: v.Name} + byName[key] = group + groups = append(groups, group) + } + group.Values = append(group.Values, newVariableValue(v, variableSet.ScopeValues)) + } + + sort.SliceStable(groups, func(i, j int) bool { + return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name) + }) + for _, group := range groups { + values := group.Values + sort.SliceStable(values, func(i, j int) bool { + if values[i].IsScoped != values[j].IsScoped { + return !values[i].IsScoped + } + return values[i].ScopeSummary < values[j].ScopeSummary + }) + } + + return groups +} + +func newVariableValue(v *variables.Variable, lookup *variables.VariableScopeValues) *VariableValue { + value := &VariableValue{ + Id: v.GetID(), + Value: v.Value, + IsSensitive: v.IsSensitive, + Type: v.Type, + Description: v.Description, + IsScoped: !v.Scope.IsEmpty(), + ScopeSummary: Unscoped, + Prompt: v.Prompt, + } + if value.IsScoped { + value.Scope = resolveScope(v.Scope, lookup) + value.ScopeSummary = ScopeSummary(value.Scope) + } + return value +} + +func resolveScope(scope variables.VariableScope, lookup *variables.VariableScopeValues) *ScopeAsJson { + if lookup == nil { + lookup = &variables.VariableScopeValues{} + } + return &ScopeAsJson{ + Environments: resolveNames(scope.Environments, lookup.Environments), + Roles: resolveNames(scope.Roles, lookup.Roles), + Machines: resolveNames(scope.Machines, lookup.Machines), + // tenant tag scope values are canonical tag names already + TenantTags: append([]string{}, scope.TenantTags...), + Channels: resolveNames(scope.Channels, lookup.Channels), + Actions: resolveNames(scope.Actions, lookup.Actions), + Processes: resolveProcessNames(scope.ProcessOwners, lookup.Processes), + } +} + +// ScopeSummary renders a scope as a single line, e.g. "Environment: Production; Role: web-server". +func ScopeSummary(scope *ScopeAsJson) string { + if scope == nil { + return Unscoped + } + + parts := []string{} + add := func(label string, values []string) { + if len(values) > 0 { + parts = append(parts, fmt.Sprintf("%s: %s", label, strings.Join(values, ", "))) + } + } + add("Environment", scope.Environments) + add("Role", scope.Roles) + add("Target", scope.Machines) + add("Tenant tag", scope.TenantTags) + add("Channel", scope.Channels) + add("Step", scope.Actions) + add("Process", scope.Processes) + + if len(parts) == 0 { + return Unscoped + } + return strings.Join(parts, "; ") +} + +func resolveNames(ids []string, refs []*resources.ReferenceDataItem) []string { + if len(ids) == 0 { + return nil + } + names := make([]string, 0, len(ids)) + for _, id := range ids { + names = append(names, lookupName(id, refs)) + } + return names +} + +func resolveProcessNames(ids []string, refs []*resources.ProcessReferenceDataItem) []string { + if len(ids) == 0 { + return nil + } + names := make([]string, 0, len(ids)) + for _, id := range ids { + name := id + for _, r := range refs { + if strings.EqualFold(r.ID, id) && r.Name != "" { + name = r.Name + break + } + } + names = append(names, name) + } + return names +} + +// lookupName falls back to the raw ID rather than erroring, so one unresolvable +// scope value can't stop the whole set from being displayed. +func lookupName(id string, refs []*resources.ReferenceDataItem) string { + for _, r := range refs { + if strings.EqualFold(r.ID, id) && r.Name != "" { + return r.Name + } + } + return id +} diff --git a/pkg/cmd/libraryvariableset/shared/shared_test.go b/pkg/cmd/libraryvariableset/shared/shared_test.go new file mode 100644 index 00000000..25af8bb8 --- /dev/null +++ b/pkg/cmd/libraryvariableset/shared/shared_test.go @@ -0,0 +1,121 @@ +package shared_test + +import ( + "testing" + + "github.com/OctopusDeploy/cli/pkg/cmd/libraryvariableset/shared" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/stretchr/testify/assert" +) + +func newVariable(id string, name string, value string, scope variables.VariableScope) *variables.Variable { + v := variables.NewVariable(name) + v.ID = id + v.Value = value + v.Scope = scope + return v +} + +func newVariableSet(scopeValues *variables.VariableScopeValues, vars ...*variables.Variable) *variables.VariableSet { + variableSet := fixtures.NewVariableSetForLibraryVariableSet("Spaces-1", "LibraryVariableSets-1") + variableSet.ScopeValues = scopeValues + variableSet.Variables = vars + return variableSet +} + +var scopeValues = &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{ + {ID: "Environments-1", Name: "Production"}, + {ID: "Environments-2", Name: "Test"}, + }, + Roles: []*resources.ReferenceDataItem{ + {ID: "web-server", Name: "web-server"}, + }, +} + +func TestGroupVariables_CollapsesValuesSharingAName(t *testing.T) { + variableSet := newVariableSet(scopeValues, + newVariable("Variables-2", "Slack.Url", "https://prod", variables.VariableScope{Environments: []string{"Environments-1"}}), + newVariable("Variables-1", "Slack.Url", "https://default", variables.VariableScope{}), + newVariable("Variables-3", "Api.Key", "abc", variables.VariableScope{}), + ) + + groups := shared.GroupVariables(variableSet) + + assert.Equal(t, 2, len(groups)) + assert.Equal(t, "Api.Key", groups[0].Name) + assert.Equal(t, 1, len(groups[0].Values)) + + assert.Equal(t, "Slack.Url", groups[1].Name) + assert.Equal(t, 2, len(groups[1].Values)) + // the unscoped value is the fallback the scoped ones override, so it comes first + assert.Equal(t, "Variables-1", groups[1].Values[0].Id) + assert.False(t, groups[1].Values[0].IsScoped) + assert.Equal(t, shared.Unscoped, groups[1].Values[0].ScopeSummary) + assert.Equal(t, "Variables-2", groups[1].Values[1].Id) + assert.True(t, groups[1].Values[1].IsScoped) + assert.Equal(t, "Environment: Production", groups[1].Values[1].ScopeSummary) +} + +func TestGroupVariables_GroupsNamesCaseInsensitively(t *testing.T) { + variableSet := newVariableSet(scopeValues, + newVariable("Variables-1", "Slack.Url", "a", variables.VariableScope{}), + newVariable("Variables-2", "slack.url", "b", variables.VariableScope{Environments: []string{"Environments-2"}}), + ) + + groups := shared.GroupVariables(variableSet) + + assert.Equal(t, 1, len(groups)) + assert.Equal(t, "Slack.Url", groups[0].Name) + assert.Equal(t, 2, len(groups[0].Values)) +} + +func TestGroupVariables_ResolvesScopeIdsToNames(t *testing.T) { + variableSet := newVariableSet(scopeValues, + newVariable("Variables-1", "Db.Name", "orders", variables.VariableScope{ + Environments: []string{"Environments-1", "Environments-2"}, + Roles: []string{"web-server"}, + TenantTags: []string{"Regions/us-east"}, + }), + ) + + groups := shared.GroupVariables(variableSet) + + value := groups[0].Values[0] + assert.Equal(t, []string{"Production", "Test"}, value.Scope.Environments) + assert.Equal(t, []string{"web-server"}, value.Scope.Roles) + assert.Equal(t, []string{"Regions/us-east"}, value.Scope.TenantTags) + assert.Equal(t, "Environment: Production, Test; Role: web-server; Tenant tag: Regions/us-east", value.ScopeSummary) +} + +func TestGroupVariables_FallsBackToTheIdWhenAScopeValueIsUnknown(t *testing.T) { + variableSet := newVariableSet(scopeValues, + newVariable("Variables-1", "Db.Name", "orders", variables.VariableScope{Environments: []string{"Environments-99"}}), + ) + + groups := shared.GroupVariables(variableSet) + + assert.Equal(t, "Environment: Environments-99", groups[0].Values[0].ScopeSummary) +} + +func TestGroupVariables_ToleratesAMissingScopeValuesLookup(t *testing.T) { + variableSet := newVariableSet(nil, + newVariable("Variables-1", "Db.Name", "orders", variables.VariableScope{Environments: []string{"Environments-1"}}), + ) + + groups := shared.GroupVariables(variableSet) + + assert.Equal(t, "Environment: Environments-1", groups[0].Values[0].ScopeSummary) +} + +func TestVariableValue_DisplayValueMasksSensitiveValues(t *testing.T) { + sensitive := newVariable("Variables-1", "Db.Password", "", variables.VariableScope{}) + sensitive.IsSensitive = true + variableSet := newVariableSet(scopeValues, sensitive) + + groups := shared.GroupVariables(variableSet) + + assert.Equal(t, shared.SensitiveValue, groups[0].Values[0].DisplayValue()) +} diff --git a/pkg/cmd/libraryvariableset/view/view.go b/pkg/cmd/libraryvariableset/view/view.go new file mode 100644 index 00000000..e0a643d6 --- /dev/null +++ b/pkg/cmd/libraryvariableset/view/view.go @@ -0,0 +1,222 @@ +package view + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/cmd/libraryvariableset/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/usage" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + FlagFilter = "filter" + FlagWeb = "web" +) + +type ViewFlags struct { + Filter *flag.Flag[string] + Web *flag.Flag[bool] +} + +func NewViewFlags() *ViewFlags { + return &ViewFlags{ + Filter: flag.New[string](FlagFilter, false), + Web: flag.New[bool](FlagWeb, false), + } +} + +type ViewOptions struct { + Client *client.Client + Host string + Ask question.Asker + PromptEnabled bool + idOrName string + flags *ViewFlags + Command *cobra.Command +} + +func NewCmdView(f factory.Factory) *cobra.Command { + viewFlags := NewViewFlags() + cmd := &cobra.Command{ + Args: usage.MaximumNArgs(1), + Use: "view [ | ]", + Short: "View a library variable set and its variables", + Long: heredoc.Doc(` + View a library variable set in Octopus Deploy, along with its variables. + + Values stored under the same variable name are grouped together, so a + variable with several scoped values reads as one entry rather than several. + `), + Example: heredoc.Docf(` + %[1]s library-variable-set view "Slack Variables" + %[1]s library-variable-set view LibraryVariableSets-1 + %[1]s library-variable-set view "Slack Variables" --filter Url + %[1]s library-variable-set view "Slack Variables" -f json + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + idOrName := "" + if len(args) > 0 { + idOrName = args[0] + } + + opts := &ViewOptions{ + Client: c, + Host: f.GetCurrentHost(), + Ask: f.Ask, + PromptEnabled: f.IsPromptEnabled(), + idOrName: idOrName, + flags: viewFlags, + Command: cmd, + } + + return viewRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&viewFlags.Filter.Value, viewFlags.Filter.Name, "q", "", "Show only variables with a name containing the given string") + flags.BoolVarP(&viewFlags.Web.Value, viewFlags.Web.Name, "w", false, "Open in web browser") + + return cmd +} + +type LibraryVariableSetAsJson struct { + Id string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + SpaceId string `json:"SpaceId"` + VariableSetId string `json:"VariableSetId"` + TemplateCount int `json:"TemplateCount"` + Variables []*shared.VariableGroup `json:"Variables"` + WebUrl string `json:"WebUrl"` +} + +func viewRun(opts *ViewOptions) error { + set, err := shared.ResolveLibraryVariableSet(opts.Client, opts.Ask, opts.PromptEnabled, + "Select the library variable set you wish to view:", opts.idOrName) + if err != nil { + return err + } + + // the set itself and the variables it owns live on two different endpoints; + // stitching them together here is the point of this command + variableSet, err := opts.Client.Variables.GetAll(set.GetID()) + if err != nil { + return err + } + + groups := shared.GroupVariables(&variableSet) + if filter := strings.ToLower(opts.flags.Filter.Value); filter != "" { + groups = util.SliceFilter(groups, func(g *shared.VariableGroup) bool { + return strings.Contains(strings.ToLower(g.Name), filter) + }) + if groups == nil { + // SliceFilter returns nil on no match; keep Variables as [] in json + groups = []*shared.VariableGroup{} + } + } + + webUrl := util.GenerateWebURL(opts.Host, set.SpaceID, fmt.Sprintf("library/variablesets/%s", set.GetID())) + + outputFormat, _ := opts.Command.Flags().GetString(constants.FlagOutputFormat) + if outputFormat == "" { + outputFormat = viper.GetString(constants.ConfigOutputFormat) + } + + // output.PrintResource/PrintArray both render a single shape; a library variable + // set needs the set's own details plus a row per stored value, so the formats are + // dispatched here (as pkg/cmd/config/list does). + switch strings.ToLower(outputFormat) { + case constants.OutputFormatJson: + data, _ := json.MarshalIndent(LibraryVariableSetAsJson{ + Id: set.GetID(), + Name: set.Name, + Description: set.Description, + SpaceId: set.SpaceID, + VariableSetId: set.VariableSetID, + TemplateCount: len(set.Templates), + Variables: groups, + WebUrl: webUrl, + }, "", " ") + opts.Command.Println(string(data)) + case constants.OutputFormatBasic: + opts.Command.Print(formatForBasic(set.Name, set.GetID(), set.Description, groups, webUrl)) + case constants.OutputFormatTable, "": + printTable(opts, groups) + default: + return usage.NewUsageError( + fmt.Sprintf("unsupported output format %s. Valid values are 'json', 'table', 'basic'. Defaults to table", outputFormat), + opts.Command) + } + + if opts.flags.Web.Value { + _ = browser.OpenURL(webUrl) + } + + return nil +} + +// printTable shows one row per stored value, repeating the variable name only on the +// first row of each group so that a variable with many scopes reads as one block. +func printTable(opts *ViewOptions, groups []*shared.VariableGroup) { + t := output.NewTable(opts.Command.OutOrStdout()) + t.AddRow(output.Bold("NAME"), output.Bold("VALUE"), output.Bold("SCOPE"), output.Bold("ID")) + for _, group := range groups { + for i, value := range group.Values { + name := "" + if i == 0 { + name = output.Bold(group.Name) + } + t.AddRow(name, value.DisplayValue(), value.ScopeSummary, output.Dim(value.Id)) + } + } + t.Print() +} + +func formatForBasic(name string, id string, description string, groups []*shared.VariableGroup, webUrl string) string { + var result strings.Builder + + result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(name), output.Dimf("(%s)", id))) + if description == "" { + result.WriteString(fmt.Sprintln(output.Dim(constants.NoDescription))) + } else { + result.WriteString(fmt.Sprintln(output.Dim(description))) + } + + if len(groups) == 0 { + result.WriteString("\nNo variables\n") + return result.String() + } + + for _, group := range groups { + result.WriteString(fmt.Sprintf("\n%s\n", output.Bold(group.Name))) + for _, value := range group.Values { + result.WriteString(fmt.Sprintf(" %s = %s\n", value.ScopeSummary, value.DisplayValue())) + if value.Prompt != nil { + result.WriteString(fmt.Sprintf(" %s\n", output.Dim("prompted at deployment time"))) + } + } + } + + result.WriteString(fmt.Sprintf("\nView this library variable set in Octopus Deploy: %s\n", output.Blue(webUrl))) + + return result.String() +} diff --git a/pkg/cmd/libraryvariableset/view/view_test.go b/pkg/cmd/libraryvariableset/view/view_test.go new file mode 100644 index 00000000..fd166575 --- /dev/null +++ b/pkg/cmd/libraryvariableset/view/view_test.go @@ -0,0 +1,265 @@ +package view_test + +import ( + "bytes" + "testing" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func newVariable(id string, name string, value string, scope variables.VariableScope) *variables.Variable { + v := variables.NewVariable(name) + v.ID = id + v.Value = value + v.Scope = scope + return v +} + +func TestLibraryVariableSetView(t *testing.T) { + const spaceID = "Spaces-1" + const setID = "LibraryVariableSets-1" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + slackSet := fixtures.NewLibraryVariableSet(spaceID, setID, "Slack Variables") + slackSet.Description = "Slack webhooks" + + sharedSet := fixtures.NewLibraryVariableSet(spaceID, "LibraryVariableSets-2", "Global Variables") + + // the same name stored three times: once unscoped, twice scoped + variableSet := fixtures.NewVariableSetForLibraryVariableSet(spaceID, setID) + variableSet.ScopeValues = &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{ + {ID: "Environments-1", Name: "Production"}, + {ID: "Environments-2", Name: "Test"}, + }, + } + sensitive := newVariable("Variables-4", "Slack.Token", "", variables.VariableScope{}) + sensitive.IsSensitive = true + variableSet.Variables = []*variables.Variable{ + newVariable("Variables-2", "Slack.Url", "https://prod", variables.VariableScope{Environments: []string{"Environments-1"}}), + newVariable("Variables-1", "Slack.Url", "https://default", variables.VariableScope{}), + newVariable("Variables-3", "Slack.Url", "https://test", variables.VariableScope{Environments: []string{"Environments-2"}}), + sensitive, + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"library variable set view requires a set in automation mode", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "view", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "library variable set must be specified") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"library variable set view fails when the named set doesn't exist", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "view", "Nope", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "cannot find library variable set 'Nope'") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"library variable set view prompts for the set in interactive mode", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "view", "-f", "table", "-q", "token"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the library variable set you wish to view:", + Options: []string{slackSet.Name, sharedSet.Name}, + }).AnswerWith(slackSet.Name) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/variableset-LibraryVariableSets-1").RespondWith(variableSet) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME VALUE SCOPE ID + Slack.Token *** (unscoped) Variables-4 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"library variable set view groups values sharing a name (table)", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "view", "Slack Variables", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/variableset-LibraryVariableSets-1").RespondWith(variableSet) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME VALUE SCOPE ID + Slack.Token *** (unscoped) Variables-4 + Slack.Url https://default (unscoped) Variables-1 + https://prod Environment: Production Variables-2 + https://test Environment: Test Variables-3 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"library variable set view by id (basic)", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "view", setID, "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/variableset-LibraryVariableSets-1").RespondWith(variableSet) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Slack Variables (LibraryVariableSets-1) + Slack webhooks + + Slack.Token + (unscoped) = *** + + Slack.Url + (unscoped) = https://default + Environment: Production = https://prod + Environment: Test = https://test + + View this library variable set in Octopus Deploy: http://server/app#/Spaces-1/library/variablesets/LibraryVariableSets-1 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"outputFormat json", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"library-variable-set", "view", "Slack Variables", "--no-prompt", "--output-format", "json"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/libraryvariablesets/all"). + RespondWith([]*variables.LibraryVariableSet{slackSet, sharedSet}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/variableset-LibraryVariableSets-1").RespondWith(variableSet) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type scope struct { + Environment []string + } + type value struct { + Id string + Value string + IsSensitive bool + Type string + IsScoped bool + Scope *scope + ScopeSummary string + } + type group struct { + Name string + Values []value + } + type x struct { + Id string + Name string + Description string + SpaceId string + VariableSetId string + TemplateCount int + Variables []group + WebUrl string + } + parsedStdout, err := testutil.ParseJsonStrict[x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, x{ + Id: setID, + Name: "Slack Variables", + Description: "Slack webhooks", + SpaceId: spaceID, + VariableSetId: "variableset-LibraryVariableSets-1", + TemplateCount: 0, + WebUrl: "http://server/app#/Spaces-1/library/variablesets/LibraryVariableSets-1", + Variables: []group{ + {Name: "Slack.Token", Values: []value{ + {Id: "Variables-4", Value: "", IsSensitive: true, Type: "String", IsScoped: false, ScopeSummary: "(unscoped)"}, + }}, + {Name: "Slack.Url", Values: []value{ + {Id: "Variables-1", Value: "https://default", Type: "String", IsScoped: false, ScopeSummary: "(unscoped)"}, + {Id: "Variables-2", Value: "https://prod", Type: "String", IsScoped: true, Scope: &scope{Environment: []string{"Production"}}, ScopeSummary: "Environment: Production"}, + {Id: "Variables-3", Value: "https://test", Type: "String", IsScoped: true, Scope: &scope{Environment: []string{"Test"}}, ScopeSummary: "Environment: Test"}, + }}, + }, + }, parsedStdout) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 05106062..daa520f9 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -9,6 +9,7 @@ import ( configCmd "github.com/OctopusDeploy/cli/pkg/cmd/config" environmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/environment" ephemeralEnvironmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment" + libraryVariableSetCmd "github.com/OctopusDeploy/cli/pkg/cmd/libraryvariableset" loginCmd "github.com/OctopusDeploy/cli/pkg/cmd/login" logoutCmd "github.com/OctopusDeploy/cli/pkg/cmd/logout" packageCmd "github.com/OctopusDeploy/cli/pkg/cmd/package" @@ -67,6 +68,9 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro cmd.AddCommand(tenantCmd.NewCmdTenant(f)) cmd.AddCommand(taskCmd.NewCmdTask(f)) + // library + cmd.AddCommand(libraryVariableSetCmd.NewCmdLibraryVariableSet(f)) + // configuration cmd.AddCommand(configCmd.NewCmdConfig(f)) cmd.AddCommand(spaceCmd.NewCmdSpace(f)) diff --git a/test/fixtures/projects.go b/test/fixtures/projects.go index 25525fce..ec4584ef 100644 --- a/test/fixtures/projects.go +++ b/test/fixtures/projects.go @@ -232,6 +232,24 @@ func NewVariableSetForProject(spaceID string, projectID string) *variables.Varia return result } +func NewLibraryVariableSet(spaceID string, libraryVariableSetID string, name string) *variables.LibraryVariableSet { + result := variables.NewLibraryVariableSet(name) + result.ID = libraryVariableSetID + result.SpaceID = spaceID + result.VariableSetID = "variableset-" + libraryVariableSetID + return result +} + +func NewVariableSetForLibraryVariableSet(spaceID string, libraryVariableSetID string) *variables.VariableSet { + result := variables.NewVariableSet() + result.OwnerID = libraryVariableSetID + result.SpaceID = spaceID + result.Variables = make([]*variables.Variable, 0) + result.ID = "variableset-" + libraryVariableSetID + result.Links = map[string]string{} + return result +} + func NewTenant(spaceID string, tenantID string, name string, tenantTags ...string) *tenants.Tenant { result := tenants.NewTenant(name) result.ID = tenantID diff --git a/test/integration/libraryvariableset_test.go b/test/integration/libraryvariableset_test.go new file mode 100644 index 00000000..cb8e050f --- /dev/null +++ b/test/integration/libraryvariableset_test.go @@ -0,0 +1,333 @@ +package integration_test + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/OctopusDeploy/cli/test/integration" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// the space already holds variable sets, so every assertion here is scoped to +// fixtures named with the run id rather than to whole-command output +type lvsFixture struct { + Environment *environments.Environment + Set *variables.LibraryVariableSet + ScriptModule *variables.LibraryVariableSet + Empty *variables.LibraryVariableSet +} + +func createLibraryVariableSetFixture(t *testing.T, runId uuid.UUID) *lvsFixture { + apiClient, err := integration.GetApiClient(space1ID) + testutil.RequireSuccess(t, err) + + // our own environment, so a resolved scope name is one we control + env, err := apiClient.Environments.Add(environments.NewEnvironment(fmt.Sprintf("lvsenv-%s", runId))) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.Environments.DeleteByID(env.GetID())) }) + + set := variables.NewLibraryVariableSet(fmt.Sprintf("lvs-%s", runId)) + set.Description = "set under test" + set, err = apiClient.LibraryVariableSets.Add(set) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.LibraryVariableSets.DeleteByID(set.GetID())) }) + + // script modules share the libraryvariablesets endpoint; list must skip it + // and view must not resolve it + module := variables.NewLibraryVariableSet(fmt.Sprintf("lvsmodule-%s", runId)) + module.ContentType = "ScriptModule" + module, err = apiClient.LibraryVariableSets.Add(module) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.LibraryVariableSets.DeleteByID(module.GetID())) }) + + empty, err := apiClient.LibraryVariableSets.Add(variables.NewLibraryVariableSet(fmt.Sprintf("lvsempty-%s", runId))) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.LibraryVariableSets.DeleteByID(empty.GetID())) }) + + unscoped := variables.NewVariable("Slack.Url") + unscoped.Value = "https://default" + + scopedToEnv := variables.NewVariable("Slack.Url") + scopedToEnv.Value = "https://prod" + scopedToEnv.Scope = variables.VariableScope{Environments: []string{env.GetID()}} + + scopedToEnvAndRole := variables.NewVariable("Slack.Url") + scopedToEnvAndRole.Value = "https://dev" + scopedToEnvAndRole.Scope = variables.VariableScope{Environments: []string{env.GetID()}, Roles: []string{"web-server"}} + + sensitive := variables.NewVariable("Slack.Token") + sensitive.Value = "s3cret" + sensitive.Type = "Sensitive" + sensitive.IsSensitive = true + + prompted := variables.NewVariable("Ask.Me") + prompted.Prompt = &variables.VariablePromptOptions{Label: "Give me a value", IsRequired: true} + + variableSet, err := apiClient.Variables.GetAll(set.GetID()) + testutil.RequireSuccess(t, err) + variableSet.Variables = []*variables.Variable{unscoped, scopedToEnv, scopedToEnvAndRole, sensitive, prompted} + _, err = apiClient.Variables.Update(set.GetID(), variableSet) + testutil.RequireSuccess(t, err) + + return &lvsFixture{Environment: env, Set: set, ScriptModule: module, Empty: empty} +} + +var tableColumnSeparator = regexp.MustCompile(`\s{2,}`) + +// tableRows splits table output into rows of trimmed cells. Variable IDs are +// GUIDs, so tests match on cells rather than on whole lines. +func tableRows(stdOut string) [][]string { + rows := [][]string{} + for _, line := range strings.Split(strings.TrimRight(stdOut, "\n"), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + cells := tableColumnSeparator.Split(strings.TrimRight(line, " "), -1) + for i := range cells { + cells[i] = strings.TrimSpace(cells[i]) + } + rows = append(rows, cells) + } + return rows +} + +type lvsListItem struct { + Id string + Name string + Description string + VariableSetId string +} + +type lvsViewScope struct { + Environment []string + Role []string +} + +type lvsViewValue struct { + Id string + Value string + IsSensitive bool + Type string + IsScoped bool + Scope *lvsViewScope + ScopeSummary string + Prompt *variables.VariablePromptOptions +} + +type lvsViewGroup struct { + Name string + Values []lvsViewValue +} + +type lvsView struct { + Id string + Name string + Description string + SpaceId string + VariableSetId string + TemplateCount int + Variables []lvsViewGroup + WebUrl string +} + +func TestLibraryVariableSet(t *testing.T) { + runId := uuid.New() + fx := createLibraryVariableSetFixture(t, runId) + + t.Run("list", func(t *testing.T) { testLibraryVariableSetList(t, runId, fx) }) + t.Run("view", func(t *testing.T) { testLibraryVariableSetView(t, fx) }) +} + +func testLibraryVariableSetList(t *testing.T, runId uuid.UUID, fx *lvsFixture) { + t.Run("--filter finds the set and excludes the script module", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCli("Default", "library-variable-set", "list", "--filter", runId.String(), "--output-format=basic") + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + // lvsempty- and lvs- both match the run id; the script module does not appear + assert.Equal(t, []string{fx.Set.Name, fx.Empty.Name}, strings.Fields(strings.TrimSpace(stdOut))) + assert.NotContains(t, stdOut, fx.ScriptModule.Name) + }) + + t.Run("--output-format=table", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCli("Default", "library-variable-set", "list", "--filter", fx.Set.Name, "--output-format=table") + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + rows := tableRows(stdOut) + require.Len(t, rows, 2) + assert.Equal(t, []string{"NAME", "DESCRIPTION", "ID"}, rows[0]) + assert.Equal(t, []string{fx.Set.Name, "set under test", fx.Set.GetID()}, rows[1]) + }) + + t.Run("--output-format=json uses the API's field casing", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCliRawOutput("Default", "library-variable-set", "list", "--filter", fx.Set.Name, "--output-format=json") + if !testutil.AssertSuccess(t, err, string(stdOut), string(stdErr)) { + return + } + var results []lvsListItem + require.Nil(t, json.Unmarshal(stdOut, &results)) + assert.Equal(t, []lvsListItem{{ + Id: fx.Set.GetID(), + Name: fx.Set.Name, + Description: "set under test", + VariableSetId: fx.Set.VariableSetID, + }}, results) + + // Id/VariableSetId, not the Go field names, matching every other list command + assert.Contains(t, string(stdOut), `"Id"`) + assert.Contains(t, string(stdOut), `"VariableSetId"`) + }) +} + +func testLibraryVariableSetView(t *testing.T, fx *lvsFixture) { + t.Run("--output-format=table groups values under one name", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCli("Default", "library-variable-set", "view", fx.Set.Name, "--output-format=table") + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + rows := tableRows(stdOut) + require.Len(t, rows, 6) + assert.Equal(t, []string{"NAME", "VALUE", "SCOPE", "ID"}, rows[0]) + + // prompted variable has no value; sensitive one is masked + assert.Equal(t, "Ask.Me", rows[1][0]) + assert.Equal(t, []string{"Slack.Token", "***", "(unscoped)"}, rows[2][:3]) + + // the three Slack.Url values share one name cell: unscoped first, then + // scoped ones ordered by scope summary + assert.Equal(t, []string{"Slack.Url", "https://default", "(unscoped)"}, rows[3][:3]) + assert.Equal(t, []string{"", "https://prod", fmt.Sprintf("Environment: %s", fx.Environment.Name)}, rows[4][:3]) + assert.Equal(t, []string{"", "https://dev", fmt.Sprintf("Environment: %s; Role: web-server", fx.Environment.Name)}, rows[5][:3]) + }) + + t.Run("--output-format=json resolves scope IDs to names", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCliRawOutput("Default", "library-variable-set", "view", fx.Set.Name, "--output-format=json") + if !testutil.AssertSuccess(t, err, string(stdOut), string(stdErr)) { + return + } + var result lvsView + require.Nil(t, json.Unmarshal(stdOut, &result)) + + assert.Equal(t, fx.Set.GetID(), result.Id) + assert.Equal(t, fx.Set.Name, result.Name) + assert.Equal(t, "set under test", result.Description) + assert.Equal(t, space1ID, result.SpaceId) + assert.Equal(t, fx.Set.VariableSetID, result.VariableSetId) + assert.Equal(t, 0, result.TemplateCount) + assert.Contains(t, result.WebUrl, fmt.Sprintf("library/variablesets/%s", fx.Set.GetID())) + + require.Len(t, result.Variables, 3) + assert.Equal(t, []string{"Ask.Me", "Slack.Token", "Slack.Url"}, + []string{result.Variables[0].Name, result.Variables[1].Name, result.Variables[2].Name}) + + prompted := result.Variables[0] + require.Len(t, prompted.Values, 1) + require.NotNil(t, prompted.Values[0].Prompt) + assert.Equal(t, "Give me a value", prompted.Values[0].Prompt.Label) + + // the server never returns a sensitive value, so json carries the flag not the mask + sensitive := result.Variables[1] + require.Len(t, sensitive.Values, 1) + assert.True(t, sensitive.Values[0].IsSensitive) + assert.Equal(t, "", sensitive.Values[0].Value) + assert.Equal(t, "Sensitive", sensitive.Values[0].Type) + + urls := result.Variables[2] + require.Len(t, urls.Values, 3) + for _, v := range urls.Values { + assert.NotEmpty(t, v.Id) + } + + assert.False(t, urls.Values[0].IsScoped) + assert.Nil(t, urls.Values[0].Scope) + assert.Equal(t, "(unscoped)", urls.Values[0].ScopeSummary) + + assert.True(t, urls.Values[1].IsScoped) + require.NotNil(t, urls.Values[1].Scope) + assert.Equal(t, []string{fx.Environment.Name}, urls.Values[1].Scope.Environment) + assert.Nil(t, urls.Values[1].Scope.Role) + + require.NotNil(t, urls.Values[2].Scope) + assert.Equal(t, []string{fx.Environment.Name}, urls.Values[2].Scope.Environment) + assert.Equal(t, []string{"web-server"}, urls.Values[2].Scope.Role) + }) + + t.Run("resolves by ID as well as by name", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCliRawOutput("Default", "library-variable-set", "view", fx.Set.GetID(), "--output-format=json") + if !testutil.AssertSuccess(t, err, string(stdOut), string(stdErr)) { + return + } + var result lvsView + require.Nil(t, json.Unmarshal(stdOut, &result)) + assert.Equal(t, fx.Set.Name, result.Name) + }) + + t.Run("--filter narrows to matching variables", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCliRawOutput("Default", "library-variable-set", "view", fx.Set.Name, "--filter", "Token", "--output-format=json") + if !testutil.AssertSuccess(t, err, string(stdOut), string(stdErr)) { + return + } + var result lvsView + require.Nil(t, json.Unmarshal(stdOut, &result)) + require.Len(t, result.Variables, 1) + assert.Equal(t, "Slack.Token", result.Variables[0].Name) + }) + + t.Run("--filter matching nothing still emits an array", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCliRawOutput("Default", "library-variable-set", "view", fx.Set.Name, "--filter", "no-such-variable", "--output-format=json") + if !testutil.AssertSuccess(t, err, string(stdOut), string(stdErr)) { + return + } + assert.Contains(t, string(stdOut), `"Variables": []`) + }) + + t.Run("a set with no variables", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCli("Default", "library-variable-set", "view", fx.Empty.Name, "--output-format=basic") + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + assert.Contains(t, stdOut, fx.Empty.Name) + assert.Contains(t, stdOut, "No variables") + }) + + t.Run("--output-format=basic", func(t *testing.T) { + stdOut, stdErr, err := integration.RunCli("Default", "library-variable-set", "view", fx.Set.Name, "--output-format=basic") + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + assert.Contains(t, stdOut, fmt.Sprintf("%s (%s)", fx.Set.Name, fx.Set.GetID())) + assert.Contains(t, stdOut, "(unscoped) = https://default") + assert.Contains(t, stdOut, fmt.Sprintf("Environment: %s = https://prod", fx.Environment.Name)) + assert.Contains(t, stdOut, "(unscoped) = ***") + assert.Contains(t, stdOut, "prompted at deployment time") + }) + + t.Run("errors", func(t *testing.T) { + for _, tc := range []struct { + name string + args []string + expected string + }{ + {"script module by name", []string{fx.ScriptModule.Name}, fmt.Sprintf("cannot find library variable set '%s'", fx.ScriptModule.Name)}, + {"script module by ID", []string{fx.ScriptModule.GetID()}, fmt.Sprintf("cannot find library variable set '%s'", fx.ScriptModule.GetID())}, + {"unknown name", []string{"no-such-set"}, "cannot find library variable set 'no-such-set'"}, + {"no identifier without prompting", []string{}, "library variable set must be specified"}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{"library-variable-set", "view"}, tc.args...) + stdOut, stdErr, err := integration.RunCli("Default", args...) + assert.Error(t, err, stdOut) + assert.Contains(t, stdOut+stdErr, tc.expected) + }) + } + }) +} diff --git a/test/testutil/fakeoctopusserver.go b/test/testutil/fakeoctopusserver.go index d417eee3..b5cade9f 100644 --- a/test/testutil/fakeoctopusserver.go +++ b/test/testutil/fakeoctopusserver.go @@ -227,6 +227,8 @@ func NewRootResource() *octopusApiClient.RootResource { root.Links[constants.LinkAccounts] = "/api/Spaces-1/accounts{/id}{?skip,take,ids,partialName,accountType}" root.Links[constants.LinkPackages] = "/api/Spaces-1/packages{/id}{?nuGetPackageId,filter,latest,skip,take,includeNotes}" root.Links[constants.LinkLifecycles] = "/api/Spaces-1/lifecycles{/id}{?skip,take,ids,partialName}" + root.Links[constants.LinkLibraryVariables] = "/api/Spaces-1/libraryvariablesets{/id}{?skip,contentType,take,ids,partialName}" + root.Links[constants.LinkVariables] = "/api/Spaces-1/variables{/id}{?ids}" root.Links[constants.LinkProjectGroups] = "/api/Spaces-1/projectgroups{/id}{?skip,take,ids,partialName}" root.Links[constants.LinkUsers] = "/api/users" root.Links[constants.LinkCurrentUser] = "/api/users/me"