diff --git a/go.mod b/go.mod index d32a79d2..fe5cbd52 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.88.0 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 35eb3a5a..d12df80c 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,10 @@ github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4 github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0 h1:yGoohDFkruQ13mxoK21J+U+IUkxoU5jSxGEpknW/E5Y= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.1-0.20251107005245-10b49a98c515 h1:7L4l7zszH5TKP14BXCzgqT86xmHPQ4vQSmrqHc4Bj7E= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.1-0.20251107005245-10b49a98c515/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.88.0 h1:doBP2Xk11l3tNqJfrF0cYqX0df3UHIQiQCts1qAux+w= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.88.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/ephemeralenvironment/deprovision-environment/deprovision_environment.go b/pkg/cmd/ephemeralenvironment/deprovision-environment/deprovision_environment.go new file mode 100644 index 00000000..f5d8d615 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/deprovision-environment/deprovision_environment.go @@ -0,0 +1,181 @@ +package deprovision_environment + +import ( + "encoding/json" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "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" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/spf13/cobra" +) + +const ( + FlagName = "name" +) + +type DeprovisionEnvironmentFlags struct { + Name *flag.Flag[string] +} + +func NewDeprovisionEnvironmentFlags() *DeprovisionEnvironmentFlags { + return &DeprovisionEnvironmentFlags{ + Name: flag.New[string](FlagName, false), + } +} + +type DeprovisionEnvironmentOptions struct { + *DeprovisionEnvironmentFlags + *cmd.Dependencies + Command *cobra.Command + GetAllEphemeralEnvironments func() ([]*ephemeralenvironments.EphemeralEnvironment, error) +} + +func NewDeprovisionEnvironmentOptions(deprovisionEnvironmentFlags *DeprovisionEnvironmentFlags, dependencies *cmd.Dependencies, command *cobra.Command) *DeprovisionEnvironmentOptions { + return &DeprovisionEnvironmentOptions{ + DeprovisionEnvironmentFlags: deprovisionEnvironmentFlags, + Dependencies: dependencies, + Command: command, + GetAllEphemeralEnvironments: func() ([]*ephemeralenvironments.EphemeralEnvironment, error) { + return getAllEphemeralEnvironments(dependencies) + }, + } +} + +func getAllEphemeralEnvironments(dependencies *cmd.Dependencies) ([]*ephemeralenvironments.EphemeralEnvironment, error) { + allEphemeralEnvironments, err := ephemeralenvironments.GetAll(dependencies.Client, dependencies.Client.GetSpaceID()) + + if err != nil { + return nil, err + } + + return allEphemeralEnvironments.Items, nil +} + +func NewCmdDeprovisionEnvironment(factory factory.Factory) *cobra.Command { + deprovisionEnvironmentFlags := NewDeprovisionEnvironmentFlags() + + command := &cobra.Command{ + Use: "deprovision-environment", + Short: "Deprovision an environment", + Long: "Deprovision an environment", + Example: heredoc.Docf("$ %s ephemeral-environment deprovision-environment --name PR-1234", constants.ExecutableName), + RunE: func(c *cobra.Command, _ []string) error { + deprovisionEnvironmentOptions := NewDeprovisionEnvironmentOptions(deprovisionEnvironmentFlags, cmd.NewDependencies(factory, c), c) + + return DeprovisionEnvironmentRun(deprovisionEnvironmentOptions, c) + }, + } + + flags := command.Flags() + flags.StringVarP(&deprovisionEnvironmentFlags.Name.Value, FlagName, "n", "", "Name of the environment") + + return command +} + +func DeprovisionEnvironmentRun(deprovisionEnvironmentOptions *DeprovisionEnvironmentOptions, command *cobra.Command) error { + var err error + + if !deprovisionEnvironmentOptions.NoPrompt { + err = PromptMissing(deprovisionEnvironmentOptions) + if err != nil { + return err + } + } + + if deprovisionEnvironmentOptions.Name.Value == "" { + return fmt.Errorf("environment name is required") + } + + var environment *ephemeralenvironments.EphemeralEnvironment + + environment, err = util.GetByName(deprovisionEnvironmentOptions.Client, deprovisionEnvironmentOptions.Name.Value, deprovisionEnvironmentOptions.Space.ID) + if err != nil { + return err + } + + var response *ephemeralenvironments.DeprovisionEphemeralEnvironmentResponse + + response, err = deprovisionEnvironment(deprovisionEnvironmentOptions, environment) + if err != nil { + return err + } + + outputResult(deprovisionEnvironmentOptions, command, response) + + return nil +} + +func outputResult(deprovisionEnvironmentOptions *DeprovisionEnvironmentOptions, command *cobra.Command, response *ephemeralenvironments.DeprovisionEphemeralEnvironmentResponse) { + command.Println("Deprovisioning environment: " + deprovisionEnvironmentOptions.Name.Value) + + outputFormat, err := command.Flags().GetString(constants.FlagOutputFormat) + if err != nil { // should never happen, but fallback if it does + outputFormat = constants.OutputFormatTable + } + + switch outputFormat { + case constants.OutputFormatBasic: + if response.DeprovisioningRuns == nil || len(response.DeprovisioningRuns) == 0 { + command.Println("Environment deprovisioned without running a runbook.") + } else { + for _, run := range response.DeprovisioningRuns { + command.Printf("Runbook Run ID: %s \nServer Task ID %s \n", run.RunbookRunID, run.TaskId) + } + } + case constants.OutputFormatJson: + data, err := json.Marshal(response.DeprovisioningRuns) + if err != nil { // shouldn't happen but fallback in case + command.PrintErrln(err) + } else { + _, _ = command.OutOrStdout().Write(data) + command.Println() + } + default: // table + if response.DeprovisioningRuns == nil || len(response.DeprovisioningRuns) == 0 { + command.Println("Environment deprovisioned without running a runbook.") + } else { + t := output.NewTable(command.OutOrStdout()) + t.AddRow(output.Bold("Runbook Run ID"), output.Bold("Server Task ID")) + for _, run := range response.DeprovisioningRuns { + t.AddRow(run.RunbookRunID, run.TaskId) + } + err := t.Print() + if err != nil { + command.PrintErrln(err) + } + } + } +} + +func deprovisionEnvironment(deprovisionEnvironmentOptions *DeprovisionEnvironmentOptions, environment *ephemeralenvironments.EphemeralEnvironment) (*ephemeralenvironments.DeprovisionEphemeralEnvironmentResponse, error) { + response, err := ephemeralenvironments.Deprovision(deprovisionEnvironmentOptions.Client, deprovisionEnvironmentOptions.Space.ID, environment.ID) + + if err != nil { + return nil, err + } + + return response, nil +} + +func PromptMissing(options *DeprovisionEnvironmentOptions) error { + if options.Name.Value != "" { + return nil + } + + environment, err := selectors.Select(options.Ask, "Please select the name of the environment you wish to deprovision", options.GetAllEphemeralEnvironments, func(environment *ephemeralenvironments.EphemeralEnvironment) string { return environment.Name }) + + if err != nil { + return err + } + + options.Name.Value = environment.Name + + return nil +} diff --git a/pkg/cmd/ephemeralenvironment/deprovision-environment/deprovision_environment_test.go b/pkg/cmd/ephemeralenvironment/deprovision-environment/deprovision_environment_test.go new file mode 100644 index 00000000..bdac7a84 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/deprovision-environment/deprovision_environment_test.go @@ -0,0 +1,354 @@ +package deprovision_environment_test + +import ( + "bytes" + "fmt" + "net/url" + "testing" + + "github.com/AlecAivazis/survey/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + deprovisionEnvironment "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/deprovision-environment" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/cli/test/testutil" + octopusApiClient "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/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var serverUrl, _ = url.Parse("http://server") + +const ( + placeholderApiKey = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + testSpaceID = "Spaces-1" +) + +var rootResource = testutil.NewRootResource() + +// Helper function to create test ephemeral environments +func createTestEnvironment(id, name string) *ephemeralenvironments.EphemeralEnvironment { + env := ephemeralenvironments.NewEphemeralEnvironment(name, "Environments-1", testSpaceID) + env.ID = id + return env +} + +// Helper function to create a deprovision response +func createDeprovisionResponse(runbookRunID, taskID string) *ephemeralenvironments.DeprovisionEphemeralEnvironmentResponse { + return &ephemeralenvironments.DeprovisionEphemeralEnvironmentResponse{ + DeprovisioningRuns: []ephemeralenvironments.DeprovisioningRunbookRun{ + { + RunbookRunID: runbookRunID, + TaskId: taskID, + }, + }, + } +} + +// Helper function to set up API mock with initial requests +func setupMockAPI(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/"+testSpaceID).RespondWith(rootResource) +} + +// Helper function to mock the GetByPartialName API response +func mockGetEnvByPartialName(t *testing.T, api *testutil.MockHttpServer, searchName string, envs []*ephemeralenvironments.EphemeralEnvironment) { + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/environments/v2?skip=0&take=2147483647&partialName=%s&type=Ephemeral", testSpaceID, searchName)). + RespondWith(&resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: envs, + PagedResults: resources.PagedResults{TotalResults: len(envs)}, + }) +} + +// Helper function to mock the Deprovision API response +func mockDeprovision(t *testing.T, api *testutil.MockHttpServer, environmentID string, response *ephemeralenvironments.DeprovisionEphemeralEnvironmentResponse) { + api.ExpectRequest(t, "POST", fmt.Sprintf("/api/%s/environments/ephemeral/%s/deprovision", testSpaceID, environmentID)). + RespondWith(response) +} + +// Helper function to create test dependencies +func createTestDependencies(t *testing.T, api *testutil.MockHttpServer, envName string) (*cmd.Dependencies, *deprovisionEnvironment.DeprovisionEnvironmentOptions) { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + assert.NoError(t, err) + + space := spaces.NewSpace("Test Space") + space.ID = testSpaceID + + deps := &cmd.Dependencies{ + Client: octopus, + Space: space, + NoPrompt: true, + } + + flags := &deprovisionEnvironment.DeprovisionEnvironmentFlags{ + Name: flag.New[string](deprovisionEnvironment.FlagName, false), + } + flags.Name.Value = envName + + command := &cobra.Command{} + command.SetOut(new(bytes.Buffer)) + + opts := &deprovisionEnvironment.DeprovisionEnvironmentOptions{ + DeprovisionEnvironmentFlags: flags, + Dependencies: deps, + Command: command, + } + + return deps, opts +} + +// Integration tests using MockHttpServer +func TestDeprovisionEnvironment_Success(t *testing.T) { + const envName = "test-env" + const envID = "Environments-1" + const runbookRunID = "RunbookRuns-1" + const taskID = "ServerTasks-1" + + env := createTestEnvironment(envID, envName) + deprovisionResponse := createDeprovisionResponse(runbookRunID, taskID) + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + _, opts := createTestDependencies(t, api, envName) + + buf := new(bytes.Buffer) + opts.Command.SetOut(buf) + + err := deprovisionEnvironment.DeprovisionEnvironmentRun(opts, opts.Command) + assert.NoError(t, err) + + // Verify that the output contains expected messages + output := buf.String() + assert.Contains(t, output, runbookRunID) + assert.Contains(t, output, taskID) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvByPartialName(t, api, envName, []*ephemeralenvironments.EphemeralEnvironment{env}) + mockDeprovision(t, api, envID, deprovisionResponse) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestDeprovisionEnvironment_EnvironmentNotFound(t *testing.T) { + const envName = "nonexistent-env" + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + _, opts := createTestDependencies(t, api, envName) + + err := deprovisionEnvironment.DeprovisionEnvironmentRun(opts, opts.Command) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("no ephemeral environment found with the name '%s'", envName)) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvByPartialName(t, api, envName, []*ephemeralenvironments.EphemeralEnvironment{}) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestDeprovisionEnvironment_MultipleEnvironmentsWithoutExactMatch(t *testing.T) { + const searchName = "test" + env1 := createTestEnvironment("Environments-1", "test-env-1") + env2 := createTestEnvironment("Environments-2", "test-env-2") + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + _, opts := createTestDependencies(t, api, searchName) + + err := deprovisionEnvironment.DeprovisionEnvironmentRun(opts, opts.Command) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("could not find an exact match of an ephemeral environment with the name '%s'", searchName)) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvByPartialName(t, api, searchName, []*ephemeralenvironments.EphemeralEnvironment{env1, env2}) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestDeprovisionEnvironment_MultipleEnvironmentsWithExactMatch(t *testing.T) { + const envName = "test-env" + const envID = "Environments-1" + const runbookRunID = "RunbookRuns-1" + const taskID = "ServerTasks-1" + + env1 := createTestEnvironment(envID, envName) + env2 := createTestEnvironment("Environments-2", "test-env-2") + deprovisionResponse := createDeprovisionResponse(runbookRunID, taskID) + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + _, opts := createTestDependencies(t, api, envName) + + buf := new(bytes.Buffer) + opts.Command.SetOut(buf) + + err := deprovisionEnvironment.DeprovisionEnvironmentRun(opts, opts.Command) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, runbookRunID) + assert.Contains(t, output, taskID) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvByPartialName(t, api, envName, []*ephemeralenvironments.EphemeralEnvironment{env1, env2}) + mockDeprovision(t, api, envID, deprovisionResponse) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestDeprovisionEnvironment_CaseInsensitiveMatch(t *testing.T) { + const searchName = "test-env" + const actualName = "Test-Env" + const envID = "Environments-1" + const runbookRunID = "RunbookRuns-1" + const taskID = "ServerTasks-1" + + env := createTestEnvironment(envID, actualName) + deprovisionResponse := createDeprovisionResponse(runbookRunID, taskID) + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + _, opts := createTestDependencies(t, api, searchName) + + buf := new(bytes.Buffer) + opts.Command.SetOut(buf) + + err := deprovisionEnvironment.DeprovisionEnvironmentRun(opts, opts.Command) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, runbookRunID) + assert.Contains(t, output, taskID) + + return nil + }) + + setupMockAPI(t, api) + mockGetEnvByPartialName(t, api, searchName, []*ephemeralenvironments.EphemeralEnvironment{env}) + mockDeprovision(t, api, envID, deprovisionResponse) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestDeprovisionEnvironment_EmptyName(t *testing.T) { + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + _, opts := createTestDependencies(t, api, "") + + err := deprovisionEnvironment.DeprovisionEnvironmentRun(opts, opts.Command) + assert.Error(t, err) + assert.Equal(t, "environment name is required", err.Error()) + + return nil + }) + + setupMockAPI(t, api) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestDeprovisionEnvironment_PromptForName(t *testing.T) { + const envName = "test-env" + const envID = "Environments-1" + const runbookRunID = "RunbookRuns-1" + const taskID = "ServerTasks-1" + + env1 := createTestEnvironment(envID, envName) + env2 := createTestEnvironment("Environments-2", "another-env") + deprovisionResponse := createDeprovisionResponse(runbookRunID, taskID) + + api, qa := testutil.NewMockServerAndAsker() + defer testutil.Close(api, qa) + + errReceiver := testutil.GoBegin(func() error { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + assert.NoError(t, err) + + space := spaces.NewSpace("Test Space") + space.ID = testSpaceID + + // Create dependencies with NoPrompt: false and empty name to trigger prompting + deps := &cmd.Dependencies{ + Client: octopus, + Space: space, + NoPrompt: false, + Ask: qa.AsAsker(), + } + + flags := &deprovisionEnvironment.DeprovisionEnvironmentFlags{ + Name: flag.New[string](deprovisionEnvironment.FlagName, false), + } + // Leave Name.Value empty to trigger prompting + + opts := &deprovisionEnvironment.DeprovisionEnvironmentOptions{ + DeprovisionEnvironmentFlags: flags, + Dependencies: deps, + } + command := &cobra.Command{} + command.SetOut(new(bytes.Buffer)) + + // Override GetAllEphemeralEnvironments to return our test environments + opts.GetAllEphemeralEnvironments = func() ([]*ephemeralenvironments.EphemeralEnvironment, error) { + return []*ephemeralenvironments.EphemeralEnvironment{env1, env2}, nil + } + + buf := new(bytes.Buffer) + command.SetOut(buf) + + err = deprovisionEnvironment.DeprovisionEnvironmentRun(opts, command) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, runbookRunID) + assert.Contains(t, output, taskID) + + return nil + }) + + setupMockAPI(t, api) + + // Expect the selector to ask which environment to deprovision + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Please select the name of the environment you wish to deprovision", + Options: []string{envName, "another-env"}, + }).AnswerWith(envName) + + // After selection, it will look up the environment by name + mockGetEnvByPartialName(t, api, envName, []*ephemeralenvironments.EphemeralEnvironment{env1}) + mockDeprovision(t, api, envID, deprovisionResponse) + + err := <-errReceiver + assert.NoError(t, err) +} diff --git a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go index 9e7214fa..d3c193e3 100644 --- a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go +++ b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go @@ -3,6 +3,7 @@ package ephemeralenvironment import ( "github.com/MakeNowJust/heredoc/v2" cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/create" + cmdDeprovisionEnvironment "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/deprovision-environment" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" "github.com/OctopusDeploy/cli/pkg/factory" @@ -23,5 +24,7 @@ func NewCmdEphemeralEnvironment(f factory.Factory) *cobra.Command { } cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + cmd.AddCommand(cmdDeprovisionEnvironment.NewCmdDeprovisionEnvironment(f)) + return cmd } diff --git a/pkg/cmd/ephemeralenvironment/util/util.go b/pkg/cmd/ephemeralenvironment/util/util.go new file mode 100644 index 00000000..c65d5372 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/util/util.go @@ -0,0 +1,37 @@ +package util + +import ( + "fmt" + "strings" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" +) + +func GetByName(client *client.Client, name string, spaceID string) (*ephemeralenvironments.EphemeralEnvironment, error) { + environments, err := ephemeralenvironments.GetByPartialName(client, spaceID, name) + + if err != nil { + return nil, err + } + + if environments.TotalResults == 0 { + return nil, fmt.Errorf("no ephemeral environment found with the name '%s'", name) + } else { + var exactMatch *ephemeralenvironments.EphemeralEnvironment + var toLowerName = strings.ToLower(name) + + for _, environment := range environments.Items { + if strings.ToLower(environment.Name) == toLowerName { + exactMatch = environment + break + } + } + + if exactMatch != nil { + return exactMatch, nil + } + + return nil, fmt.Errorf("could not find an exact match of an ephemeral environment with the name '%s'. Please specify a more specific name", name) + } +} diff --git a/pkg/cmd/ephemeralenvironment/util/util_test.go b/pkg/cmd/ephemeralenvironment/util/util_test.go new file mode 100644 index 00000000..fe37da9f --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/util/util_test.go @@ -0,0 +1,210 @@ +package util_test + +import ( + "fmt" + "net/url" + "testing" + + "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/util" + "github.com/OctopusDeploy/cli/test/testutil" + octopusApiClient "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/resources" + "github.com/stretchr/testify/assert" +) + +var serverUrl, _ = url.Parse("http://server") + +const ( + placeholderApiKey = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + testSpaceID = "Spaces-1" +) + +var rootResource = testutil.NewRootResource() + +// Helper function to create test ephemeral environments +func createTestEnvironment(id, name string) *ephemeralenvironments.EphemeralEnvironment { + env := ephemeralenvironments.NewEphemeralEnvironment(name, "Environments-1", testSpaceID) + env.ID = id + return env +} + +// Helper function to set up API mock with initial requests +func setupMockAPI(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/"+testSpaceID).RespondWith(rootResource) +} + +// Helper function to mock the GetByPartialName API response +func mockGetByPartialName(t *testing.T, api *testutil.MockHttpServer, searchName string, envs []*ephemeralenvironments.EphemeralEnvironment) { + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/environments/v2?skip=0&take=2147483647&partialName=%s&type=Ephemeral", testSpaceID, searchName)). + RespondWith(&resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: envs, + PagedResults: resources.PagedResults{TotalResults: len(envs)}, + }) +} + +// Integration tests using MockHttpServer +func TestGetByName_SingleMatch(t *testing.T) { + const envName = "test-env" + env1 := createTestEnvironment("Environments-1", envName) + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + if err != nil { + return err + } + + result, err := util.GetByName(octopus, envName, testSpaceID) + assert.NoError(t, err) + assert.NotNil(t, result) + if result != nil { + assert.Equal(t, env1.ID, result.ID) + assert.Equal(t, env1.Name, result.Name) + } + return nil + }) + + setupMockAPI(t, api) + mockGetByPartialName(t, api, envName, []*ephemeralenvironments.EphemeralEnvironment{env1}) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestGetByName_MultipleMatchesWithExactMatch(t *testing.T) { + const searchName = "test-env" + env1 := createTestEnvironment("Environments-1", "test-env") + env2 := createTestEnvironment("Environments-2", "test-env-2") + env3 := createTestEnvironment("Environments-3", "test-environment") + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + if err != nil { + return err + } + + result, err := util.GetByName(octopus, searchName, testSpaceID) + assert.NoError(t, err) + assert.NotNil(t, result) + if result != nil { + assert.Equal(t, env1.ID, result.ID) + assert.Equal(t, env1.Name, result.Name) + } + return nil + }) + + setupMockAPI(t, api) + mockGetByPartialName(t, api, searchName, []*ephemeralenvironments.EphemeralEnvironment{env1, env2, env3}) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestGetByName_MultipleMatchesCaseInsensitive(t *testing.T) { + const searchName = "test-env" + // The actual environment name has different case + env1 := createTestEnvironment("Environments-1", "Test-Env") + env2 := createTestEnvironment("Environments-2", "test-env-2") + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + if err != nil { + return err + } + + result, err := util.GetByName(octopus, searchName, testSpaceID) + assert.NoError(t, err) + assert.NotNil(t, result) + if result != nil { + assert.Equal(t, env1.ID, result.ID) + assert.Equal(t, "Test-Env", result.Name) // Should return the actual name with its original casing + } + return nil + }) + + setupMockAPI(t, api) + mockGetByPartialName(t, api, searchName, []*ephemeralenvironments.EphemeralEnvironment{env1, env2}) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestGetByName_MultipleMatchesWithoutExactMatch(t *testing.T) { + const searchName = "test" + env1 := createTestEnvironment("Environments-1", "test-env") + env2 := createTestEnvironment("Environments-2", "test-env-2") + env3 := createTestEnvironment("Environments-3", "test-environment") + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + if err != nil { + return err + } + + result, err := util.GetByName(octopus, searchName, testSpaceID) + assert.Error(t, err) + assert.Nil(t, result) + if err != nil { + expectedError := fmt.Sprintf("could not find an exact match of an ephemeral environment with the name '%s'. Please specify a more specific name", searchName) + assert.Equal(t, expectedError, err.Error()) + } + return nil + }) + + setupMockAPI(t, api) + mockGetByPartialName(t, api, searchName, []*ephemeralenvironments.EphemeralEnvironment{env1, env2, env3}) + + err := <-errReceiver + assert.NoError(t, err) +} + +func TestGetByName_NoResults(t *testing.T) { + const searchName = "nonexistent" + + api := testutil.NewMockHttpServer() + defer api.Close() + + errReceiver := testutil.GoBegin(func() error { + octopus, err := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, testSpaceID) + if err != nil { + return err + } + + result, err := util.GetByName(octopus, searchName, testSpaceID) + + assert.Error(t, err) + assert.Nil(t, result) + if err != nil { + expectedError := fmt.Sprintf("no ephemeral environment found with the name '%s'", searchName) + assert.Equal(t, expectedError, err.Error()) + } + return nil + }) + + // Mock the initial API requests + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/"+testSpaceID).RespondWith(rootResource) + + // Mock the GetByPartialName API call returning no results + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/%s/environments/v2?skip=0&take=2147483647&partialName=%s&type=Ephemeral", testSpaceID, searchName)). + RespondWith(&resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: []*ephemeralenvironments.EphemeralEnvironment{}, + PagedResults: resources.PagedResults{TotalResults: 0}, + }) + + err := <-errReceiver + assert.NoError(t, err) +}