diff --git a/pkg/cmd/ephemeralenvironment/delete/delete.go b/pkg/cmd/ephemeralenvironment/delete/delete.go new file mode 100644 index 00000000..0c2b1ebb --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/delete/delete.go @@ -0,0 +1,134 @@ +package delete + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/spf13/cobra" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/util" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" +) + +const ( + FlagName = "name" +) + +type DeleteFlags struct { + Name *flag.Flag[string] + *question.ConfirmFlags +} + +func NewDeleteFlags() *DeleteFlags { + return &DeleteFlags{ + Name: flag.New[string](FlagName, false), + ConfirmFlags: question.NewConfirmFlags(), + } +} + +type DeleteOptions struct { + *DeleteFlags + *cmd.Dependencies + GetAllEphemeralEnvironments func() ([]*ephemeralenvironments.EphemeralEnvironment, error) +} + +func NewDeleteOptions(deleteFlags *DeleteFlags, dependencies *cmd.Dependencies) *DeleteOptions { + return &DeleteOptions{ + DeleteFlags: deleteFlags, + Dependencies: dependencies, + GetAllEphemeralEnvironments: func() ([]*ephemeralenvironments.EphemeralEnvironment, error) { + allEnvironments, err := ephemeralenvironments.GetAll(dependencies.Client, dependencies.Space.ID) + if err != nil { + return nil, err + } + + if len(allEnvironments.Items) == 0 { + return nil, fmt.Errorf("no ephemeral environments found") + } + + return allEnvironments.Items, nil + }, + } +} + +func NewCmdDelete(f factory.Factory) *cobra.Command { + deleteFlags := NewDeleteFlags() + + command := &cobra.Command{ + Use: "delete", + Short: "Delete an ephemeral environment", + Long: "Delete an ephemeral environment in Octopus Deploy. This removes the environment without running its deprovisioning runbook.", + Aliases: []string{"del", "rm", "remove"}, + Example: heredoc.Docf(` + %[1]s ephemeral-environment delete --name PR-1234 + %[1]s ephemeral-environment delete --name PR-1234 --confirm + `, constants.ExecutableName), + RunE: func(c *cobra.Command, _ []string) error { + deps, err := cmd.NewDependencies(f, c) + if err != nil { + return err + } + opts := NewDeleteOptions(deleteFlags, deps) + + return DeleteRun(opts) + }, + } + + flags := command.Flags() + flags.StringVarP(&deleteFlags.Name.Value, deleteFlags.Name.Name, "n", "", "Name of the environment") + question.RegisterConfirmDeletionFlag(command, &deleteFlags.Confirm.Value, "ephemeral environment") + + return command +} + +func DeleteRun(opts *DeleteOptions) error { + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + + if opts.Name.Value == "" { + return fmt.Errorf("environment name is required") + } + + environment, err := util.GetByName(opts.Client, opts.Name.Value, opts.Space.ID) + if err != nil { + return err + } + + doDelete := func() error { + return ephemeralenvironments.Delete(opts.Client, opts.Space.ID, environment.ID) + } + + if opts.Confirm.Value { + if err := doDelete(); err != nil { + return err + } + _, err = fmt.Fprintf(opts.Out, "Deleted ephemeral environment '%s' with id '%s'.\n", environment.Name, environment.ID) + return err + } + + return question.DeleteWithConfirmation(opts.Ask, "ephemeral environment", environment.Name, environment.ID, doDelete) +} + +func PromptMissing(opts *DeleteOptions) error { + if opts.Name.Value != "" { + return nil + } + + environment, err := selectors.Select(opts.Ask, "Please select the name of the environment you wish to delete:", opts.GetAllEphemeralEnvironments, func(environment *ephemeralenvironments.EphemeralEnvironment) string { return environment.Name }) + if err != nil { + return err + } + + opts.Name.Value = environment.Name + + return nil +} diff --git a/pkg/cmd/ephemeralenvironment/delete/delete_test.go b/pkg/cmd/ephemeralenvironment/delete/delete_test.go new file mode 100644 index 00000000..6e3770a3 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/delete/delete_test.go @@ -0,0 +1,204 @@ +package delete_test + +import ( + "bytes" + "fmt" + "net/url" + "testing" + + "github.com/AlecAivazis/survey/v2" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/stretchr/testify/assert" + + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + + "github.com/OctopusDeploy/cli/pkg/cmd" + deleteCmd "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/delete" + cliErrors "github.com/OctopusDeploy/cli/pkg/errors" + "github.com/OctopusDeploy/cli/test/testutil" +) + +var serverUrl, _ = url.Parse("http://server") + +const ( + placeholderApiKey = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + testSpaceID = "Spaces-1" + envName = "PR-1234" + envID = "Environments-1001" +) + +var rootResource = testutil.NewRootResource() + +func setupMockAPI(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/"+testSpaceID).RespondWith(rootResource) +} + +func mockGetEnvironmentByName(t *testing.T, api *testutil.MockHttpServer, envs []*ephemeralenvironments.EphemeralEnvironment) { + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/environments/v2?name=&skip=0&take=2147483647&partialName=%s&type=Ephemeral", testSpaceID, envName)). + RespondWith(&resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: envs, + PagedResults: resources.PagedResults{TotalResults: len(envs)}, + }) +} + +func testEnvironment() *ephemeralenvironments.EphemeralEnvironment { + env := ephemeralenvironments.NewEphemeralEnvironment(envName, "Environments-0", testSpaceID) + env.ID = envID + return env +} + +func createOptions(t *testing.T, api *testutil.MockHttpServer, name string, confirm bool, noPrompt bool, ask func(survey.Prompt, any, ...survey.AskOpt) error) (*deleteCmd.DeleteOptions, *bytes.Buffer) { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + assert.NoError(t, err) + + space := spaces.NewSpace("Test Space") + space.ID = testSpaceID + + buf := new(bytes.Buffer) + + flags := deleteCmd.NewDeleteFlags() + flags.Name.Value = name + flags.Confirm.Value = confirm + + deps := &cmd.Dependencies{ + Client: octopus, + Space: space, + NoPrompt: noPrompt, + Out: buf, + Ask: ask, + } + + return deleteCmd.NewDeleteOptions(flags, deps), buf +} + +func TestDelete_WithConfirmFlag(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, buf := createOptions(t, api, envName, true, true, nil) + + assert.NoError(t, deleteCmd.DeleteRun(opts)) + assert.Contains(t, buf.String(), envName) + assert.Contains(t, buf.String(), envID) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + api.ExpectRequest(t, "DELETE", "/api/"+testSpaceID+"/environments/"+envID).RespondWith(nil) + + assert.NoError(t, <-errReceiver) +} + +func TestDelete_NoPromptWithoutConfirmFlagDoesNotDelete(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + // with prompting disabled the asker reports that it cannot prompt + ask := func(survey.Prompt, any, ...survey.AskOpt) error { + return &cliErrors.PromptDisabledError{} + } + opts, _ := createOptions(t, api, envName, false, true, ask) + + err := deleteCmd.DeleteRun(opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--confirm") + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + + assert.NoError(t, <-errReceiver) +} + +func TestDelete_PromptsForConfirmation(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + + asked := 0 + ask := func(p survey.Prompt, response any, _ ...survey.AskOpt) error { + asked++ + *(response.(*string)) = envName + return nil + } + opts, _ := createOptions(t, api, envName, false, false, ask) + + assert.NoError(t, deleteCmd.DeleteRun(opts)) + assert.Equal(t, 1, asked) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + api.ExpectRequest(t, "DELETE", "/api/"+testSpaceID+"/environments/"+envID).RespondWith(nil) + + assert.NoError(t, <-errReceiver) +} + +func TestDelete_WrongConfirmationNameDoesNotDelete(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + + ask := func(p survey.Prompt, response any, _ ...survey.AskOpt) error { + *(response.(*string)) = "something-else" + return nil + } + opts, _ := createOptions(t, api, envName, false, false, ask) + + assert.Error(t, deleteCmd.DeleteRun(opts)) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + + assert.NoError(t, <-errReceiver) +} + +func TestDelete_EnvironmentNotFound(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _ := createOptions(t, api, envName, true, true, nil) + + assert.EqualError(t, deleteCmd.DeleteRun(opts), fmt.Sprintf("no ephemeral environment found with the name '%s'", envName)) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{}) + + assert.NoError(t, <-errReceiver) +} + +func TestDelete_RequiresName(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _ := createOptions(t, api, "", true, true, nil) + assert.EqualError(t, deleteCmd.DeleteRun(opts), "environment name is required") + + return nil + }) + + setupMockAPI(t, api) + + assert.NoError(t, <-errReceiver) +} diff --git a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go index 3457fc64..1e874915 100644 --- a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go +++ b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go @@ -5,8 +5,11 @@ import ( "github.com/spf13/cobra" cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/create" + cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/delete" cmdDeprovisionEnvironment "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/deprovision-environment" cmdDeprovisionProject "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/deprovision-project" + cmdList "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/list" + cmdView "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/view" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" "github.com/OctopusDeploy/cli/pkg/factory" @@ -19,6 +22,9 @@ func NewCmdEphemeralEnvironment(f factory.Factory) *cobra.Command { Long: "Manage ephemeral environments in Octopus Deploy", Example: heredoc.Docf(` %[1]s ephemeral-environment create --name "MyEphemeralEnvironment" --project "MyProject" + %[1]s ephemeral-environment list --project "MyProject" + %[1]s ephemeral-environment view --name "MyEphemeralEnvironment" --project "MyProject" + %[1]s ephemeral-environment delete --name "MyEphemeralEnvironment" `, constants.ExecutableName), Annotations: map[string]string{ annotations.IsInfrastructure: "true", @@ -28,6 +34,9 @@ func NewCmdEphemeralEnvironment(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdCreate.NewCmdCreate(f)) cmd.AddCommand(cmdDeprovisionEnvironment.NewCmdDeprovisionEnvironment(f)) cmd.AddCommand(cmdDeprovisionProject.NewCmdDeprovisionProject(f)) + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdView.NewCmdView(f)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f)) return cmd } diff --git a/pkg/cmd/ephemeralenvironment/list/list.go b/pkg/cmd/ephemeralenvironment/list/list.go new file mode 100644 index 00000000..12f8b69f --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/list/list.go @@ -0,0 +1,149 @@ +package list + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/util/flag" +) + +const ( + FlagProject = "project" + + // StatusUnknown is shown when the status of an environment could not be read for the project. + StatusUnknown = "Unknown" +) + +type ListFlags struct { + Project *flag.Flag[string] +} + +func NewListFlags() *ListFlags { + return &ListFlags{ + Project: flag.New[string](FlagProject, false), + } +} + +type ListOptions struct { + *ListFlags + *cmd.Dependencies + Command *cobra.Command +} + +func NewListOptions(listFlags *ListFlags, dependencies *cmd.Dependencies, command *cobra.Command) *ListOptions { + return &ListOptions{ + ListFlags: listFlags, + Dependencies: dependencies, + Command: command, + } +} + +// EphemeralEnvironmentViewModel is a listed ephemeral environment. Status is only set when a project is given, +// because the provisioning status of an ephemeral environment is per project. +type EphemeralEnvironmentViewModel struct { + Id string `json:"Id"` + Name string `json:"Name"` + Status string `json:"Status,omitempty"` +} + +func NewCmdList(f factory.Factory) *cobra.Command { + listFlags := NewListFlags() + + command := &cobra.Command{ + Use: "list", + Short: "List ephemeral environments", + Long: "List ephemeral environments in Octopus Deploy. Pass a project to include the provisioning status of each environment for that project.", + Example: heredoc.Docf(` + %[1]s ephemeral-environment list + %[1]s ephemeral-environment ls + %[1]s ephemeral-environment list --project "MyProject" + `, constants.ExecutableName), + Aliases: []string{"ls"}, + RunE: func(c *cobra.Command, _ []string) error { + deps, err := cmd.NewDependencies(f, c) + if err != nil { + return err + } + opts := NewListOptions(listFlags, deps, c) + + return ListRun(opts) + }, + } + + flags := command.Flags() + flags.StringVarP(&listFlags.Project.Value, listFlags.Project.Name, "p", "", "Name of the project to show the provisioning status for") + + return command +} + +func ListRun(opts *ListOptions) error { + var project *projects.Project + + if opts.Project.Value != "" { + var err error + project, err = projects.GetByName(opts.Client, opts.Space.ID, opts.Project.Value) + if err != nil { + return fmt.Errorf("failed to find project '%s': %w", opts.Project.Value, err) + } + } + + allEnvironments, err := ephemeralenvironments.GetAll(opts.Client, opts.Space.ID) + if err != nil { + return err + } + + viewModels := make([]EphemeralEnvironmentViewModel, 0, len(allEnvironments.Items)) + for _, environment := range allEnvironments.Items { + viewModel := EphemeralEnvironmentViewModel{Id: environment.ID, Name: environment.Name} + + if project != nil { + viewModel.Status = getStatus(opts, environment, project) + } + + viewModels = append(viewModels, viewModel) + } + + header := []string{"NAME", "ID"} + if project != nil { + header = append(header, "STATUS") + } + + return output.PrintArray(viewModels, opts.Command, output.Mappers[EphemeralEnvironmentViewModel]{ + Json: func(item EphemeralEnvironmentViewModel) any { + return item + }, + Table: output.TableDefinition[EphemeralEnvironmentViewModel]{ + Header: header, + Row: func(item EphemeralEnvironmentViewModel) []string { + row := []string{output.Bold(item.Name), item.Id} + if project != nil { + row = append(row, item.Status) + } + return row + }, + }, + Basic: func(item EphemeralEnvironmentViewModel) string { + return item.Name + }, + }) +} + +// getStatus reads the provisioning status of an environment for a project. A failure for one environment +// should not stop the whole listing, so it is reported on stderr and shown as unknown. +func getStatus(opts *ListOptions, environment *ephemeralenvironments.EphemeralEnvironment, project *projects.Project) string { + status, err := ephemeralenvironments.GetStatus(opts.Client, opts.Space.ID, environment.ID, project.GetID()) + if err != nil { + opts.Command.PrintErrf("Could not get the status of ephemeral environment '%s' for project '%s': %v\n", environment.Name, project.GetName(), err) + return StatusUnknown + } + + return status.Status +} diff --git a/pkg/cmd/ephemeralenvironment/list/list_test.go b/pkg/cmd/ephemeralenvironment/list/list_test.go new file mode 100644 index 00000000..839cd7f9 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/list/list_test.go @@ -0,0 +1,253 @@ +package list_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/list" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/test/testutil" +) + +var serverUrl, _ = url.Parse("http://server") + +const ( + placeholderApiKey = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + testSpaceID = "Spaces-1" + projectName = "Test Project" + projectID = "Projects-1" +) + +var rootResource = testutil.NewRootResource() + +func setupMockAPI(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/"+testSpaceID).RespondWith(rootResource) +} + +func mockGetProjectByName(t *testing.T, api *testutil.MockHttpServer) { + project := projects.NewProject(projectName, "Lifecycles-1", "ProjectGroups-1") + project.ID = projectID + + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/projects?partialName=%s", testSpaceID, url.PathEscape(projectName))). + RespondWith(&resources.Resources[*projects.Project]{ + Items: []*projects.Project{project}, + PagedResults: resources.PagedResults{TotalResults: 1}, + }) +} + +func testEnvironment(id string, name string) *ephemeralenvironments.EphemeralEnvironment { + env := ephemeralenvironments.NewEphemeralEnvironment(name, "Environments-0", testSpaceID) + env.ID = id + return env +} + +func mockGetAllEnvironments(t *testing.T, api *testutil.MockHttpServer, envs []*ephemeralenvironments.EphemeralEnvironment) { + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/environments/v2?name=&skip=0&take=2147483647&type=Ephemeral", testSpaceID)). + RespondWith(&resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: envs, + PagedResults: resources.PagedResults{TotalResults: len(envs)}, + }) +} + +func statusPath(environmentID string) string { + return fmt.Sprintf("/api/%s/projects/%s/environments/ephemeral/%s/status", testSpaceID, projectID, environmentID) +} + +func createOptions(t *testing.T, api *testutil.MockHttpServer, project string, outputFormat string) (*list.ListOptions, *bytes.Buffer, *bytes.Buffer) { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + assert.NoError(t, err) + + space := spaces.NewSpace("Test Space") + space.ID = testSpaceID + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + command := &cobra.Command{} + command.Flags().String(constants.FlagOutputFormat, outputFormat, "") + command.SetOut(stdout) + command.SetErr(stderr) + + flags := list.NewListFlags() + flags.Project.Value = project + + deps := &cmd.Dependencies{ + Client: octopus, + Space: space, + NoPrompt: true, + Out: stdout, + } + + return list.NewListOptions(flags, deps, command), stdout, stderr +} + +func TestList_WithoutProjectHasNoStatus(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, stdout, _ := createOptions(t, api, "", constants.OutputFormatTable) + + assert.NoError(t, list.ListRun(opts)) + + assert.Contains(t, stdout.String(), "PR-1") + assert.Contains(t, stdout.String(), "Environments-1") + assert.Contains(t, stdout.String(), "PR-2") + assert.NotContains(t, stdout.String(), "STATUS") + + return nil + }) + + setupMockAPI(t, api) + mockGetAllEnvironments(t, api, []*ephemeralenvironments.EphemeralEnvironment{ + testEnvironment("Environments-1", "PR-1"), + testEnvironment("Environments-2", "PR-2"), + }) + + assert.NoError(t, <-errReceiver) +} + +func TestList_WithProjectShowsStatus(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, stdout, _ := createOptions(t, api, projectName, constants.OutputFormatTable) + + assert.NoError(t, list.ListRun(opts)) + + assert.Contains(t, stdout.String(), "STATUS") + assert.Contains(t, stdout.String(), "Provisioned") + assert.Contains(t, stdout.String(), "ProvisioningFailed") + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetAllEnvironments(t, api, []*ephemeralenvironments.EphemeralEnvironment{ + testEnvironment("Environments-1", "PR-1"), + testEnvironment("Environments-2", "PR-2"), + }) + api.ExpectRequest(t, "GET", statusPath("Environments-1")). + RespondWith(&ephemeralenvironments.EphemeralEnvironmentStatus{Status: "Provisioned"}) + api.ExpectRequest(t, "GET", statusPath("Environments-2")). + RespondWith(&ephemeralenvironments.EphemeralEnvironmentStatus{Status: "ProvisioningFailed"}) + + assert.NoError(t, <-errReceiver) +} + +func TestList_Json(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, stdout, _ := createOptions(t, api, projectName, constants.OutputFormatJson) + + assert.NoError(t, list.ListRun(opts)) + + var result []list.EphemeralEnvironmentViewModel + assert.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + assert.Equal(t, []list.EphemeralEnvironmentViewModel{ + {Id: "Environments-1", Name: "PR-1", Status: "Provisioned"}, + }, result) + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetAllEnvironments(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment("Environments-1", "PR-1")}) + api.ExpectRequest(t, "GET", statusPath("Environments-1")). + RespondWith(&ephemeralenvironments.EphemeralEnvironmentStatus{Status: "Provisioned"}) + + assert.NoError(t, <-errReceiver) +} + +func TestList_JsonWithoutProjectOmitsStatus(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, stdout, _ := createOptions(t, api, "", constants.OutputFormatJson) + + assert.NoError(t, list.ListRun(opts)) + assert.NotContains(t, stdout.String(), "Status") + + return nil + }) + + setupMockAPI(t, api) + mockGetAllEnvironments(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment("Environments-1", "PR-1")}) + + assert.NoError(t, <-errReceiver) +} + +func TestList_StatusFailureDoesNotStopListing(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, stdout, stderr := createOptions(t, api, projectName, constants.OutputFormatTable) + + assert.NoError(t, list.ListRun(opts)) + + assert.Contains(t, stdout.String(), "PR-1") + assert.Contains(t, stdout.String(), list.StatusUnknown) + assert.Contains(t, stdout.String(), "PR-2") + assert.Contains(t, stdout.String(), "Provisioned") + assert.Contains(t, stderr.String(), "Could not get the status of ephemeral environment 'PR-1'") + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetAllEnvironments(t, api, []*ephemeralenvironments.EphemeralEnvironment{ + testEnvironment("Environments-1", "PR-1"), + testEnvironment("Environments-2", "PR-2"), + }) + api.ExpectRequest(t, "GET", statusPath("Environments-1")). + RespondWithStatus(404, "404 Not Found", struct{ ErrorMessage string }{"not found"}) + api.ExpectRequest(t, "GET", statusPath("Environments-2")). + RespondWith(&ephemeralenvironments.EphemeralEnvironmentStatus{Status: "Provisioned"}) + + assert.NoError(t, <-errReceiver) +} + +func TestList_ProjectNotFound(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _, _ := createOptions(t, api, projectName, constants.OutputFormatTable) + + err := list.ListRun(opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("failed to find project '%s'", projectName)) + + return nil + }) + + setupMockAPI(t, api) + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/projects?partialName=%s", testSpaceID, url.PathEscape(projectName))). + RespondWith(&resources.Resources[*projects.Project]{ + Items: []*projects.Project{}, + PagedResults: resources.PagedResults{TotalResults: 0}, + }) + + assert.NoError(t, <-errReceiver) +} diff --git a/pkg/cmd/ephemeralenvironment/util/util.go b/pkg/cmd/ephemeralenvironment/util/util.go index f05054a1..1db4f9e0 100644 --- a/pkg/cmd/ephemeralenvironment/util/util.go +++ b/pkg/cmd/ephemeralenvironment/util/util.go @@ -3,16 +3,49 @@ package util import ( "encoding/json" "fmt" + "slices" "strings" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/spf13/cobra" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/output" ) +// GetConfiguredProjects returns the projects that have an ephemeral environment channel. +func GetConfiguredProjects(client *client.Client) ([]*projects.Project, error) { + allProjects, err := shared.GetAllProjects(client) + if err != nil { + return nil, err + } + + var filteredProjects []*projects.Project + + for _, project := range allProjects { + projectChannels, err := client.Projects.GetChannels(project) + if err != nil { + return nil, fmt.Errorf("failed to get channels for project '%s': %w", project.GetName(), err) + } + + if slices.ContainsFunc(projectChannels, func(channel *channels.Channel) bool { + return channel.Type == channels.ChannelTypeEphemeral + }) { + filteredProjects = append(filteredProjects, project) + } + } + + if len(filteredProjects) == 0 { + return nil, fmt.Errorf("no configured projects - configure a project with an ephemeral environment channel first") + } + + return filteredProjects, nil +} + func GetByName(client *client.Client, name string, spaceID string) (*ephemeralenvironments.EphemeralEnvironment, error) { environments, err := ephemeralenvironments.GetByPartialName(client, spaceID, name) if err != nil { diff --git a/pkg/cmd/ephemeralenvironment/view/view.go b/pkg/cmd/ephemeralenvironment/view/view.go new file mode 100644 index 00000000..564e6ada --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/view/view.go @@ -0,0 +1,187 @@ +package view + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/util" + "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" +) + +const ( + FlagName = "name" + FlagProject = "project" +) + +type ViewFlags struct { + Name *flag.Flag[string] + Project *flag.Flag[string] +} + +func NewViewFlags() *ViewFlags { + return &ViewFlags{ + Name: flag.New[string](FlagName, false), + Project: flag.New[string](FlagProject, false), + } +} + +type ViewOptions struct { + *ViewFlags + *cmd.Dependencies + Command *cobra.Command + GetConfiguredProjectsCallback func() ([]*projects.Project, error) + GetAllEphemeralEnvironments func() ([]*ephemeralenvironments.EphemeralEnvironment, error) +} + +func NewViewOptions(viewFlags *ViewFlags, dependencies *cmd.Dependencies, command *cobra.Command) *ViewOptions { + return &ViewOptions{ + ViewFlags: viewFlags, + Dependencies: dependencies, + Command: command, + GetConfiguredProjectsCallback: func() ([]*projects.Project, error) { + return util.GetConfiguredProjects(dependencies.Client) + }, + GetAllEphemeralEnvironments: func() ([]*ephemeralenvironments.EphemeralEnvironment, error) { + allEnvironments, err := ephemeralenvironments.GetAll(dependencies.Client, dependencies.Space.ID) + if err != nil { + return nil, err + } + + if len(allEnvironments.Items) == 0 { + return nil, fmt.Errorf("no ephemeral environments found") + } + + return allEnvironments.Items, nil + }, + } +} + +// ViewResult is what the view command outputs: an ephemeral environment and its provisioning status for a project. +type ViewResult struct { + Id string `json:"Id"` + Name string `json:"Name"` + ProjectId string `json:"ProjectId"` + ProjectName string `json:"ProjectName"` + Status string `json:"Status"` +} + +func NewCmdView(f factory.Factory) *cobra.Command { + viewFlags := NewViewFlags() + + command := &cobra.Command{ + Use: "view", + Short: "View an ephemeral environment", + Long: "View the provisioning status of an ephemeral environment for a project in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s ephemeral-environment view --name PR-1234 --project "MyProject" + %[1]s ephemeral-environment view --name PR-1234 --project "MyProject" --output-format json + `, constants.ExecutableName), + RunE: func(c *cobra.Command, _ []string) error { + deps, err := cmd.NewDependencies(f, c) + if err != nil { + return err + } + opts := NewViewOptions(viewFlags, deps, c) + + return ViewRun(opts) + }, + } + + flags := command.Flags() + flags.StringVarP(&viewFlags.Name.Value, viewFlags.Name.Name, "n", "", "Name of the environment") + flags.StringVarP(&viewFlags.Project.Value, viewFlags.Project.Name, "p", "", "Name of the project") + + return command +} + +func ViewRun(opts *ViewOptions) error { + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + + if opts.Name.Value == "" { + return fmt.Errorf("environment name is required") + } + + if opts.Project.Value == "" { + return fmt.Errorf("project name is required") + } + + project, err := projects.GetByName(opts.Client, opts.Space.ID, opts.Project.Value) + if err != nil { + return fmt.Errorf("failed to find project '%s': %w", opts.Project.Value, err) + } + + environment, err := util.GetByName(opts.Client, opts.Name.Value, opts.Space.ID) + if err != nil { + return err + } + + status, err := ephemeralenvironments.GetStatus(opts.Client, opts.Space.ID, environment.ID, project.GetID()) + if err != nil { + return fmt.Errorf("failed to get the status of ephemeral environment '%s' for project '%s': %w", environment.Name, project.GetName(), err) + } + + result := &ViewResult{ + Id: environment.ID, + Name: environment.Name, + ProjectId: project.GetID(), + ProjectName: project.GetName(), + Status: status.Status, + } + + err = output.PrintResource(result, opts.Command, output.Mappers[*ViewResult]{ + Json: func(r *ViewResult) any { return r }, + Table: output.TableDefinition[*ViewResult]{ + Header: []string{"NAME", "ID", "PROJECT", "STATUS"}, + Row: func(r *ViewResult) []string { + return []string{output.Bold(r.Name), r.Id, r.ProjectName, r.Status} + }, + }, + Basic: func(r *ViewResult) string { + return fmt.Sprintf("%s (%s) for project %s: %s", r.Name, r.Id, r.ProjectName, r.Status) + }, + }) + if err != nil { + return err + } + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.Project) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + + return nil +} + +func PromptMissing(opts *ViewOptions) error { + if opts.Project.Value == "" { + fmt.Fprintf(opts.Out, " Choose from projects configured with an ephemeral environment channel.\n") + project, err := selectors.Select(opts.Ask, "Select a project:", opts.GetConfiguredProjectsCallback, func(project *projects.Project) string { return project.GetName() }) + if err != nil { + return err + } + opts.Project.Value = project.GetName() + } + + if opts.Name.Value == "" { + environment, err := selectors.Select(opts.Ask, "Please select the name of the environment you wish to view:", opts.GetAllEphemeralEnvironments, func(environment *ephemeralenvironments.EphemeralEnvironment) string { return environment.Name }) + if err != nil { + return err + } + opts.Name.Value = environment.Name + } + + return nil +} diff --git a/pkg/cmd/ephemeralenvironment/view/view_test.go b/pkg/cmd/ephemeralenvironment/view/view_test.go new file mode 100644 index 00000000..787e0c4d --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/view/view_test.go @@ -0,0 +1,226 @@ +package view_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/view" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/test/testutil" +) + +var serverUrl, _ = url.Parse("http://server") + +const ( + placeholderApiKey = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + testSpaceID = "Spaces-1" + envName = "PR-1234" + envID = "Environments-1" + projectName = "Test Project" + projectID = "Projects-1" +) + +var rootResource = testutil.NewRootResource() + +func setupMockAPI(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/"+testSpaceID).RespondWith(rootResource) +} + +func mockGetProjectByName(t *testing.T, api *testutil.MockHttpServer) { + project := projects.NewProject(projectName, "Lifecycles-1", "ProjectGroups-1") + project.ID = projectID + + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/projects?partialName=%s", testSpaceID, url.PathEscape(projectName))). + RespondWith(&resources.Resources[*projects.Project]{ + Items: []*projects.Project{project}, + PagedResults: resources.PagedResults{TotalResults: 1}, + }) +} + +func mockGetEnvironmentByName(t *testing.T, api *testutil.MockHttpServer, envs []*ephemeralenvironments.EphemeralEnvironment) { + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/environments/v2?name=&skip=0&take=2147483647&partialName=%s&type=Ephemeral", testSpaceID, envName)). + RespondWith(&resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: envs, + PagedResults: resources.PagedResults{TotalResults: len(envs)}, + }) +} + +func testEnvironment() *ephemeralenvironments.EphemeralEnvironment { + env := ephemeralenvironments.NewEphemeralEnvironment(envName, "Environments-0", testSpaceID) + env.ID = envID + return env +} + +func createOptions(t *testing.T, api *testutil.MockHttpServer, name string, project string, outputFormat string) (*view.ViewOptions, *bytes.Buffer) { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + assert.NoError(t, err) + + space := spaces.NewSpace("Test Space") + space.ID = testSpaceID + + buf := new(bytes.Buffer) + command := &cobra.Command{} + command.Flags().String(constants.FlagOutputFormat, outputFormat, "") + command.SetOut(buf) + + flags := view.NewViewFlags() + flags.Name.Value = name + flags.Project.Value = project + + deps := &cmd.Dependencies{ + Client: octopus, + Space: space, + NoPrompt: true, + Out: buf, + } + + return view.NewViewOptions(flags, deps, command), buf +} + +func TestView_Table(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, buf := createOptions(t, api, envName, projectName, constants.OutputFormatTable) + + err := view.ViewRun(opts) + assert.NoError(t, err) + + assert.Contains(t, buf.String(), envName) + assert.Contains(t, buf.String(), envID) + assert.Contains(t, buf.String(), projectName) + assert.Contains(t, buf.String(), "ProvisioningFailed") + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/projects/%s/environments/ephemeral/%s/status", testSpaceID, projectID, envID)). + RespondWith(&ephemeralenvironments.EphemeralEnvironmentStatus{Status: "ProvisioningFailed"}) + + assert.NoError(t, <-errReceiver) +} + +func TestView_Json(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, buf := createOptions(t, api, envName, projectName, constants.OutputFormatJson) + + err := view.ViewRun(opts) + assert.NoError(t, err) + + var result view.ViewResult + assert.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, view.ViewResult{ + Id: envID, + Name: envName, + ProjectId: projectID, + ProjectName: projectName, + Status: "Provisioned", + }, result) + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/projects/%s/environments/ephemeral/%s/status", testSpaceID, projectID, envID)). + RespondWith(&ephemeralenvironments.EphemeralEnvironmentStatus{Status: "Provisioned"}) + + assert.NoError(t, <-errReceiver) +} + +func TestView_EnvironmentNotFound(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _ := createOptions(t, api, envName, projectName, constants.OutputFormatTable) + + err := view.ViewRun(opts) + assert.EqualError(t, err, fmt.Sprintf("no ephemeral environment found with the name '%s'", envName)) + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{}) + + assert.NoError(t, <-errReceiver) +} + +func TestView_StatusError(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _ := createOptions(t, api, envName, projectName, constants.OutputFormatTable) + + err := view.ViewRun(opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get the status of ephemeral environment") + + return nil + }) + + setupMockAPI(t, api) + mockGetProjectByName(t, api) + mockGetEnvironmentByName(t, api, []*ephemeralenvironments.EphemeralEnvironment{testEnvironment()}) + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/projects/%s/environments/ephemeral/%s/status", testSpaceID, projectID, envID)). + RespondWithStatus(404, "404 Not Found", struct{ ErrorMessage string }{"not found"}) + + assert.NoError(t, <-errReceiver) +} + +func TestView_RequiresName(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _ := createOptions(t, api, "", projectName, constants.OutputFormatTable) + assert.EqualError(t, view.ViewRun(opts), "environment name is required") + + return nil + }) + + setupMockAPI(t, api) + + assert.NoError(t, <-errReceiver) +} + +func TestView_RequiresProject(t *testing.T) { + api := testutil.NewMockHttpServer() + + errReceiver := testutil.GoBegin(func() error { + defer api.Close() + opts, _ := createOptions(t, api, envName, "", constants.OutputFormatTable) + assert.EqualError(t, view.ViewRun(opts), "project name is required") + + return nil + }) + + setupMockAPI(t, api) + + assert.NoError(t, <-errReceiver) +}