diff --git a/pkg/cmd/environment/delete/delete.go b/pkg/cmd/environment/delete/delete.go index e3ffa8fc..99bf61df 100644 --- a/pkg/cmd/environment/delete/delete.go +++ b/pkg/cmd/environment/delete/delete.go @@ -5,6 +5,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/cmd/environment/helper" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/question" @@ -37,22 +38,10 @@ func NewCmdDelete(f factory.Factory) *cobra.Command { return err } - // SDK doesn't have accounts.GetByIDOrName so we emulate it here - foundEnvironments, err := client.Environments.Get(environments.EnvironmentsQuery{ - // TODO we can't lookup by ID here because the server will AND it with the ItemName and produce no results - PartialName: itemIDOrName, - }) + itemToDelete, err := helper.GetByIDOrName(client.Environments, itemIDOrName) if err != nil { return err } - // need exact match - var itemToDelete *environments.Environment - for _, item := range foundEnvironments.Items { - if item.Name == itemIDOrName { - itemToDelete = item - break - } - } if itemToDelete == nil { return fmt.Errorf("cannot find an environment with name or ID of '%s'", itemIDOrName) } diff --git a/pkg/cmd/environment/environment.go b/pkg/cmd/environment/environment.go index 20d72dd2..c1e05d38 100644 --- a/pkg/cmd/environment/environment.go +++ b/pkg/cmd/environment/environment.go @@ -6,6 +6,7 @@ import ( cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/environment/delete" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/environment/list" cmdTag "github.com/OctopusDeploy/cli/pkg/cmd/environment/tag" + cmdView "github.com/OctopusDeploy/cli/pkg/cmd/environment/view" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" "github.com/OctopusDeploy/cli/pkg/factory" @@ -30,5 +31,7 @@ func NewCmdEnvironment(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdDelete.NewCmdDelete(f)) cmd.AddCommand(cmdCreate.NewCmdCreate(f)) cmd.AddCommand(cmdTag.NewCmdTag(f)) + cmd.AddCommand(cmdView.NewCmdView(f)) + return cmd } diff --git a/pkg/cmd/environment/helper/helper.go b/pkg/cmd/environment/helper/helper.go new file mode 100644 index 00000000..e193f275 --- /dev/null +++ b/pkg/cmd/environment/helper/helper.go @@ -0,0 +1,38 @@ +package helper + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" +) + +// GetByIDOrName returns the environment matching the given ID or exact name. +// The SDK has no environments.GetByIDOrName, so we emulate it here. +// Returns (nil, nil) when nothing matches; callers must handle that. +func GetByIDOrName(service *environments.EnvironmentService, idOrName string) (*environments.Environment, error) { + // A 404 here just means the input wasn't an ID; anything else is a real error. + environment, err := service.GetByID(idOrName) + if err != nil { + apiError, ok := err.(*core.APIError) + if !ok || apiError.StatusCode != 404 { + return nil, err + } + } else if environment != nil { + return environment, nil + } + + // The server only offers a partial name match, so we filter for the exact name. + foundEnvironments, err := service.Get(environments.EnvironmentsQuery{ + PartialName: idOrName, + }) + if err != nil { + return nil, err + } + + for _, item := range foundEnvironments.Items { + if item.Name == idOrName { + return item, nil + } + } + + return nil, nil +} diff --git a/pkg/cmd/environment/view/view.go b/pkg/cmd/environment/view/view.go new file mode 100644 index 00000000..005d01b1 --- /dev/null +++ b/pkg/cmd/environment/view/view.go @@ -0,0 +1,164 @@ +package view + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/cmd/environment/helper" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/usage" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +const ( + FlagWeb = "web" +) + +type ViewFlags struct { + Web *flag.Flag[bool] +} + +func NewViewFlags() *ViewFlags { + return &ViewFlags{ + Web: flag.New[bool](FlagWeb, false), + } +} + +type ViewOptions struct { + Client *client.Client + Host string + 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 an environment", + Long: "View an environment in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s environment view 'Production' + $ %[1]s environment view Environments-102 + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + opts := &ViewOptions{ + client, + f.GetCurrentHost(), + args[0], + viewFlags, + cmd, + } + + return viewRun(opts) + }, + } + + flags := cmd.Flags() + flags.BoolVarP(&viewFlags.Web.Value, viewFlags.Web.Name, "w", false, "Open in web browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + environment, err := helper.GetByIDOrName(opts.Client.Environments, opts.idOrName) + if err != nil { + return err + } + if environment == nil { + return fmt.Errorf("cannot find an environment with name or ID of '%s'", opts.idOrName) + } + + return output.PrintResource(environment, opts.Command, output.Mappers[*environments.Environment]{ + Json: func(env *environments.Environment) any { + return EnvironmentAsJson{ + Id: env.GetID(), + Slug: env.Slug, + Name: env.Name, + Description: env.Description, + UseGuidedFailure: env.UseGuidedFailure, + AllowDynamicInfrastructure: env.AllowDynamicInfrastructure, + WebUrl: generateWebUrl(opts.Host, env), + } + }, + Table: output.TableDefinition[*environments.Environment]{ + Header: []string{"NAME", "SLUG", "DESCRIPTION", "GUIDED FAILURE", "DYNAMIC INFRASTRUCTURE", "WEB URL"}, + Row: func(env *environments.Environment) []string { + description := env.Description + if description == "" { + description = constants.NoDescription + } + + return []string{ + output.Bold(env.Name), + env.Slug, + description, + getBoolToString(env.UseGuidedFailure, "Enabled", "Disabled"), + getBoolToString(env.AllowDynamicInfrastructure, "Allowed", "Disallowed"), + output.Blue(generateWebUrl(opts.Host, env)), + } + }, + }, + Basic: func(env *environments.Environment) string { + var result strings.Builder + + // header + result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(env.Name), output.Dimf("(%s)", env.GetID()))) + + // metadata + if len(env.Description) == 0 { + result.WriteString(fmt.Sprintf("%s\n", output.Dim(constants.NoDescription))) + } else { + result.WriteString(fmt.Sprintf("%s\n", output.Dim(env.Description))) + } + + url := generateWebUrl(opts.Host, env) + result.WriteString(fmt.Sprintf("View this environment in Octopus Deploy: %s\n", output.Blue(url))) + + if opts.flags.Web.Value { + browser.OpenURL(url) + } + + return result.String() + }, + }) +} + +type EnvironmentAsJson struct { + Id string `json:"Id"` + Slug string `json:"Slug"` + Name string `json:"Name"` + Description string `json:"Description"` + UseGuidedFailure bool `json:"UseGuidedFailure"` + AllowDynamicInfrastructure bool `json:"AllowDynamicInfrastructure"` + WebUrl string `json:"WebUrl"` +} + +func generateWebUrl(host string, env *environments.Environment) string { + return util.GenerateWebURL(host, env.SpaceID, fmt.Sprintf("infrastructure/environments/%s", env.GetID())) +} + +func getBoolToString(value bool, trueString string, falseString string) string { + if value { + return trueString + } else { + return falseString + } +} diff --git a/pkg/cmd/environment/view/view_test.go b/pkg/cmd/environment/view/view_test.go new file mode 100644 index 00000000..d3eecaac --- /dev/null +++ b/pkg/cmd/environment/view/view_test.go @@ -0,0 +1,150 @@ +package view_test + +import ( + "bytes" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestEnvironmentView(t *testing.T) { + const spaceID = "Spaces-1" + const envID = "Environments-3" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + newDevEnvironment := func() *environments.Environment { + env := fixtures.NewEnvironment(spaceID, envID, "Development") + env.Slug = "development" + env.UseGuidedFailure = false + // opposite of UseGuidedFailure so the two columns can't be confused + env.AllowDynamicInfrastructure = true + return env + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"view by name renders each flag from its own field", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + env := newDevEnvironment() + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"environment", "view", "Development", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + // not an ID, so the ID lookup 404s and the name lookup follows + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/Development"). + RespondWithStatus(http.StatusNotFound, "404 Not Found", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=Development"). + RespondWith(resources.Resources[*environments.Environment]{Items: []*environments.Environment{env}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME SLUG DESCRIPTION GUIDED FAILURE DYNAMIC INFRASTRUCTURE WEB URL + Development development No description provided Disabled Allowed http://server/app#/Spaces-1/infrastructure/environments/Environments-3 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"view by ID resolves via the ID lookup", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + env := newDevEnvironment() + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"environment", "view", envID, "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + // resolves on the first hop; no name lookup should follow + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/Environments-3").RespondWith(env) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Development (Environments-3) + No description provided + View this environment in Octopus Deploy: http://server/app#/Spaces-1/infrastructure/environments/Environments-3 + + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"view returns an error when nothing matches", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"environment", "view", "NoSuchEnvironment", "--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/environments/NoSuchEnvironment"). + RespondWithStatus(http.StatusNotFound, "404 Not Found", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=NoSuchEnvironment"). + RespondWith(resources.Resources[*environments.Environment]{Items: []*environments.Environment{}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "cannot find an environment with name or ID of 'NoSuchEnvironment'") + + assert.Equal(t, "", stdOut.String()) + }}, + + {"view surfaces a non-404 failure rather than reporting not-found", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"environment", "view", "Development", "--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/environments/Development"). + RespondWithStatus(http.StatusForbidden, "403 Forbidden", core.APIError{ErrorMessage: "You do not have permission"}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "You do not have permission") + + assert.Equal(t, "", stdOut.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, rootCmd, stdout, stderr) + }) + } +}