Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions pkg/cmd/channel/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -16,13 +19,19 @@ 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",
},
}

cmd.AddCommand(cmdCreate.NewCmdCreate(f))
cmd.AddCommand(cmdList.NewCmdList(f))
cmd.AddCommand(cmdView.NewCmdView(f))
cmd.AddCommand(cmdDelete.NewCmdDelete(f))

return cmd
}
175 changes: 175 additions & 0 deletions pkg/cmd/channel/delete/delete.go
Original file line number Diff line number Diff line change
@@ -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 {<name> | <id> | <slug>}",
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)
}
159 changes: 159 additions & 0 deletions pkg/cmd/channel/delete/delete_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading