diff --git a/pkg/cmd/channel/channel.go b/pkg/cmd/channel/channel.go index 04fa15a3..5649afc7 100644 --- a/pkg/cmd/channel/channel.go +++ b/pkg/cmd/channel/channel.go @@ -3,6 +3,9 @@ package channel import ( "github.com/MakeNowJust/heredoc/v2" cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/channel/create" + cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/channel/delete" + cmdList "github.com/OctopusDeploy/cli/pkg/cmd/channel/list" + cmdView "github.com/OctopusDeploy/cli/pkg/cmd/channel/view" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" "github.com/OctopusDeploy/cli/pkg/factory" @@ -16,6 +19,9 @@ func NewCmdChannel(f factory.Factory) *cobra.Command { Long: "Manage channels in Octopus Deploy", Example: heredoc.Docf(` %[1]s channel create + %[1]s channel list --project myProject + %[1]s channel view "Hotfix" --project myProject + %[1]s channel delete "Hotfix" --project myProject `, constants.ExecutableName), Annotations: map[string]string{ annotations.IsCore: "true", @@ -23,6 +29,9 @@ func NewCmdChannel(f factory.Factory) *cobra.Command { } cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdView.NewCmdView(f)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f)) return cmd } diff --git a/pkg/cmd/channel/delete/delete.go b/pkg/cmd/channel/delete/delete.go new file mode 100644 index 00000000..4ddb47bf --- /dev/null +++ b/pkg/cmd/channel/delete/delete.go @@ -0,0 +1,175 @@ +package delete + +import ( + "errors" + "fmt" + + "github.com/AlecAivazis/survey/v2" + "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" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" +) + +type DeleteFlags struct { + Project *flag.Flag[string] + *question.ConfirmFlags +} + +func NewDeleteFlags() *DeleteFlags { + return &DeleteFlags{ + Project: flag.New[string](FlagProject, false), + ConfirmFlags: question.NewConfirmFlags(), + } +} + +type DeleteOptions struct { + Client *client.Client + Ask question.Asker + Out *cobra.Command + NoPrompt bool + IdOrName string + *DeleteFlags +} + +func NewCmdDelete(f factory.Factory) *cobra.Command { + deleteFlags := NewDeleteFlags() + cmd := &cobra.Command{ + Use: "delete { | | }", + Short: "Delete a channel", + Long: "Delete a channel in Octopus Deploy", + Aliases: []string{"del", "rm", "remove"}, + Example: heredoc.Docf(` + %[1]s channel delete "Hotfix" --project myProject + %[1]s channel rm Channels-123 --project myProject -y + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, args []string) error { + if deleteFlags.Project.Value == "" { + return errors.New("--project is required") + } + + c, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + idOrName := "" + if len(args) > 0 { + idOrName = args[0] + } + + opts := &DeleteOptions{ + Client: c, + Ask: f.Ask, + Out: cmd, + NoPrompt: !f.IsPromptEnabled(), + IdOrName: idOrName, + DeleteFlags: deleteFlags, + } + + return deleteRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&deleteFlags.Project.Value, deleteFlags.Project.Name, "p", "", "Name or ID of the project the channel belongs to") + question.RegisterConfirmDeletionFlag(cmd, &deleteFlags.Confirm.Value, "channel") + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + project, err := selectors.FindProject(opts.Client, opts.Project.Value) + if err != nil { + return err + } + + if !opts.NoPrompt { + if err := promptMissing(opts, project); err != nil { + return err + } + } + + if opts.IdOrName == "" { + return errors.New("channel name or ID must be specified") + } + + itemToDelete, err := resolveChannel(opts.Client, project, opts.IdOrName) + if err != nil { + return err + } + + // In interactive mode, warn for version-controlled (CaC) projects since deleting a + // channel can break OCL deployments referencing it. + if !opts.NoPrompt && project.IsVersionControlled { + opts.Out.Printf("%s This project is version-controlled (Config-as-Code). Deleting this channel can break OCL deployments that reference it.\n", + output.Yellow("Warning:")) + } + + doDelete := func() error { + return opts.Client.Channels.DeleteByID(itemToDelete.GetID()) + } + + if opts.Confirm.Value { + return doDelete() + } + return question.DeleteWithConfirmation(opts.Ask, "channel", itemToDelete.Name, itemToDelete.GetID(), doDelete) +} + +func promptMissing(opts *DeleteOptions, project *projects.Project) error { + if opts.IdOrName != "" { + return nil + } + existing, err := opts.Client.Projects.GetChannels(project) + if err != nil { + return err + } + if len(existing) == 0 { + return fmt.Errorf("project %s has no channels", project.Name) + } + var chosenName string + if err := opts.Ask(&survey.Select{ + Message: "Select the channel you wish to delete:", + Options: channelNames(existing), + }, &chosenName); err != nil { + return err + } + for _, c := range existing { + if c.Name == chosenName { + opts.IdOrName = c.GetID() + break + } + } + return nil +} + +func channelNames(cs []*channels.Channel) []string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.Name) + } + return out +} + +func resolveChannel(c *client.Client, project *projects.Project, idOrName string) (*channels.Channel, error) { + if ch, err := c.Channels.GetByID(idOrName); err == nil && ch != nil { + // Verify the channel actually belongs to the named project so we don't accidentally + // delete a channel from another project that happens to share an ID prefix. + if ch.ProjectID == project.GetID() { + return ch, nil + } + } + return selectors.FindChannel(c, project, idOrName) +} diff --git a/pkg/cmd/channel/delete/delete_test.go b/pkg/cmd/channel/delete/delete_test.go new file mode 100644 index 00000000..7866d655 --- /dev/null +++ b/pkg/cmd/channel/delete/delete_test.go @@ -0,0 +1,159 @@ +package delete_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/channels" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestChannelDelete(t *testing.T) { + const spaceID = "Spaces-1" + const projectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, projectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + + hotfixChannel := fixtures.NewChannel(spaceID, "Channels-2", "Hotfix", projectID) + hotfixChannel.Type = channels.ChannelTypeLifecycle + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"channel delete requires a project", 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{"channel", "delete", "Channels-2", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "--project is required") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel delete by id with confirm flag (automation)", 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{"channel", "delete", "Channels-2", "-p", "Projects-22", "--no-prompt", "-y"}) + 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/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-2").RespondWith(hotfixChannel) + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/channels/Channels-2").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel delete by name falls back to project lookup", 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{"channel", "delete", "Hotfix", "-p", "Projects-22", "--no-prompt", "-y"}) + 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/projects/Projects-22").RespondWith(fireProject) + // GetByID with a name 404s, so we fall back to the project-scoped channel list. + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Hotfix").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{hotfixChannel}, + }) + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/channels/Channels-2").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel delete interactive prompts for confirmation", 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{"channel", "delete", "Channels-2", "-p", "Projects-22"}) + 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/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-2").RespondWith(hotfixChannel) + + q := qa.ExpectQuestion(t, &survey.Input{ + Message: `You are about to delete the channel "Hotfix" (Channels-2). This action cannot be reversed. To confirm, type the channel name:`, + }) + _ = q.AnswerWith("Hotfix") + + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/channels/Channels-2").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel delete warns for version-controlled (CaC) projects", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cacProject := fixtures.NewVersionControlledProject(spaceID, projectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + cacProject.IsVersionControlled = true + cacChannel := fixtures.NewChannel(spaceID, "Channels-2", "Hotfix", projectID) + cacChannel.Type = channels.ChannelTypeLifecycle + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"channel", "delete", "Channels-2", "-p", "Projects-22", "-y"}) + 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/projects/Projects-22").RespondWith(cacProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-2").RespondWith(cacChannel) + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/channels/Channels-2").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Warning: This project is version-controlled (Config-as-Code). Deleting this channel can break OCL deployments that reference it. + `), 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/channel/list/list.go b/pkg/cmd/channel/list/list.go new file mode 100644 index 00000000..e2fbd196 --- /dev/null +++ b/pkg/cmd/channel/list/list.go @@ -0,0 +1,154 @@ +package list + +import ( + "errors" + "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" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagPartialName = "partial-name" +) + +type ListFlags struct { + Project *flag.Flag[string] + PartialName *flag.Flag[string] +} + +func NewListFlags() *ListFlags { + return &ListFlags{ + Project: flag.New[string](FlagProject, false), + PartialName: flag.New[string](FlagPartialName, false), + } +} + +type ChannelViewModel struct { + ID string + Name string + Description string + LifecycleID string + IsDefault bool + Type string +} + +func NewCmdList(f factory.Factory) *cobra.Command { + listFlags := NewListFlags() + cmd := &cobra.Command{ + Use: "list", + Short: "List channels", + Long: "List channels for a project in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s channel list myProject + %[1]s channel ls "Other Project" + %[1]s channel list --project myProject + %[1]s channel list --project myProject --partial-name "Hotfix" + `, constants.ExecutableName), + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && listFlags.Project.Value == "" { + listFlags.Project.Value = args[0] + } + + return listRun(cmd, f, listFlags) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&listFlags.Project.Value, listFlags.Project.Name, "p", "", "Name or ID of the project to list channels for") + flags.StringVar(&listFlags.PartialName.Value, listFlags.PartialName.Name, "", "Filter channels by partial name match (case-insensitive)") + return cmd +} + +func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error { + outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) + if err != nil { // should never happen, but fallback if it does + outputFormat = constants.OutputFormatTable + } + + projectNameOrID := flags.Project.Value + + octopus, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + var selectedProject *projects.Project + if f.IsPromptEnabled() { + if projectNameOrID == "" { + selectedProject, err = selectors.Project("Select the project to list channels for", octopus, f.Ask) + if err != nil { + return err + } + } else { + selectedProject, err = selectors.FindProject(octopus, projectNameOrID) + if err != nil { + return err + } + if !constants.IsProgrammaticOutputFormat(outputFormat) { + cmd.Printf("Project %s\n", output.Cyan(selectedProject.Name)) + } + } + } else { + if projectNameOrID == "" { + return errors.New("project must be specified") + } + selectedProject, err = selectors.FindProject(octopus, projectNameOrID) + if err != nil { + return err + } + } + + // Projects.GetChannels handles paging internally and returns the project-scoped list. + // Server-side partialName filtering on the project-scoped endpoint isn't exposed by the + // SDK helper, so we filter client-side (mirrors pkg/question/selectors/channels.go). + allChannels, err := octopus.Projects.GetChannels(selectedProject) + if err != nil { + return err + } + + partial := strings.ToLower(flags.PartialName.Value) + viewModels := make([]ChannelViewModel, 0, len(allChannels)) + for _, c := range allChannels { + if partial != "" && !strings.Contains(strings.ToLower(c.Name), partial) { + continue + } + viewModels = append(viewModels, ChannelViewModel{ + ID: c.ID, + Name: c.Name, + Description: c.Description, + LifecycleID: c.LifecycleID, + IsDefault: c.IsDefault, + Type: string(c.Type), + }) + } + + return output.PrintArray(viewModels, cmd, output.Mappers[ChannelViewModel]{ + Json: func(item ChannelViewModel) any { + return item + }, + Table: output.TableDefinition[ChannelViewModel]{ + Header: []string{"NAME", "TYPE", "DEFAULT", "LIFECYCLE ID"}, + Row: func(item ChannelViewModel) []string { + def := "" + if item.IsDefault { + def = "*" + } + return []string{item.Name, item.Type, def, item.LifecycleID} + }, + }, + Basic: func(item ChannelViewModel) string { + return item.Name + }, + }) +} + diff --git a/pkg/cmd/channel/list/list_test.go b/pkg/cmd/channel/list/list_test.go new file mode 100644 index 00000000..921e4895 --- /dev/null +++ b/pkg/cmd/channel/list/list_test.go @@ -0,0 +1,227 @@ +package list_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/channels" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestChannelList(t *testing.T) { + const spaceID = "Spaces-1" + const projectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, projectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + + defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Default", projectID) + defaultChannel.IsDefault = true + defaultChannel.LifecycleID = "Lifecycles-1" + defaultChannel.Type = channels.ChannelTypeLifecycle + + hotfixChannel := fixtures.NewChannel(spaceID, "Channels-2", "Hotfix", projectID) + hotfixChannel.Description = "Urgent fixes" + hotfixChannel.LifecycleID = "Lifecycles-1" + hotfixChannel.Type = channels.ChannelTypeLifecycle + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"channel list requires a project 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{"channel", "list", "--no-prompt", "-f", "table"}) + 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, "project must be specified") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel list prompts for project 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{"channel", "list", "-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/projects/all").RespondWith([]*projects.Project{fireProject}) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the project to list channels for", + Options: []string{fireProject.Name}, + }).AnswerWith(fireProject.Name) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, hotfixChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME TYPE DEFAULT LIFECYCLE ID + Default Lifecycle * Lifecycles-1 + Hotfix Lifecycle Lifecycles-1 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel list picks up project from args in automation mode and prints list", 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{"channel", "list", "Projects-22", "--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/projects/Projects-22").RespondWith(fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, hotfixChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME TYPE DEFAULT LIFECYCLE ID + Default Lifecycle * Lifecycles-1 + Hotfix Lifecycle Lifecycles-1 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel list picks up project from flag and filters by partial 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{"channel", "list", "-p", "Projects-22", "--partial-name", "hot", "--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/projects/Projects-22").RespondWith(fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, hotfixChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME TYPE DEFAULT LIFECYCLE ID + Hotfix Lifecycle Lifecycles-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{"channel", "list", "-p", "Projects-22", "--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/projects/Projects-22").RespondWith(fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, hotfixChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type x struct { + ID string + Name string + Description string + LifecycleID string + IsDefault bool + Type string + } + parsedStdout, err := testutil.ParseJsonStrict[[]x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, []x{ + {ID: "Channels-1", Name: "Default", Description: "", LifecycleID: "Lifecycles-1", IsDefault: true, Type: "Lifecycle"}, + {ID: "Channels-2", Name: "Hotfix", Description: "Urgent fixes", LifecycleID: "Lifecycles-1", IsDefault: false, Type: "Lifecycle"}, + }, 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{"channel", "list", "-p", "Projects-22", "--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/projects/Projects-22").RespondWith(fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, hotfixChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Default + Hotfix + `), 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/channel/view/view.go b/pkg/cmd/channel/view/view.go new file mode 100644 index 00000000..e50ccd78 --- /dev/null +++ b/pkg/cmd/channel/view/view.go @@ -0,0 +1,253 @@ +package view + +import ( + "errors" + "fmt" + "io" + "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" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "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/channels" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagWeb = "web" + + // inheritedLifecycle is shown when a channel has no lifecycle of its own and + // therefore inherits the project's lifecycle (e.g. the default channel). + inheritedLifecycle = "Inherited from project" +) + +type ViewFlags struct { + Project *flag.Flag[string] + Web *flag.Flag[bool] +} + +func NewViewFlags() *ViewFlags { + return &ViewFlags{ + Project: flag.New[string](FlagProject, false), + Web: flag.New[bool](FlagWeb, false), + } +} + +type ViewOptions struct { + Client *client.Client + Host string + out io.Writer + idOrName string + flags *ViewFlags + Command *cobra.Command +} + +func NewCmdView(f factory.Factory) *cobra.Command { + viewFlags := NewViewFlags() + cmd := &cobra.Command{ + Args: usage.ExactArgs(1), + Use: "view { | | }", + Short: "View a channel", + Long: "View a channel in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s channel view "Hotfix" --project myProject + %[1]s channel view Channels-123 --project myProject + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, args []string) error { + if viewFlags.Project.Value == "" { + return errors.New("--project is required") + } + + c, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + opts := &ViewOptions{ + Client: c, + Host: f.GetCurrentHost(), + out: cmd.OutOrStdout(), + idOrName: args[0], + flags: viewFlags, + Command: cmd, + } + + return viewRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&viewFlags.Project.Value, viewFlags.Project.Name, "p", "", "Name or ID of the project the channel belongs to") + flags.BoolVarP(&viewFlags.Web.Value, viewFlags.Web.Name, "w", false, "Open in web browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + project, err := selectors.FindProject(opts.Client, opts.flags.Project.Value) + if err != nil { + return err + } + + // Try direct GetByID first; if that fails (the user gave a name or slug), fall back to + // the project-scoped FindChannel which iterates Projects.GetChannels. + var channel *channels.Channel + channel, err = opts.Client.Channels.GetByID(opts.idOrName) + if err != nil || channel == nil { + channel, err = selectors.FindChannel(opts.Client, project, opts.idOrName) + if err != nil { + return err + } + } + + // Resolve lifecycle name for display (best-effort — fall back to ID on error). + // A channel with no LifecycleId (e.g. the default channel) inherits the project's lifecycle. + lifecycleName := channel.LifecycleID + if channel.LifecycleID != "" { + if lc, lcErr := opts.Client.Lifecycles.GetByIDOrName(channel.LifecycleID); lcErr == nil && lc != nil { + lifecycleName = lc.Name + } + } + + webPath := fmt.Sprintf("projects/%s/deployments/channels/edit/%s", project.Slug, channel.ID) + + return output.PrintResource(channel, opts.Command, output.Mappers[*channels.Channel]{ + Json: func(c *channels.Channel) any { + return ChannelAsJson{ + Id: c.ID, + Name: c.Name, + Description: c.Description, + ProjectId: c.ProjectID, + LifecycleId: c.LifecycleID, + LifecycleName: lifecycleName, + IsDefault: c.IsDefault, + Type: string(c.Type), + TenantTags: c.TenantTags, + EphemeralEnvironmentNameTemplate: c.EphemeralEnvironmentNameTemplate, + ParentEnvironmentId: c.ParentEnvironmentID, + AutomaticEphemeralEnvironmentDeployments: c.AutomaticEphemeralEnvironmentDeployments, + RuleCount: len(c.Rules), + GitReferenceRuleCount: len(c.GitReferenceRules), + GitResourceRuleCount: len(c.GitResourceRules), + CustomFieldDefinitionCount: len(c.CustomFieldDefinitions), + WebUrl: util.GenerateWebURL(opts.Host, c.SpaceID, webPath), + } + }, + Table: output.TableDefinition[*channels.Channel]{ + Header: []string{"NAME", "TYPE", "DEFAULT", "LIFECYCLE", "DESCRIPTION", "WEB URL"}, + Row: func(c *channels.Channel) []string { + description := c.Description + if description == "" { + description = constants.NoDescription + } + def := "" + if c.IsDefault { + def = "yes" + } + lifecycleDisplay := lifecycleName + if c.LifecycleID == "" { + lifecycleDisplay = inheritedLifecycle + } + return []string{ + output.Bold(c.Name), + string(c.Type), + def, + lifecycleDisplay, + description, + output.Blue(util.GenerateWebURL(opts.Host, c.SpaceID, webPath)), + } + }, + }, + Basic: func(c *channels.Channel) string { + return formatChannelForBasic(opts, c, lifecycleName, webPath) + }, + }) +} + +type ChannelAsJson struct { + Id string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + ProjectId string `json:"ProjectId"` + LifecycleId string `json:"LifecycleId"` + LifecycleName string `json:"LifecycleName,omitempty"` + IsDefault bool `json:"IsDefault"` + Type string `json:"Type"` + TenantTags []string `json:"TenantTags,omitempty"` + EphemeralEnvironmentNameTemplate string `json:"EphemeralEnvironmentNameTemplate,omitempty"` + ParentEnvironmentId string `json:"ParentEnvironmentId,omitempty"` + AutomaticEphemeralEnvironmentDeployments bool `json:"AutomaticEphemeralEnvironmentDeployments,omitempty"` + RuleCount int `json:"RuleCount"` + GitReferenceRuleCount int `json:"GitReferenceRuleCount"` + GitResourceRuleCount int `json:"GitResourceRuleCount"` + CustomFieldDefinitionCount int `json:"CustomFieldDefinitionCount"` + WebUrl string `json:"WebUrl"` +} + +func formatChannelForBasic(opts *ViewOptions, c *channels.Channel, lifecycleName, webPath string) string { + var result strings.Builder + + result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(c.Name), output.Dimf("(%s)", c.ID))) + + if c.IsDefault { + result.WriteString(fmt.Sprintf("%s\n", output.Cyan("Default channel"))) + } + + result.WriteString(fmt.Sprintf("Type: %s\n", string(c.Type))) + if c.LifecycleID == "" { + result.WriteString(fmt.Sprintf("Lifecycle: %s\n", inheritedLifecycle)) + } else { + result.WriteString(fmt.Sprintf("Lifecycle: %s %s\n", lifecycleName, output.Dimf("(%s)", c.LifecycleID))) + } + + if c.Description == "" { + result.WriteString(fmt.Sprintln(output.Dim(constants.NoDescription))) + } else { + result.WriteString(fmt.Sprintln(output.Dim(c.Description))) + } + + if len(c.TenantTags) > 0 { + result.WriteString(fmt.Sprintf("Tenant tags: %s\n", output.Cyan(output.FormatAsList(c.TenantTags)))) + } + + if string(c.Type) == "EphemeralEnvironment" { + if c.ParentEnvironmentID != "" { + result.WriteString(fmt.Sprintf("Parent environment: %s\n", c.ParentEnvironmentID)) + } + if c.EphemeralEnvironmentNameTemplate != "" { + result.WriteString(fmt.Sprintf("Ephemeral environment name template: %s\n", c.EphemeralEnvironmentNameTemplate)) + } + result.WriteString(fmt.Sprintf("Automatic deployments: %t\n", c.AutomaticEphemeralEnvironmentDeployments)) + } + + if len(c.Rules) > 0 { + result.WriteString(fmt.Sprintf("Version rules: %d\n", len(c.Rules))) + } + if len(c.GitReferenceRules) > 0 { + result.WriteString(fmt.Sprintf("Git reference rules: %d\n", len(c.GitReferenceRules))) + } + if len(c.GitResourceRules) > 0 { + result.WriteString(fmt.Sprintf("Git resource rules: %d\n", len(c.GitResourceRules))) + } + if len(c.CustomFieldDefinitions) > 0 { + result.WriteString(fmt.Sprintf("Custom field definitions: %d\n", len(c.CustomFieldDefinitions))) + } + + url := util.GenerateWebURL(opts.Host, c.SpaceID, webPath) + result.WriteString(fmt.Sprintf("\nView this channel in Octopus Deploy: %s\n", output.Blue(url))) + + if opts.flags.Web.Value { + _ = browser.OpenURL(url) + } + + return result.String() +} diff --git a/pkg/cmd/channel/view/view_test.go b/pkg/cmd/channel/view/view_test.go new file mode 100644 index 00000000..3a2c8ea3 --- /dev/null +++ b/pkg/cmd/channel/view/view_test.go @@ -0,0 +1,194 @@ +package view_test + +import ( + "bytes" + "testing" + + "github.com/MakeNowJust/heredoc/v2" + cmdChannelView "github.com/OctopusDeploy/cli/pkg/cmd/channel/view" + 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/channels" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/lifecycles" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestChannelView(t *testing.T) { + const spaceID = "Spaces-1" + const projectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, projectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + fireProject.Slug = "fire-project" + + lifecycle := lifecycles.NewLifecycle("Default Lifecycle") + lifecycle.ID = "Lifecycles-1" + + hotfixChannel := fixtures.NewChannel(spaceID, "Channels-2", "Hotfix", projectID) + hotfixChannel.Description = "Urgent fixes" + hotfixChannel.LifecycleID = "Lifecycles-1" + hotfixChannel.Type = channels.ChannelTypeLifecycle + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"channel view requires a project", 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{"channel", "view", "Channels-2"}) + return rootCmd.ExecuteC() + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "--project is required") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel view by id (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{"channel", "view", "Channels-2", "-p", "Projects-22", "-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/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-2").RespondWith(hotfixChannel) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWith(lifecycle) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME TYPE DEFAULT LIFECYCLE DESCRIPTION WEB URL + Hotfix Lifecycle Default Lifecycle Urgent fixes http://server/app#/Spaces-1/projects/fire-project/deployments/channels/edit/Channels-2 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel view by name falls back to project lookup (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{"channel", "view", "Hotfix", "-p", "Projects-22", "-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/projects/Projects-22").RespondWith(fireProject) + // GetByID with a name 404s, so we fall back to the project-scoped channel list. + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Hotfix").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{hotfixChannel}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWith(lifecycle) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Hotfix (Channels-2) + Type: Lifecycle + Lifecycle: Default Lifecycle (Lifecycles-1) + Urgent fixes + + View this channel in Octopus Deploy: http://server/app#/Spaces-1/projects/fire-project/deployments/channels/edit/Channels-2 + + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"channel view default channel shows inherited lifecycle (basic)", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + // The default channel has no LifecycleId of its own; it inherits the project's lifecycle. + defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Default", projectID) + defaultChannel.IsDefault = true + defaultChannel.Type = channels.ChannelTypeLifecycle + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"channel", "view", "Channels-1", "-p", "Projects-22", "-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/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-1").RespondWith(defaultChannel) + // No lifecycle lookup is made because LifecycleId is empty. + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Default (Channels-1) + Default channel + Type: Lifecycle + Lifecycle: Inherited from project + No description provided + + View this channel in Octopus Deploy: http://server/app#/Spaces-1/projects/fire-project/deployments/channels/edit/Channels-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{"channel", "view", "Channels-2", "-p", "Projects-22", "-f", "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/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-2").RespondWith(hotfixChannel) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWith(lifecycle) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + parsedStdout, err := testutil.ParseJsonStrict[cmdChannelView.ChannelAsJson](stdOut) + assert.Nil(t, err) + + assert.Equal(t, "Channels-2", parsedStdout.Id) + assert.Equal(t, "Hotfix", parsedStdout.Name) + assert.Equal(t, "Urgent fixes", parsedStdout.Description) + assert.Equal(t, "Projects-22", parsedStdout.ProjectId) + assert.Equal(t, "Lifecycles-1", parsedStdout.LifecycleId) + assert.Equal(t, "Default Lifecycle", parsedStdout.LifecycleName) + assert.Equal(t, "Lifecycle", parsedStdout.Type) + assert.False(t, parsedStdout.IsDefault) + 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/runbook/run/preview_variables_test.go b/pkg/cmd/runbook/run/preview_variables_test.go new file mode 100644 index 00000000..dc185ca7 --- /dev/null +++ b/pkg/cmd/runbook/run/preview_variables_test.go @@ -0,0 +1,108 @@ +package run + +import ( + "testing" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/core" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/stretchr/testify/assert" +) + +func noPromptAsker(t *testing.T) question.Asker { + t.Helper() + return func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + t.Fatalf("unexpected prompt: %T %#v", p, p) + return nil + } +} + +func cannedAsker(answer interface{}) question.Asker { + return func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + return core.WriteAnswer(response, "", answer) + } +} + +// Regression test for issue #582: command-line --variable values that don't +// match a runbook form preview control were being silently dropped in +// interactive mode, causing the run to fall back to default values. +func TestResolveRunbookPreviewVariables_PreservesCmdVarWithoutMatchingControl(t *testing.T) { + controls := map[string]*deployments.Control{} + values := map[string]string{} + cmdVars := map[string]string{ + "Approver": "John", + "Signoff": "Jane", + } + + result, sensitive, err := resolveRunbookPreviewVariables(noPromptAsker(t), controls, values, cmdVars) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{"Approver": "John", "Signoff": "Jane"}, result) + assert.Empty(t, sensitive) +} + +func TestResolveRunbookPreviewVariables_CanonicalisesCasingForMatchedControl(t *testing.T) { + approver := deployments.NewControl("VariableValue", "Approver", "", "", false, &resources.DisplaySettings{}) + controls := map[string]*deployments.Control{"elem-1": approver} + values := map[string]string{"elem-1": ""} + cmdVars := map[string]string{"APPROVER": "John"} + + result, _, err := resolveRunbookPreviewVariables(noPromptAsker(t), controls, values, cmdVars) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{"Approver": "John"}, result) +} + +func TestResolveRunbookPreviewVariables_DoesNotPromptWhenCmdVarSuppliedForRequiredControl(t *testing.T) { + approver := deployments.NewControl("VariableValue", "Approver", "", "", true, &resources.DisplaySettings{}) + controls := map[string]*deployments.Control{"elem-1": approver} + values := map[string]string{"elem-1": ""} + cmdVars := map[string]string{"Approver": "John"} + + result, _, err := resolveRunbookPreviewVariables(noPromptAsker(t), controls, values, cmdVars) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{"Approver": "John"}, result) +} + +func TestResolveRunbookPreviewVariables_TracksSensitiveControl(t *testing.T) { + token := deployments.NewControl("VariableValue", "Token", "", "", true, resources.NewDisplaySettings(resources.ControlTypeSensitive, nil)) + controls := map[string]*deployments.Control{"elem-1": token} + values := map[string]string{"elem-1": ""} + cmdVars := map[string]string{"Token": "secret"} + + result, sensitive, err := resolveRunbookPreviewVariables(noPromptAsker(t), controls, values, cmdVars) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{"Token": "secret"}, result) + assert.Equal(t, []string{"Token"}, sensitive) +} + +func TestResolveRunbookPreviewVariables_PromptsForRequiredControlWithoutCmdVar(t *testing.T) { + approver := deployments.NewControl("VariableValue", "Approver", "", "", true, &resources.DisplaySettings{}) + controls := map[string]*deployments.Control{"elem-1": approver} + values := map[string]string{"elem-1": ""} + cmdVars := map[string]string{} + + result, _, err := resolveRunbookPreviewVariables(cannedAsker("John"), controls, values, cmdVars) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{"Approver": "John"}, result) +} + +func TestResolveRunbookPreviewVariables_PreservesUnmatchedAndCanonicalisesMatched(t *testing.T) { + approver := deployments.NewControl("VariableValue", "Approver", "", "", false, &resources.DisplaySettings{}) + controls := map[string]*deployments.Control{"elem-1": approver} + values := map[string]string{"elem-1": ""} + cmdVars := map[string]string{ + "ApprOVER": "John", + "extra": "passthrough", + } + + result, _, err := resolveRunbookPreviewVariables(noPromptAsker(t), controls, values, cmdVars) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{"Approver": "John", "extra": "passthrough"}, result) +} diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index a05f567a..8c4fcbfb 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -1076,8 +1076,24 @@ func askRunbookPreviewVariables( } } - // Process variables from command line and prompts - result := make(map[string]string) + return resolveRunbookPreviewVariables(asker, flattenedControls, flattenedValues, variablesFromCmd) +} + +// resolveRunbookPreviewVariables merges command-line --variable values with the +// runbook form preview, prompting for any required prompted variables not +// supplied on the command line. Command-line variables that don't match a +// preview control are passed through unchanged (issue #582). +func resolveRunbookPreviewVariables( + asker question.Asker, + flattenedControls map[string]*deployments.Control, + flattenedValues map[string]string, + variablesFromCmd map[string]string, +) (map[string]string, []string, error) { + result := make(map[string]string, len(variablesFromCmd)) + for k, v := range variablesFromCmd { + result[k] = v + } + lcaseVarsFromCmd := make(map[string]string, len(variablesFromCmd)) for k, v := range variablesFromCmd { lcaseVarsFromCmd[strings.ToLower(k)] = v @@ -1088,14 +1104,19 @@ func askRunbookPreviewVariables( return keys[i] > keys[j] }) - // Track sensitive variables sensitiveVars := make([]string, 0) for _, key := range keys { control := flattenedControls[key] valueFromCmd, foundValueOnCommandLine := lcaseVarsFromCmd[strings.ToLower(control.Name)] if foundValueOnCommandLine { - // implicitly fixes up variable casing + // Canonicalise to control.Name when the CLI used a different casing, + // so we don't end up with both spellings in the result map. + for k := range result { + if k != control.Name && strings.EqualFold(k, control.Name) { + delete(result, k) + } + } result[control.Name] = valueFromCmd } if control.Required == true && !foundValueOnCommandLine { @@ -1114,7 +1135,6 @@ func askRunbookPreviewVariables( result[control.Name] = responseString } - // Track sensitive variables from the preview if control.DisplaySettings.ControlType == "Sensitive" { sensitiveVars = append(sensitiveVars, control.Name) }