diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 7cd276f1f8a..5abe7e1adcd 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,6 +20,7 @@ on: - "doc/**" - ".gitpod.yml" - "README.md" + workflow_dispatch: jobs: golangci: diff --git a/actor/v7action/cloud_controller_client.go b/actor/v7action/cloud_controller_client.go index e8810085e4a..e8793ed0ea4 100644 --- a/actor/v7action/cloud_controller_client.go +++ b/actor/v7action/cloud_controller_client.go @@ -84,6 +84,7 @@ type CloudControllerClient interface { GetDroplet(guid string) (resources.Droplet, ccv3.Warnings, error) GetDroplets(query ...ccv3.Query) ([]resources.Droplet, ccv3.Warnings, error) GetEnvironmentVariableGroup(group constant.EnvironmentVariableGroupName) (resources.EnvironmentVariables, ccv3.Warnings, error) + GetEnvironmentVariablesByURL(url string) (resources.EnvironmentVariables, ccv3.Warnings, error) GetEvents(query ...ccv3.Query) ([]ccv3.Event, ccv3.Warnings, error) GetFeatureFlag(featureFlagName string) (resources.FeatureFlag, ccv3.Warnings, error) GetFeatureFlags() ([]resources.FeatureFlag, ccv3.Warnings, error) diff --git a/actor/v7action/revisions.go b/actor/v7action/revisions.go index c2109c8f95a..f159ab64364 100644 --- a/actor/v7action/revisions.go +++ b/actor/v7action/revisions.go @@ -76,6 +76,20 @@ func (actor Actor) GetRevisionByApplicationAndVersion(appGUID string, revisionVe return revisions[0], Warnings(warnings), nil } +func (actor Actor) GetEnvironmentVariableGroupByRevision(revision resources.Revision) (EnvironmentVariableGroup, bool, Warnings, error) { + envVarApiLink, isPresent := revision.Links["environment_variables"] + if !isPresent { + return EnvironmentVariableGroup{}, isPresent, Warnings{"Unable to retrieve environment variables for revision."}, nil + } + + environmentVariables, warnings, err := actor.CloudControllerClient.GetEnvironmentVariablesByURL(envVarApiLink.HREF) + if err != nil { + return EnvironmentVariableGroup{}, false, Warnings(warnings), err + } + + return EnvironmentVariableGroup(environmentVariables), true, Warnings(warnings), nil +} + func (actor Actor) setRevisionsDeployableByDropletStateForApp(appGUID string, revisions []resources.Revision) ([]resources.Revision, Warnings, error) { droplets, warnings, err := actor.CloudControllerClient.GetDroplets( ccv3.Query{Key: ccv3.AppGUIDFilter, Values: []string{appGUID}}, diff --git a/actor/v7action/revisions_test.go b/actor/v7action/revisions_test.go index 61e8b96eaba..2a8c74368d4 100644 --- a/actor/v7action/revisions_test.go +++ b/actor/v7action/revisions_test.go @@ -5,11 +5,13 @@ import ( "strconv" "code.cloudfoundry.org/cli/actor/actionerror" + "code.cloudfoundry.org/cli/actor/v7action" . "code.cloudfoundry.org/cli/actor/v7action" "code.cloudfoundry.org/cli/actor/v7action/v7actionfakes" "code.cloudfoundry.org/cli/api/cloudcontroller/ccv3" "code.cloudfoundry.org/cli/api/cloudcontroller/ccv3/constant" "code.cloudfoundry.org/cli/resources" + "code.cloudfoundry.org/cli/types" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -397,4 +399,81 @@ var _ = Describe("Revisions Actions", func() { }) }) }) + + Describe("GetEnvironmentVariableGroupByRevision", func() { + var ( + actor *Actor + environmentVariablesGroup v7action.EnvironmentVariableGroup + executeErr error + fakeCloudControllerClient *v7actionfakes.FakeCloudControllerClient + fakeConfig *v7actionfakes.FakeConfig + isPresent bool + revision resources.Revision + warnings Warnings + ) + + BeforeEach(func() { + fakeCloudControllerClient = new(v7actionfakes.FakeCloudControllerClient) + fakeConfig = new(v7actionfakes.FakeConfig) + actor = NewActor(fakeCloudControllerClient, fakeConfig, nil, nil, nil, nil) + revision = resources.Revision{ + Links: resources.APILinks{ + "environment_variables": resources.APILink{ + HREF: "url", + }, + }, + } + fakeConfig.APIVersionReturns("3.86.0") + }) + + JustBeforeEach(func() { + environmentVariablesGroup, isPresent, warnings, executeErr = actor.GetEnvironmentVariableGroupByRevision(revision) + }) + + When("the revision does not provide HREF", func() { + BeforeEach(func() { + revision = resources.Revision{} + }) + + It("returns as not present", func() { + Expect(executeErr).To(Not(HaveOccurred())) + Expect(warnings).To(ConsistOf("Unable to retrieve environment variables for revision.")) + Expect(isPresent).To(Equal(false)) + }) + }) + + When("finding the environment variables fails", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetEnvironmentVariablesByURLReturns( + nil, + ccv3.Warnings{"get-env-vars-warning-1"}, + errors.New("get-env-vars-error-1"), + ) + }) + + It("returns an error and warnings", func() { + Expect(executeErr).To(MatchError("get-env-vars-error-1")) + Expect(warnings).To(ConsistOf("get-env-vars-warning-1")) + }) + }) + + When("finding the environment variables succeeds", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetEnvironmentVariablesByURLReturns( + resources.EnvironmentVariables{"foo": *types.NewFilteredString("bar")}, + ccv3.Warnings{"get-env-vars-warning-1"}, + nil, + ) + }) + + It("returns the environment variables and warnings", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(fakeCloudControllerClient.GetEnvironmentVariablesByURLCallCount()).To(Equal(1)) + Expect(fakeCloudControllerClient.GetEnvironmentVariablesByURLArgsForCall(0)).To(Equal("url")) + Expect(warnings).To(ConsistOf("get-env-vars-warning-1")) + Expect(len(environmentVariablesGroup)).To(Equal(1)) + Expect(environmentVariablesGroup["foo"].Value).To(Equal("bar")) + }) + }) + }) }) diff --git a/actor/v7action/v7actionfakes/fake_cloud_controller_client.go b/actor/v7action/v7actionfakes/fake_cloud_controller_client.go index 942728edbda..f9035d65ab1 100644 --- a/actor/v7action/v7actionfakes/fake_cloud_controller_client.go +++ b/actor/v7action/v7actionfakes/fake_cloud_controller_client.go @@ -1101,6 +1101,21 @@ type FakeCloudControllerClient struct { result2 ccv3.Warnings result3 error } + GetEnvironmentVariablesByURLStub func(string) (resources.EnvironmentVariables, ccv3.Warnings, error) + getEnvironmentVariablesByURLMutex sync.RWMutex + getEnvironmentVariablesByURLArgsForCall []struct { + arg1 string + } + getEnvironmentVariablesByURLReturns struct { + result1 resources.EnvironmentVariables + result2 ccv3.Warnings + result3 error + } + getEnvironmentVariablesByURLReturnsOnCall map[int]struct { + result1 resources.EnvironmentVariables + result2 ccv3.Warnings + result3 error + } GetEventsStub func(...ccv3.Query) ([]ccv3.Event, ccv3.Warnings, error) getEventsMutex sync.RWMutex getEventsArgsForCall []struct { @@ -7597,6 +7612,73 @@ func (fake *FakeCloudControllerClient) GetEnvironmentVariableGroupReturnsOnCall( }{result1, result2, result3} } +func (fake *FakeCloudControllerClient) GetEnvironmentVariablesByURL(arg1 string) (resources.EnvironmentVariables, ccv3.Warnings, error) { + fake.getEnvironmentVariablesByURLMutex.Lock() + ret, specificReturn := fake.getEnvironmentVariablesByURLReturnsOnCall[len(fake.getEnvironmentVariablesByURLArgsForCall)] + fake.getEnvironmentVariablesByURLArgsForCall = append(fake.getEnvironmentVariablesByURLArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.GetEnvironmentVariablesByURLStub + fakeReturns := fake.getEnvironmentVariablesByURLReturns + fake.recordInvocation("GetEnvironmentVariablesByURL", []interface{}{arg1}) + fake.getEnvironmentVariablesByURLMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeCloudControllerClient) GetEnvironmentVariablesByURLCallCount() int { + fake.getEnvironmentVariablesByURLMutex.RLock() + defer fake.getEnvironmentVariablesByURLMutex.RUnlock() + return len(fake.getEnvironmentVariablesByURLArgsForCall) +} + +func (fake *FakeCloudControllerClient) GetEnvironmentVariablesByURLCalls(stub func(string) (resources.EnvironmentVariables, ccv3.Warnings, error)) { + fake.getEnvironmentVariablesByURLMutex.Lock() + defer fake.getEnvironmentVariablesByURLMutex.Unlock() + fake.GetEnvironmentVariablesByURLStub = stub +} + +func (fake *FakeCloudControllerClient) GetEnvironmentVariablesByURLArgsForCall(i int) string { + fake.getEnvironmentVariablesByURLMutex.RLock() + defer fake.getEnvironmentVariablesByURLMutex.RUnlock() + argsForCall := fake.getEnvironmentVariablesByURLArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeCloudControllerClient) GetEnvironmentVariablesByURLReturns(result1 resources.EnvironmentVariables, result2 ccv3.Warnings, result3 error) { + fake.getEnvironmentVariablesByURLMutex.Lock() + defer fake.getEnvironmentVariablesByURLMutex.Unlock() + fake.GetEnvironmentVariablesByURLStub = nil + fake.getEnvironmentVariablesByURLReturns = struct { + result1 resources.EnvironmentVariables + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeCloudControllerClient) GetEnvironmentVariablesByURLReturnsOnCall(i int, result1 resources.EnvironmentVariables, result2 ccv3.Warnings, result3 error) { + fake.getEnvironmentVariablesByURLMutex.Lock() + defer fake.getEnvironmentVariablesByURLMutex.Unlock() + fake.GetEnvironmentVariablesByURLStub = nil + if fake.getEnvironmentVariablesByURLReturnsOnCall == nil { + fake.getEnvironmentVariablesByURLReturnsOnCall = make(map[int]struct { + result1 resources.EnvironmentVariables + result2 ccv3.Warnings + result3 error + }) + } + fake.getEnvironmentVariablesByURLReturnsOnCall[i] = struct { + result1 resources.EnvironmentVariables + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeCloudControllerClient) GetEvents(arg1 ...ccv3.Query) ([]ccv3.Event, ccv3.Warnings, error) { fake.getEventsMutex.Lock() ret, specificReturn := fake.getEventsReturnsOnCall[len(fake.getEventsArgsForCall)] @@ -15102,6 +15184,8 @@ func (fake *FakeCloudControllerClient) Invocations() map[string][][]interface{} defer fake.getDropletsMutex.RUnlock() fake.getEnvironmentVariableGroupMutex.RLock() defer fake.getEnvironmentVariableGroupMutex.RUnlock() + fake.getEnvironmentVariablesByURLMutex.RLock() + defer fake.getEnvironmentVariablesByURLMutex.RUnlock() fake.getEventsMutex.RLock() defer fake.getEventsMutex.RUnlock() fake.getFeatureFlagMutex.RLock() diff --git a/api/cloudcontroller/ccv3/revisions.go b/api/cloudcontroller/ccv3/revisions.go index 67b14a0fc53..d50fca2630b 100644 --- a/api/cloudcontroller/ccv3/revisions.go +++ b/api/cloudcontroller/ccv3/revisions.go @@ -35,3 +35,14 @@ func (client *Client) GetApplicationRevisionsDeployed(appGUID string) ([]resourc }) return revisions, warnings, err } + +func (client *Client) GetEnvironmentVariablesByURL(url string) (resources.EnvironmentVariables, Warnings, error) { + environmentVariables := make(resources.EnvironmentVariables) + + _, warnings, err := client.MakeRequest(RequestParams{ + URL: url, + ResponseBody: &environmentVariables, + }) + + return environmentVariables, warnings, err +} diff --git a/api/cloudcontroller/ccv3/revisions_test.go b/api/cloudcontroller/ccv3/revisions_test.go index fe3ac699ea2..afeb59fbb7e 100644 --- a/api/cloudcontroller/ccv3/revisions_test.go +++ b/api/cloudcontroller/ccv3/revisions_test.go @@ -8,6 +8,7 @@ import ( "code.cloudfoundry.org/cli/api/cloudcontroller/ccv3/ccv3fakes" "code.cloudfoundry.org/cli/api/cloudcontroller/ccv3/internal" "code.cloudfoundry.org/cli/resources" + "code.cloudfoundry.org/cli/types" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -133,4 +134,79 @@ var _ = Describe("Revisions", func() { }) }) }) + + Describe("GetEnvironmentVariablesByURL", func() { + var ( + warnings Warnings + executeErr error + environmentVariables resources.EnvironmentVariables + ) + + JustBeforeEach(func() { + environmentVariables, warnings, executeErr = client.GetEnvironmentVariablesByURL("url") + }) + + When("the cloud controller returns errors and warnings", func() { + BeforeEach(func() { + errors := []ccerror.V3Error{ + { + Code: 10008, + Detail: "The request is semantically invalid: command presence", + Title: "CF-UnprocessableEntity", + }, + { + Code: 10010, + Detail: "App not found", + Title: "CF-ResourceNotFound", + }, + } + + requester.MakeRequestReturns( + "url", + Warnings{"this is a warning"}, + ccerror.MultiError{ResponseCode: http.StatusTeapot, Errors: errors}, + ) + }) + + It("returns the error and all warnings", func() { + Expect(executeErr).To(MatchError(ccerror.MultiError{ + ResponseCode: http.StatusTeapot, + Errors: []ccerror.V3Error{ + { + Code: 10008, + Detail: "The request is semantically invalid: command presence", + Title: "CF-UnprocessableEntity", + }, + { + Code: 10010, + Detail: "App not found", + Title: "CF-ResourceNotFound", + }, + }, + })) + Expect(warnings).To(ConsistOf("this is a warning")) + }) + }) + + When("revision exist", func() { + BeforeEach(func() { + requester.MakeRequestCalls(func(requestParams RequestParams) (JobURL, Warnings, error) { + (*requestParams.ResponseBody.(*resources.EnvironmentVariables))["foo"] = *types.NewFilteredString("bar") + return "url", Warnings{"this is a warning"}, nil + }) + }) + + It("returns the environment variables and all warnings", func() { + Expect(requester.MakeRequestCallCount()).To(Equal(1)) + actualParams := requester.MakeRequestArgsForCall(0) + Expect(actualParams.URL).To(Equal("url")) + + Expect(executeErr).NotTo(HaveOccurred()) + Expect(warnings).To(ConsistOf("this is a warning")) + + Expect(len(environmentVariables)).To(Equal(1)) + Expect(environmentVariables["foo"].Value).To(Equal("bar")) + }) + }) + }) }) diff --git a/command/common/help_command.go b/command/common/help_command.go index 60aea1586d9..a7553bb8aa2 100644 --- a/command/common/help_command.go +++ b/command/common/help_command.go @@ -136,8 +136,6 @@ func (cmd HelpCommand) displayHelpFooter(cmdInfo map[string]sharedaction.Command cmd.UI.DisplayNonWrappingTable(sharedaction.AllCommandsIndent, cmd.globalOptionsTableData(), 25) cmd.UI.DisplayNewline() - - cmd.displayCommandGroups(internal.ExperimentalHelpCategoryList, cmdInfo, 34) } func (cmd HelpCommand) displayCommonCommands() { diff --git a/command/common/internal/help_all_display.go b/command/common/internal/help_all_display.go index b3a8ca198af..9de9243d3fc 100644 --- a/command/common/internal/help_all_display.go +++ b/command/common/internal/help_all_display.go @@ -17,7 +17,7 @@ var HelpCategoryList = []HelpCategory{ {"start", "stop", "restart", "stage-package", "restage", "restart-app-instance"}, {"run-task", "task", "tasks", "terminate-task"}, {"packages", "create-package"}, - {"revisions", "rollback"}, + {"revision", "revisions", "rollback"}, {"droplets", "set-droplet", "download-droplet"}, {"events", "logs"}, {"env", "set-env", "unset-env"}, @@ -168,12 +168,3 @@ var HelpCategoryList = []HelpCategory{ }, }, } - -var ExperimentalHelpCategoryList = []HelpCategory{ - { - CategoryName: "EXPERIMENTAL COMMANDS:", - CommandList: [][]string{ - {"revision"}, - }, - }, -} diff --git a/command/common/internal/help_all_display_test.go b/command/common/internal/help_all_display_test.go index bb93ffb9ce9..695b5690566 100644 --- a/command/common/internal/help_all_display_test.go +++ b/command/common/internal/help_all_display_test.go @@ -34,16 +34,6 @@ var _ = Describe("test help all display", func() { } } } - - for _, category := range internal.ExperimentalHelpCategoryList { - for _, row := range category.CommandList { - for _, command := range row { - if command != "" { - fromHelpAllDisplay = append(fromHelpAllDisplay, command) - } - } - } - } }) It("lists all commands from command list in at least one category", func() { diff --git a/command/v7/actor.go b/command/v7/actor.go index ff5303ea855..d75347909ed 100644 --- a/command/v7/actor.go +++ b/command/v7/actor.go @@ -108,6 +108,7 @@ type Actor interface { GetDomainLabels(domainName string) (map[string]types.NullString, v7action.Warnings, error) GetEffectiveIsolationSegmentBySpace(spaceGUID string, orgDefaultIsolationSegmentGUID string) (resources.IsolationSegment, v7action.Warnings, error) GetEnvironmentVariableGroup(group constant.EnvironmentVariableGroupName) (v7action.EnvironmentVariableGroup, v7action.Warnings, error) + GetEnvironmentVariableGroupByRevision(revision resources.Revision) (v7action.EnvironmentVariableGroup, bool, v7action.Warnings, error) GetEnvironmentVariablesByApplicationNameAndSpace(appName string, spaceGUID string) (v7action.EnvironmentVariableGroups, v7action.Warnings, error) GetFeatureFlagByName(featureFlagName string) (resources.FeatureFlag, v7action.Warnings, error) GetFeatureFlags() ([]resources.FeatureFlag, v7action.Warnings, error) diff --git a/command/v7/revision_command.go b/command/v7/revision_command.go index ebd429291b4..814eff0c5dc 100644 --- a/command/v7/revision_command.go +++ b/command/v7/revision_command.go @@ -1,8 +1,13 @@ package v7 import ( - "code.cloudfoundry.org/cli/command" + "fmt" + "strconv" + + "code.cloudfoundry.org/cli/actor/v7action" "code.cloudfoundry.org/cli/command/flag" + "code.cloudfoundry.org/cli/resources" + "code.cloudfoundry.org/cli/types" ) type RevisionCommand struct { @@ -14,7 +19,118 @@ type RevisionCommand struct { } func (cmd RevisionCommand) Execute(_ []string) error { - cmd.UI.DisplayWarning(command.ExperimentalWarning) + err := cmd.SharedActor.CheckTarget(true, true) + if err != nil { + return err + } + + user, err := cmd.Config.CurrentUser() + if err != nil { + return err + } + + appName := cmd.RequiredArgs.AppName + cmd.UI.DisplayTextWithFlavor("Showing revision {{.Version}} for app {{.AppName}} in org {{.OrgName}} / space {{.SpaceName}} as {{.Username}}...", map[string]interface{}{ + "AppName": appName, + "OrgName": cmd.Config.TargetedOrganization().Name, + "SpaceName": cmd.Config.TargetedSpace().Name, + "Username": user.Name, + "Version": cmd.Version.Value, + }) + cmd.UI.DisplayNewline() + + app, warnings, err := cmd.Actor.GetApplicationByNameAndSpace(appName, cmd.Config.TargetedSpace().GUID) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + deployedRevisions, warnings, err := cmd.Actor.GetApplicationRevisionsDeployed(app.GUID) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + revision, warnings, err := cmd.Actor.GetRevisionByApplicationAndVersion( + app.GUID, + cmd.Version.Value, + ) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + isDeployed := revisionDeployed(revision, deployedRevisions) + + cmd.displayBasicRevisionInfo(revision, isDeployed) cmd.UI.DisplayNewline() + + cmd.UI.DisplayHeader("labels:") + labels := revision.Metadata.Labels + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + if len(labels) > 0 { + cmd.displayMetaData(labels) + cmd.UI.DisplayNewline() + } + + cmd.UI.DisplayHeader("annotations:") + annotations := revision.Metadata.Annotations + cmd.UI.DisplayWarnings(warnings) + + if len(annotations) > 0 { + cmd.displayMetaData(annotations) + cmd.UI.DisplayNewline() + } + + cmd.UI.DisplayHeader("application environment variables:") + envVars, isPresent, warnings, _ := cmd.Actor.GetEnvironmentVariableGroupByRevision(revision) + cmd.UI.DisplayWarnings(warnings) + if isPresent { + cmd.displayEnvVarGroup(envVars) + cmd.UI.DisplayNewline() + } + return nil } + +func (cmd RevisionCommand) displayBasicRevisionInfo(revision resources.Revision, isDeployed bool) { + keyValueTable := [][]string{ + {"revision:", fmt.Sprintf("%d", cmd.Version.Value)}, + {"deployed:", strconv.FormatBool(isDeployed)}, + {"description:", revision.Description}, + {"deployable:", strconv.FormatBool(revision.Deployable)}, + {"revision GUID:", revision.GUID}, + {"droplet GUID:", revision.Droplet.GUID}, + {"created on:", revision.CreatedAt}, + } + cmd.UI.DisplayKeyValueTable("", keyValueTable, 3) +} + +func (cmd RevisionCommand) displayEnvVarGroup(envVarGroup v7action.EnvironmentVariableGroup) { + envVarTable := [][]string{} + for k, v := range envVarGroup { + envVarTable = append(envVarTable, []string{fmt.Sprintf("%s:", k), v.Value}) + } + cmd.UI.DisplayKeyValueTable("", envVarTable, 3) +} + +func (cmd RevisionCommand) displayMetaData(data map[string]types.NullString) { + tableData := [][]string{} + for k, v := range data { + tableData = append(tableData, []string{fmt.Sprintf("%s:", k), v.Value}) + } + cmd.UI.DisplayKeyValueTable("", tableData, 3) + +} + +func revisionDeployed(revision resources.Revision, deployedRevisions []resources.Revision) bool { + for _, deployedRevision := range deployedRevisions { + if revision.GUID == deployedRevision.GUID { + return true + } + } + return false +} diff --git a/command/v7/revision_command_test.go b/command/v7/revision_command_test.go index 2b31c71045f..659ec9e8ad6 100644 --- a/command/v7/revision_command_test.go +++ b/command/v7/revision_command_test.go @@ -1,9 +1,17 @@ package v7_test import ( + "errors" + + "code.cloudfoundry.org/cli/actor/actionerror" + "code.cloudfoundry.org/cli/actor/v7action" "code.cloudfoundry.org/cli/command/commandfakes" + "code.cloudfoundry.org/cli/command/flag" v7 "code.cloudfoundry.org/cli/command/v7" "code.cloudfoundry.org/cli/command/v7/v7fakes" + "code.cloudfoundry.org/cli/resources" + "code.cloudfoundry.org/cli/types" + "code.cloudfoundry.org/cli/util/configv3" "code.cloudfoundry.org/cli/util/ui" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -18,6 +26,7 @@ var _ = Describe("revision Command", func() { fakeSharedActor *commandfakes.FakeSharedActor fakeActor *v7fakes.FakeActor binaryName string + executeErr error appName string out *Buffer @@ -46,10 +55,218 @@ var _ = Describe("revision Command", func() { }) JustBeforeEach(func() { - Expect(cmd.Execute(nil)).To(Succeed()) + executeErr = cmd.Execute(nil) + }) + + When("checking target fails", func() { + BeforeEach(func() { + fakeSharedActor.CheckTargetReturns(actionerror.NotLoggedInError{BinaryName: binaryName}) + }) + + It("returns an error", func() { + Expect(executeErr).To(MatchError(actionerror.NotLoggedInError{BinaryName: binaryName})) + + Expect(fakeSharedActor.CheckTargetCallCount()).To(Equal(1)) + checkTargetedOrg, checkTargetedSpace := fakeSharedActor.CheckTargetArgsForCall(0) + Expect(checkTargetedOrg).To(BeTrue()) + Expect(checkTargetedSpace).To(BeTrue()) + }) }) - It("displays the experimental warning", func() { - Expect(testUI.Err).To(Say("This command is in EXPERIMENTAL stage and may change without notice")) + When("the user is logged in, an org is targeted and a space is targeted", func() { + BeforeEach(func() { + fakeConfig.TargetedSpaceReturns(configv3.Space{Name: "some-space", GUID: "some-space-guid"}) + fakeConfig.TargetedOrganizationReturns(configv3.Organization{Name: "some-org"}) + }) + + When("getting the current user returns an error", func() { + BeforeEach(func() { + fakeConfig.CurrentUserReturns(configv3.User{}, errors.New("some-error")) + }) + + It("returns the error", func() { + Expect(executeErr).To(MatchError("some-error")) + }) + }) + + When("getting the current user succeeds", func() { + BeforeEach(func() { + fakeConfig.CurrentUserReturns(configv3.User{Name: "banana"}, nil) + }) + + When("when the requested app and revision exist", func() { + var revision resources.Revision + BeforeEach(func() { + fakeApp := resources.Application{ + GUID: "fake-guid", + Name: "some-app", + } + fakeActor.GetApplicationByNameAndSpaceReturns(fakeApp, nil, nil) + + revision = resources.Revision{ + Version: 3, + GUID: "A68F13F7-7E5E-4411-88E8-1FAC54F73F50", + Description: "On a different note", + CreatedAt: "2020-03-10T17:11:58Z", + Deployable: true, + Droplet: resources.Droplet{ + GUID: "droplet-guid", + }, + Links: resources.APILinks{ + "environment_variables": resources.APILink{ + HREF: "revision-environment-variables-link-3", + }, + }, + Metadata: &resources.Metadata{ + Labels: map[string]types.NullString{ + "label": types.NewNullString("foo3"), + }, + Annotations: map[string]types.NullString{ + "annotation": types.NewNullString("foo3"), + }, + }, + } + fakeActor.GetRevisionByApplicationAndVersionReturns(revision, nil, nil) + fakeActor.GetApplicationByNameAndSpaceReturns(resources.Application{GUID: "app-guid"}, nil, nil) + fakeActor.GetApplicationRevisionsDeployedReturns([]resources.Revision{revision}, nil, nil) + + environmentVariableGroup := v7action.EnvironmentVariableGroup{ + "foo": *types.NewFilteredString("bar3"), + } + fakeActor.GetEnvironmentVariableGroupByRevisionReturns( + environmentVariableGroup, + true, + nil, + nil, + ) + + cmd.Version = flag.Revision{NullInt: types.NullInt{Value: 3, IsSet: true}} + }) + + It("gets the app guid", func() { + Expect(fakeActor.GetApplicationByNameAndSpaceCallCount()).To(Equal(1)) + appName, spaceGUID := fakeActor.GetApplicationByNameAndSpaceArgsForCall(0) + Expect(appName).To(Equal("some-app")) + Expect(spaceGUID).To(Equal("some-space-guid")) + }) + + It("retrieves the requested revision for the app", func() { + Expect(fakeActor.GetRevisionByApplicationAndVersionCallCount()).To(Equal(1)) + appGUID, version := fakeActor.GetRevisionByApplicationAndVersionArgsForCall(0) + Expect(appGUID).To(Equal("app-guid")) + Expect(version).To(Equal(3)) + }) + + It("retrieves the deployed revisions", func() { + Expect(fakeActor.GetApplicationRevisionsDeployedCallCount()).To(Equal(1)) + Expect(fakeActor.GetApplicationRevisionsDeployedArgsForCall(0)).To(Equal("app-guid")) + }) + + It("retrieves the environment variables for the revision", func() { + Expect(fakeActor.GetEnvironmentVariableGroupByRevisionCallCount()).To(Equal(1)) + Expect(fakeActor.GetEnvironmentVariableGroupByRevisionArgsForCall(0)).To(Equal( + revision, + )) + }) + + It("displays the revision", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(testUI.Out).To(Say(`Showing revision 3 for app some-app in org some-org / space some-space as banana...`)) + Expect(testUI.Out).To(Say(`revision: 3`)) + Expect(testUI.Out).To(Say(`deployed: true`)) + Expect(testUI.Out).To(Say(`description: On a different note`)) + Expect(testUI.Out).To(Say(`deployable: true`)) + Expect(testUI.Out).To(Say(`revision GUID: A68F13F7-7E5E-4411-88E8-1FAC54F73F50`)) + Expect(testUI.Out).To(Say(`droplet GUID: droplet-guid`)) + Expect(testUI.Out).To(Say(`created on: 2020-03-10T17:11:58Z`)) + + Expect(testUI.Out).To(Say(`labels:`)) + Expect(testUI.Out).To(Say(`label: foo3`)) + + Expect(testUI.Out).To(Say(`annotations:`)) + Expect(testUI.Out).To(Say(`annotation: foo3`)) + + Expect(testUI.Out).To(Say(`application environment variables:`)) + Expect(testUI.Out).To(Say(`foo: bar3`)) + + }) + + When("there are no environment_variables link and metadata provided", func() { + BeforeEach(func() { + revision = resources.Revision{ + Version: 3, + GUID: "A68F13F7-7E5E-4411-88E8-1FAC54F73F50", + Description: "No env var link", + CreatedAt: "2020-03-10T17:11:58Z", + Deployable: true, + Droplet: resources.Droplet{ + GUID: "droplet-guid", + }, + Links: resources.APILinks{}, + Metadata: &resources.Metadata{}, + } + fakeActor.GetRevisionByApplicationAndVersionReturns(revision, nil, nil) + fakeActor.GetApplicationRevisionsDeployedReturns([]resources.Revision{revision}, nil, nil) + fakeActor.GetEnvironmentVariableGroupByRevisionReturns(nil, false, v7action.Warnings{"warn-env-var"}, nil) + }) + + It("warns the user it will not display env vars ", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(testUI.Err).To(Say("warn-env-var")) + Expect(testUI.Out).To(Say("labels:")) + Expect(testUI.Out).To(Say("annotations:")) + Expect(testUI.Out).To(Say("application environment variables:")) + }) + }) + + When("revision is not deployed", func() { + BeforeEach(func() { + revisionDeployed := resources.Revision{ + Version: 12345, + GUID: "Fake-guid", + Description: "derployed and definitely not your revision", + CreatedAt: "2020-03-10T17:11:58Z", + Deployable: true, + Droplet: resources.Droplet{ + GUID: "droplet-guid", + }, + } + fakeActor.GetApplicationRevisionsDeployedReturns([]resources.Revision{revisionDeployed}, nil, nil) + }) + + It("displays deployed field correctly", func() { + Expect(testUI.Out).To(Say(`deployed: false`)) + }) + }) + + When("no revisions were deployed", func() { + BeforeEach(func() { + fakeActor.GetApplicationRevisionsDeployedReturns([]resources.Revision{}, nil, nil) + }) + + It("displays deployed field correctly", func() { + Expect(testUI.Out).To(Say(`deployed: false`)) + }) + }) + }) + + When("there are no revisions available", func() { + BeforeEach(func() { + revision := resources.Revision{ + Version: 120, + } + fakeActor.GetRevisionByApplicationAndVersionReturns( + revision, + nil, + errors.New("Revision 120 not found"), + ) + }) + + It("returns 'revision not found'", func() { + Expect(executeErr).To(MatchError("Revision 120 not found")) + }) + }) + }) }) }) diff --git a/command/v7/revisions_command.go b/command/v7/revisions_command.go index 2c309350b61..c9e6373fe22 100644 --- a/command/v7/revisions_command.go +++ b/command/v7/revisions_command.go @@ -21,7 +21,7 @@ type RevisionsCommand struct { usage interface{} `usage:"CF_NAME revisions APP_NAME"` BaseCommand - relatedCommands interface{} `related_commands:"rollback"` + relatedCommands interface{} `related_commands:"revision, rollback"` } func (cmd RevisionsCommand) Execute(_ []string) error { @@ -90,6 +90,11 @@ func (cmd RevisionsCommand) Execute(_ []string) error { return err } + if len(revisionsDeployed) > 1 { + cmd.UI.DisplayText("Info: this app is in the middle of a rolling deployment. More than one revision is deployed.") + cmd.UI.DisplayNewline() + } + table := [][]string{{ "revision", "description", diff --git a/command/v7/rollback_command.go b/command/v7/rollback_command.go index 29c7c2e7169..477b75bdeea 100644 --- a/command/v7/rollback_command.go +++ b/command/v7/rollback_command.go @@ -20,7 +20,7 @@ type RollbackCommand struct { MaxInFlight *int `long:"max-in-flight" description:"Defines the maximum number of instances that will be actively being rolled back."` Strategy flag.DeploymentStrategy `long:"strategy" description:"Deployment strategy can be canary or rolling. When not specified, it defaults to rolling."` Version flag.Revision `long:"version" required:"true" description:"Roll back to the specified revision"` - relatedCommands interface{} `related_commands:"revisions"` + relatedCommands interface{} `related_commands:"revision, revisions"` usage interface{} `usage:"CF_NAME rollback APP_NAME [--version VERSION] [-f]"` LogCacheClient sharedaction.LogCacheClient diff --git a/command/v7/v7fakes/fake_actor.go b/command/v7/v7fakes/fake_actor.go index 12df707c491..62df07b77aa 100644 --- a/command/v7/v7fakes/fake_actor.go +++ b/command/v7/v7fakes/fake_actor.go @@ -1328,6 +1328,23 @@ type FakeActor struct { result2 v7action.Warnings result3 error } + GetEnvironmentVariableGroupByRevisionStub func(resources.Revision) (v7action.EnvironmentVariableGroup, bool, v7action.Warnings, error) + getEnvironmentVariableGroupByRevisionMutex sync.RWMutex + getEnvironmentVariableGroupByRevisionArgsForCall []struct { + arg1 resources.Revision + } + getEnvironmentVariableGroupByRevisionReturns struct { + result1 v7action.EnvironmentVariableGroup + result2 bool + result3 v7action.Warnings + result4 error + } + getEnvironmentVariableGroupByRevisionReturnsOnCall map[int]struct { + result1 v7action.EnvironmentVariableGroup + result2 bool + result3 v7action.Warnings + result4 error + } GetEnvironmentVariablesByApplicationNameAndSpaceStub func(string, string) (v7action.EnvironmentVariableGroups, v7action.Warnings, error) getEnvironmentVariablesByApplicationNameAndSpaceMutex sync.RWMutex getEnvironmentVariablesByApplicationNameAndSpaceArgsForCall []struct { @@ -9401,6 +9418,76 @@ func (fake *FakeActor) GetEnvironmentVariableGroupReturnsOnCall(i int, result1 v }{result1, result2, result3} } +func (fake *FakeActor) GetEnvironmentVariableGroupByRevision(arg1 resources.Revision) (v7action.EnvironmentVariableGroup, bool, v7action.Warnings, error) { + fake.getEnvironmentVariableGroupByRevisionMutex.Lock() + ret, specificReturn := fake.getEnvironmentVariableGroupByRevisionReturnsOnCall[len(fake.getEnvironmentVariableGroupByRevisionArgsForCall)] + fake.getEnvironmentVariableGroupByRevisionArgsForCall = append(fake.getEnvironmentVariableGroupByRevisionArgsForCall, struct { + arg1 resources.Revision + }{arg1}) + stub := fake.GetEnvironmentVariableGroupByRevisionStub + fakeReturns := fake.getEnvironmentVariableGroupByRevisionReturns + fake.recordInvocation("GetEnvironmentVariableGroupByRevision", []interface{}{arg1}) + fake.getEnvironmentVariableGroupByRevisionMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3, ret.result4 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4 +} + +func (fake *FakeActor) GetEnvironmentVariableGroupByRevisionCallCount() int { + fake.getEnvironmentVariableGroupByRevisionMutex.RLock() + defer fake.getEnvironmentVariableGroupByRevisionMutex.RUnlock() + return len(fake.getEnvironmentVariableGroupByRevisionArgsForCall) +} + +func (fake *FakeActor) GetEnvironmentVariableGroupByRevisionCalls(stub func(resources.Revision) (v7action.EnvironmentVariableGroup, bool, v7action.Warnings, error)) { + fake.getEnvironmentVariableGroupByRevisionMutex.Lock() + defer fake.getEnvironmentVariableGroupByRevisionMutex.Unlock() + fake.GetEnvironmentVariableGroupByRevisionStub = stub +} + +func (fake *FakeActor) GetEnvironmentVariableGroupByRevisionArgsForCall(i int) resources.Revision { + fake.getEnvironmentVariableGroupByRevisionMutex.RLock() + defer fake.getEnvironmentVariableGroupByRevisionMutex.RUnlock() + argsForCall := fake.getEnvironmentVariableGroupByRevisionArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeActor) GetEnvironmentVariableGroupByRevisionReturns(result1 v7action.EnvironmentVariableGroup, result2 bool, result3 v7action.Warnings, result4 error) { + fake.getEnvironmentVariableGroupByRevisionMutex.Lock() + defer fake.getEnvironmentVariableGroupByRevisionMutex.Unlock() + fake.GetEnvironmentVariableGroupByRevisionStub = nil + fake.getEnvironmentVariableGroupByRevisionReturns = struct { + result1 v7action.EnvironmentVariableGroup + result2 bool + result3 v7action.Warnings + result4 error + }{result1, result2, result3, result4} +} + +func (fake *FakeActor) GetEnvironmentVariableGroupByRevisionReturnsOnCall(i int, result1 v7action.EnvironmentVariableGroup, result2 bool, result3 v7action.Warnings, result4 error) { + fake.getEnvironmentVariableGroupByRevisionMutex.Lock() + defer fake.getEnvironmentVariableGroupByRevisionMutex.Unlock() + fake.GetEnvironmentVariableGroupByRevisionStub = nil + if fake.getEnvironmentVariableGroupByRevisionReturnsOnCall == nil { + fake.getEnvironmentVariableGroupByRevisionReturnsOnCall = make(map[int]struct { + result1 v7action.EnvironmentVariableGroup + result2 bool + result3 v7action.Warnings + result4 error + }) + } + fake.getEnvironmentVariableGroupByRevisionReturnsOnCall[i] = struct { + result1 v7action.EnvironmentVariableGroup + result2 bool + result3 v7action.Warnings + result4 error + }{result1, result2, result3, result4} +} + func (fake *FakeActor) GetEnvironmentVariablesByApplicationNameAndSpace(arg1 string, arg2 string) (v7action.EnvironmentVariableGroups, v7action.Warnings, error) { fake.getEnvironmentVariablesByApplicationNameAndSpaceMutex.Lock() ret, specificReturn := fake.getEnvironmentVariablesByApplicationNameAndSpaceReturnsOnCall[len(fake.getEnvironmentVariablesByApplicationNameAndSpaceArgsForCall)] @@ -19650,6 +19737,8 @@ func (fake *FakeActor) Invocations() map[string][][]interface{} { defer fake.getEffectiveIsolationSegmentBySpaceMutex.RUnlock() fake.getEnvironmentVariableGroupMutex.RLock() defer fake.getEnvironmentVariableGroupMutex.RUnlock() + fake.getEnvironmentVariableGroupByRevisionMutex.RLock() + defer fake.getEnvironmentVariableGroupByRevisionMutex.RUnlock() fake.getEnvironmentVariablesByApplicationNameAndSpaceMutex.RLock() defer fake.getEnvironmentVariablesByApplicationNameAndSpaceMutex.RUnlock() fake.getFeatureFlagByNameMutex.RLock() diff --git a/integration/v7/isolated/revision_command_test.go b/integration/v7/isolated/revision_command_test.go index 47cf099fc4f..87b5c19dc4b 100644 --- a/integration/v7/isolated/revision_command_test.go +++ b/integration/v7/isolated/revision_command_test.go @@ -1,6 +1,12 @@ package isolated import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + . "code.cloudfoundry.org/cli/cf/util/testhelpers/matchers" "code.cloudfoundry.org/cli/integration/helpers" . "github.com/onsi/ginkgo/v2" @@ -10,12 +16,26 @@ import ( ) var _ = Describe("revision command", func() { + var ( + orgName string + spaceName string + appName string + username string + ) + + BeforeEach(func() { + username, _ = helpers.GetCredentials() + orgName = helpers.NewOrgName() + spaceName = helpers.NewSpaceName() + appName = helpers.PrefixedRandomName("app") + }) + Describe("help", func() { When("--help flag is set", func() { It("appears in cf help -a", func() { session := helpers.CF("help", "-a") Eventually(session).Should(Exit(0)) - Expect(session).To(HaveCommandInCategoryWithDescription("revision", "EXPERIMENTAL COMMANDS", "Show details for a specific app revision")) + Expect(session).To(HaveCommandInCategoryWithDescription("revision", "APPS", "Show details for a specific app revision")) }) It("Displays revision command usage to output", func() { @@ -34,4 +54,112 @@ var _ = Describe("revision command", func() { }) }) }) + + When("targetting and org and space", func() { + BeforeEach(func() { + helpers.SetupCF(orgName, spaceName) + }) + + AfterEach(func() { + helpers.QuickDeleteOrg(orgName) + }) + + When("the requested revision version does not exist", func() { + BeforeEach(func() { + helpers.WithHelloWorldApp(func(appDir string) { + Eventually(helpers.CF("create-app", appName)).Should(Exit(0)) + Eventually(helpers.CF("set-env", appName, "foo", "bar1")).Should(Exit(0)) + Eventually(helpers.CF("push", appName, "-p", appDir)).Should(Exit(0)) + }) + }) + It("displays revision not found", func() { + session := helpers.CF("revision", appName, "--version", "125") + Eventually(session).Should(Exit(1)) + + Expect(session).Should(Say( + fmt.Sprintf("Showing revision 125 for app %s in org %s / space %s as %s...", appName, orgName, spaceName, username), + )) + Expect(session.Err).Should(Say("Revision '125' not found")) + }) + }) + + When("the requested app and revision both exist", func() { + BeforeEach(func() { + helpers.WithHelloWorldApp(func(appDir string) { + Eventually(helpers.CF("create-app", appName)).Should(Exit(0)) + Eventually(helpers.CF("set-env", appName, "foo", "bar1")).Should(Exit(0)) + Eventually(helpers.CF("push", appName, "-p", appDir)).Should(Exit(0)) + Eventually(helpers.CF("push", appName, "-p", appDir)).Should(Exit(0)) + }) + }) + + It("shows details about the revision", func() { + cmd := exec.Command("bash", "-c", "cf revision "+appName+" --version 1 | grep \"revision GUID\" | sed -e 's/.*:\\s*//' -e 's/^[ \\t]*//'") + var stdout bytes.Buffer + cmd.Stdout = &stdout + err := cmd.Run() + if err != nil { + return + } + revisionGUID := strings.TrimSpace(stdout.String()) + data := map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "label": "foo3", + }, + "annotations": map[string]string{ + "annotation": "foo3", + }, + }, + } + metadata, err := json.Marshal(data) + Expect(err).NotTo(HaveOccurred()) + + url := "/v3/revisions/" + string(revisionGUID) + Eventually(helpers.CF("curl", "-X", "PATCH", url, "-d", string(metadata))).Should(Exit(0)) + + session := helpers.CF("revision", appName, "--version", "1") + Eventually(session).Should(Exit(0)) + + Expect(session).Should(Say( + fmt.Sprintf("Showing revision 1 for app %s in org %s / space %s as %s...", appName, orgName, spaceName, username), + )) + Expect(session).Should(Say(`revision: 1`)) + Expect(session).Should(Say(`deployed: false`)) + Expect(session).Should(Say(`description: Initial revision`)) + Expect(session).Should(Say(`deployable: true`)) + Expect(session).Should(Say(`revision GUID: \S+\n`)) + Expect(session).Should(Say(`droplet GUID: \S+\n`)) + Expect(session).Should(Say(`created on: \S+\n`)) + + Expect(session).Should(Say(`labels:`)) + Expect(session).Should(Say(`label: foo3`)) + + Expect(session).Should(Say(`annotations:`)) + Expect(session).Should(Say(`annotation: foo3`)) + + Expect(session).Should(Say(`application environment variables:`)) + Expect(session).Should(Say(`foo: bar1`)) + + session = helpers.CF("revision", appName, "--version", "2") + Eventually(session).Should(Exit(0)) + Expect(session).Should(Say( + fmt.Sprintf("Showing revision 2 for app %s in org %s / space %s as %s...", appName, orgName, spaceName, username), + )) + Expect(session).Should(Say(`revision: 2`)) + Expect(session).Should(Say(`deployed: true`)) + Expect(session).Should(Say(`description: New droplet deployed`)) + Expect(session).Should(Say(`deployable: true`)) + Expect(session).Should(Say(`revision GUID: \S+\n`)) + Expect(session).Should(Say(`droplet GUID: \S+\n`)) + Expect(session).Should(Say(`created on: \S+\n`)) + + Expect(session).Should(Say(`labels:`)) + Expect(session).Should(Say(`annotations:`)) + + Expect(session).Should(Say(`application environment variables:`)) + Expect(session).Should(Say(`foo: bar1`)) + }) + }) + }) }) diff --git a/integration/v7/isolated/revisions_command_test.go b/integration/v7/isolated/revisions_command_test.go index 952654b1b7a..bb718f0177c 100644 --- a/integration/v7/isolated/revisions_command_test.go +++ b/integration/v7/isolated/revisions_command_test.go @@ -43,7 +43,7 @@ var _ = Describe("revisions command", func() { Eventually(session).Should(Say("USAGE:")) Eventually(session).Should(Say("cf revisions APP_NAME")) Eventually(session).Should(Say("SEE ALSO:")) - Eventually(session).Should(Say("rollback")) + Eventually(session).Should(Say("revision, rollback")) Eventually(session).Should(Exit(0)) }) }) diff --git a/integration/v7/isolated/rollback_command_test.go b/integration/v7/isolated/rollback_command_test.go index e8c1b6c9431..e2bbc7b4f79 100644 --- a/integration/v7/isolated/rollback_command_test.go +++ b/integration/v7/isolated/rollback_command_test.go @@ -39,7 +39,7 @@ var _ = Describe("rollback command", func() { Expect(session).To(Say(`--strategy\s+Deployment strategy can be canary or rolling. When not specified, it defaults to rolling.`)) Expect(session).To(Say(`--version\s+Roll back to the specified revision`)) Expect(session).To(Say("SEE ALSO:")) - Expect(session).To(Say("revisions")) + Expect(session).To(Say("revision, revisions")) }) }) }) diff --git a/resources/metadata_resource.go b/resources/metadata_resource.go index 414fe1427de..afa1c180323 100644 --- a/resources/metadata_resource.go +++ b/resources/metadata_resource.go @@ -3,7 +3,8 @@ package resources import "code.cloudfoundry.org/cli/types" type Metadata struct { - Labels map[string]types.NullString `json:"labels,omitempty"` + Annotations map[string]types.NullString `json:"annotations,omitempty"` + Labels map[string]types.NullString `json:"labels,omitempty"` } type ResourceMetadata struct { diff --git a/resources/revision_resource.go b/resources/revision_resource.go index 39d577a26ef..e187c951f01 100644 --- a/resources/revision_resource.go +++ b/resources/revision_resource.go @@ -1,11 +1,13 @@ package resources type Revision struct { - GUID string `json:"guid"` - Version int `json:"version"` - Deployable bool `json:"deployable"` - Description string `json:"description"` - Droplet Droplet `json:"droplet"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + GUID string `json:"guid"` + Version int `json:"version"` + Deployable bool `json:"deployable"` + Description string `json:"description"` + Droplet Droplet `json:"droplet"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Links APILinks `json:"links"` + Metadata *Metadata `json:"metadata,omitempty"` }