Skip to content
Draft
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
12 changes: 11 additions & 1 deletion pkg/cmd/release/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,16 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error
return err
}
options.ProjectName = project.GetName()

if options.ReleaseVersion != "" {
// resolve the release up front; the executions API reports an unknown version as an
// unhelpful null reference error, and having the ID saves looking it up again later
release, err := selectors.FindRelease(octopus, f.GetCurrentSpace().ID, project, options.ReleaseVersion)
if err != nil {
return err
}
options.ReleaseID = release.ID
}
}

}
Expand Down Expand Up @@ -426,7 +436,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques
return err
}
} else {
selectedRelease, err = releases.GetReleaseInProject(octopus, space.ID, selectedProject.ID, options.ReleaseVersion)
selectedRelease, err = selectors.FindRelease(octopus, space.ID, selectedProject, options.ReleaseVersion)
if err != nil {
return err
}
Expand Down
76 changes: 52 additions & 24 deletions pkg/cmd/release/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.9").RespondWith(release10)

_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "environment(s) must be specified")
Expand All @@ -1602,6 +1603,45 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
assert.Equal(t, "", stdErr.String())
}},

{"release deploy reports a release version that doesn't exist", 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{"release", "deploy", "--project", fireProject.Name, "--version", "9.9", "--environment", "dev"})
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/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/9.9").
RespondWithStatus(404, "404 Not Found", &core.APIError{ErrorMessage: "The resource you requested was not found."})

_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "cannot find a release with version '9.9' in project 'Fire Project'")

assert.Equal(t, "", stdOut.String())
assert.Equal(t, "", stdErr.String())
}},

{"release deploy explains that 'latest' is not a supported release version", 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{"release", "deploy", "--project", fireProject.Name, "--version", "latest", "--environment", "dev"})
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/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/latest").RespondWithStatus(404, "NotFound", nil)

_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "cannot find a release with version 'latest' in project 'Fire Project'; 'latest' is not a supported alias, specify an exact version. Run 'octopus release list --project \"Fire Project\"' to see the available versions")

assert.Equal(t, "", stdOut.String())
assert.Equal(t, "", stdErr.String())
}},

{"release deploy specifying project, version, env only (bare minimum) assuming untenanted", 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()
Expand All @@ -1612,6 +1652,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

// Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted
req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1")
Expand All @@ -1634,12 +1675,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
},
})

// now it's going to try and look up the project/version to generate the web URL
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{
Items: []*projects.Project{fireProject},
})
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)
// no lookup to generate the web URL; the release was already resolved before deploying

_, err = testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
Expand All @@ -1662,6 +1698,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/2.1").RespondWith(release10)

// Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted
req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1")
Expand All @@ -1684,12 +1721,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
},
})

// now it's going to try and look up the project/version to generate the web URL
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{
Items: []*projects.Project{fireProject},
})
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/2.1").RespondWith(release10)
// no lookup to generate the web URL; the release was already resolved before deploying

_, err = testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
Expand All @@ -1712,6 +1744,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

// Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted
api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1").RespondWith(&deployments.CreateDeploymentResponseV1{
Expand Down Expand Up @@ -1742,6 +1775,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

// Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted
serverTasks := []*deployments.DeploymentServerTask{
Expand Down Expand Up @@ -1773,6 +1807,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1")
requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body)
Expand All @@ -1794,12 +1829,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
},
})

// now it's going to try and look up the project/version to generate the web URL
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{
Items: []*projects.Project{fireProject},
})
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)
// no lookup to generate the web URL; the release was already resolved before deploying

_, err = testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
Expand All @@ -1822,6 +1852,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1")
requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body)
Expand All @@ -1843,12 +1874,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
},
})

// now it's going to try and look up the project/version to generate the web URL
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{
Items: []*projects.Project{fireProject},
})
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)
// no lookup to generate the web URL; the release was already resolved before deploying

_, err = testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
Expand Down Expand Up @@ -1888,6 +1914,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

// Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted
req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1")
Expand Down Expand Up @@ -1962,6 +1989,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) {
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10)

req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1")
requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body)
Expand Down
11 changes: 1 addition & 10 deletions pkg/cmd/release/progression/shared/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,5 @@ func SelectRelease(octopus *client.Client, project *projects.Project, ask questi
}

func FindRelease(octopus *client.Client, project *projects.Project, version string) (*releases.Release, error) {
existingRelease, err := releases.GetReleaseInProject(octopus, octopus.GetSpaceID(), project.GetID(), version)
if err != nil {
return nil, err
}

if existingRelease == nil {
return nil, fmt.Errorf("unable to locate a release with version/release number '%s'", version)
}

return existingRelease, nil
return selectors.FindRelease(octopus, octopus.GetSpaceID(), project, version)
}
45 changes: 45 additions & 0 deletions pkg/question/selectors/releases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package selectors

import (
"errors"
"fmt"
"net/http"
"strings"

"github.com/OctopusDeploy/cli/pkg/constants"
octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases"
)

// latestReleaseAlias is the value the old `octo` CLI accepted to mean "the newest release".
// This CLI has no equivalent, so it is called out explicitly when the lookup fails.
const latestReleaseAlias = "latest"

// FindRelease looks up a release by version within a project. A version that doesn't exist is
// reported here, because the executions API answers one with a null reference error instead.
func FindRelease(octopus *octopusApiClient.Client, spaceID string, project *projects.Project, releaseVersion string) (*releases.Release, error) {
release, err := releases.GetReleaseInProject(octopus, spaceID, project.GetID(), releaseVersion)
if err != nil {
var apiError *core.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound {
return nil, releaseNotFoundError(project, releaseVersion)
}
return nil, err
}
// a 404 with an empty body doesn't reach the error path above; it decodes as an empty release
if release == nil || release.GetID() == "" {
return nil, releaseNotFoundError(project, releaseVersion)
}

return release, nil
}

func releaseNotFoundError(project *projects.Project, releaseVersion string) error {
if strings.EqualFold(releaseVersion, latestReleaseAlias) {
return fmt.Errorf("cannot find a release with version '%s' in project '%s'; '%s' is not a supported alias, specify an exact version. Run '%s release list --project \"%s\"' to see the available versions",
releaseVersion, project.GetName(), releaseVersion, constants.ExecutableName, project.GetName())
}
return fmt.Errorf("cannot find a release with version '%s' in project '%s'", releaseVersion, project.GetName())
}