diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index cfea069..643cac9 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -42,6 +42,11 @@ const ( UserOwnersFlag = "user-owners" GroupOwnersFlag = "group-owners" SigningKeyFlag = "signing-key" + SyncFlag = "sync" + PromotionTypeFlag = "promotion-type" + DryRunFlag = "dry-run" + ExcludeReposFlag = "exclude-repos" + IncludeReposFlag = "include-repos" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -69,6 +74,11 @@ var flagsMap = map[string]components.Flag{ UserOwnersFlag: components.NewStringFlag(UserOwnersFlag, "Comma-separated list of user owners.", func(f *components.StringFlag) { f.Mandatory = false }), GroupOwnersFlag: components.NewStringFlag(GroupOwnersFlag, "Comma-separated list of group owners.", func(f *components.StringFlag) { f.Mandatory = false }), SigningKeyFlag: components.NewStringFlag(SigningKeyFlag, "The GPG/RSA key-pair name given in Artifactory.", func(f *components.StringFlag) { f.Mandatory = false }), + SyncFlag: components.NewBoolFlag(SyncFlag, "Whether to synchronize the operation.", components.WithBoolDefaultValueTrue()), + PromotionTypeFlag: components.NewStringFlag(PromotionTypeFlag, "The promotion type. The following values are supported: "+coreutils.ListToText(model.PromotionTypeValues), func(f *components.StringFlag) { f.Mandatory = false; f.DefaultValue = model.PromotionTypeCopy }), + DryRunFlag: components.NewBoolFlag(DryRunFlag, "Perform a simulation of the operation.", components.WithBoolDefaultValueFalse()), + ExcludeReposFlag: components.NewStringFlag(ExcludeReposFlag, "Semicolon-separated list of repositories to exclude.", func(f *components.StringFlag) { f.Mandatory = false }), + IncludeReposFlag: components.NewStringFlag(IncludeReposFlag, "Semicolon-separated list of repositories to include.", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -90,8 +100,11 @@ var commandFlags = map[string][]string{ user, accessToken, serverId, - ApplicationKeyFlag, - StageVarsFlag, + SyncFlag, + PromotionTypeFlag, + DryRunFlag, + ExcludeReposFlag, + IncludeReposFlag, }, DeleteAppVersion: { url, diff --git a/apptrust/commands/version/promote_app_version_cmd.go b/apptrust/commands/version/promote_app_version_cmd.go index 909d20d..95fc680 100644 --- a/apptrust/commands/version/promote_app_version_cmd.go +++ b/apptrust/commands/version/promote_app_version_cmd.go @@ -20,7 +20,10 @@ import ( type promoteAppVersionCommand struct { versionService versions.VersionService serverDetails *coreConfig.ServerDetails + applicationKey string + version string requestPayload *model.PromoteAppVersionRequest + sync bool } func (pv *promoteAppVersionCommand) Run() error { @@ -29,7 +32,7 @@ func (pv *promoteAppVersionCommand) Run() error { return err } - return pv.versionService.PromoteAppVersion(ctx, pv.requestPayload) + return pv.versionService.PromoteAppVersion(ctx, pv.applicationKey, pv.version, pv.requestPayload, pv.sync) } func (pv *promoteAppVersionCommand) ServerDetails() (*coreConfig.ServerDetails, error) { @@ -41,9 +44,17 @@ func (pv *promoteAppVersionCommand) CommandName() string { } func (pv *promoteAppVersionCommand) prepareAndRunCommand(ctx *components.Context) error { - if len(ctx.Arguments) != 1 { + if len(ctx.Arguments) != 3 { return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) } + + // Extract from arguments + pv.applicationKey = ctx.Arguments[0] + pv.version = ctx.Arguments[1] + + // Extract sync flag value + pv.sync = ctx.GetBoolTFlagValue(commands.SyncFlag) + serverDetails, err := utils.ServerDetailsByFlags(ctx) if err != nil { return err @@ -57,10 +68,37 @@ func (pv *promoteAppVersionCommand) prepareAndRunCommand(ctx *components.Context } func (pv *promoteAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.PromoteAppVersionRequest, error) { + stage := ctx.Arguments[2] + + var includedRepos []string + var excludedRepos []string + + if includeReposStr := ctx.GetStringFlagValue(commands.IncludeReposFlag); includeReposStr != "" { + includedRepos = utils.ParseSliceFlag(includeReposStr) + } + + if excludeReposStr := ctx.GetStringFlagValue(commands.ExcludeReposFlag); excludeReposStr != "" { + excludedRepos = utils.ParseSliceFlag(excludeReposStr) + } + + // Validate promotion type flag + promotionType := ctx.GetStringFlagValue(commands.PromotionTypeFlag) + validatedPromotionType, err := utils.ValidateEnumFlag(commands.PromotionTypeFlag, promotionType, model.PromotionTypeCopy, model.PromotionTypeValues) + if err != nil { + return nil, err + } + + // If dry-run is true, override with dry_run + dryRun := ctx.GetBoolFlagValue(commands.DryRunFlag) + if dryRun { + validatedPromotionType = model.PromotionTypeDryRun + } + return &model.PromoteAppVersionRequest{ - ApplicationKey: ctx.GetStringFlagValue(commands.ApplicationKeyFlag), - Version: ctx.Arguments[0], - Environment: ctx.GetStringFlagValue(commands.StageVarsFlag), + Stage: stage, + PromotionType: validatedPromotionType, + IncludedRepositoryKeys: includedRepos, + ExcludedRepositoryKeys: excludedRepos, }, nil } @@ -73,8 +111,18 @@ func GetPromoteAppVersionCommand(appContext app.Context) components.Command { Aliases: []string{"vp"}, Arguments: []components.Argument{ { - Name: "version-name", - Description: "The name of the version", + Name: "application-key", + Description: "The application key", + Optional: false, + }, + { + Name: "version", + Description: "The version to promote", + Optional: false, + }, + { + Name: "target-stage", + Description: "The target stage to which the application version should be promoted", Optional: false, }, }, diff --git a/apptrust/commands/version/promote_app_version_cmd_test.go b/apptrust/commands/version/promote_app_version_cmd_test.go index 9ba2035..3923930 100644 --- a/apptrust/commands/version/promote_app_version_cmd_test.go +++ b/apptrust/commands/version/promote_app_version_cmd_test.go @@ -1,6 +1,7 @@ package version import ( + "errors" "testing" "github.com/jfrog/jfrog-cli-application/apptrust/commands" @@ -8,40 +9,92 @@ import ( "go.uber.org/mock/gomock" "github.com/jfrog/jfrog-cli-application/apptrust/model" - coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/stretchr/testify/assert" ) -func TestRun(t *testing.T) { +func TestPromoteAppVersionCommand_Run(t *testing.T) { + tests := []struct { + name string + sync bool + }{ + { + name: "sync flag true", + sync: true, + }, + { + name: "sync flag false", + sync: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + version := "1.0.0" + requestPayload := &model.PromoteAppVersionRequest{ + Stage: "prod", + } + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().PromoteAppVersion(gomock.Any(), applicationKey, version, requestPayload, tt.sync). + Return(nil).Times(1) + + cmd := &promoteAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + sync: tt.sync, + } + + err := cmd.Run() + assert.NoError(t, err) + }) + } +} + +func TestPromoteAppVersionCommand_Run_Error(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - serverDetails := &coreconfig.ServerDetails{} + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + version := "1.0.0" requestPayload := &model.PromoteAppVersionRequest{ - ApplicationKey: "app", - Version: "1.0.0", - Environment: "env", + Stage: "prod", } + sync := true + expectedError := errors.New("service error occurred") mockVersionService := mockversions.NewMockVersionService(ctrl) - mockVersionService.EXPECT().PromoteAppVersion(gomock.Any(), requestPayload). - Return(nil).Times(1) + mockVersionService.EXPECT().PromoteAppVersion(gomock.Any(), applicationKey, version, requestPayload, sync). + Return(expectedError).Times(1) cmd := &promoteAppVersionCommand{ versionService: mockVersionService, serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, requestPayload: requestPayload, + sync: sync, } err := cmd.Run() - assert.NoError(t, err) + assert.Error(t, err) + assert.Contains(t, err.Error(), "service error occurred") } -func TestServerDetails(t *testing.T) { +func TestPromoteAppVersionCommand_ServerDetails(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - serverDetails := &coreconfig.ServerDetails{} + serverDetails := &config.ServerDetails{} cmd := &promoteAppVersionCommand{ serverDetails: serverDetails, } @@ -51,7 +104,7 @@ func TestServerDetails(t *testing.T) { assert.Equal(t, serverDetails, details) } -func TestCommandName(t *testing.T) { +func TestPromoteAppVersionCommand_CommandName(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/apptrust/http/http_client.go b/apptrust/http/http_client.go index 1356244..2922bcc 100644 --- a/apptrust/http/http_client.go +++ b/apptrust/http/http_client.go @@ -23,7 +23,7 @@ const apptrustApiPath = "apptrust/api" type ApptrustHttpClient interface { GetHttpClient() *jfroghttpclient.JfrogHttpClient - Post(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) + Post(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) Get(path string) (resp *http.Response, body []byte, err error) Patch(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) Delete(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) @@ -86,8 +86,8 @@ func (c *apptrustHttpClient) GetHttpClient() *jfroghttpclient.JfrogHttpClient { return c.client } -func (c *apptrustHttpClient) Post(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) { - url, err := utils.BuildUrl(c.serverDetails.Url, apptrustApiPath+path, nil) +func (c *apptrustHttpClient) Post(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) { + url, err := utils.BuildUrl(c.serverDetails.Url, apptrustApiPath+path, params) if err != nil { return nil, nil, err } diff --git a/apptrust/model/promote_app_version_request.go b/apptrust/model/promote_app_version_request.go index a9a6143..6b27dec 100644 --- a/apptrust/model/promote_app_version_request.go +++ b/apptrust/model/promote_app_version_request.go @@ -1,7 +1,22 @@ package model +const ( + PromotionTypeCopy = "copy" + PromotionTypeMove = "move" + + // This value cannot be set via the --promotion-type flag in the CLI. + // It is sent to the promotion_type field in the REST API only when the --dry-run flag is used. + PromotionTypeDryRun = "dry_run" +) + +var PromotionTypeValues = []string{ + PromotionTypeCopy, + PromotionTypeMove, +} + type PromoteAppVersionRequest struct { - ApplicationKey string `json:"application_key"` - Version string `json:"version"` - Environment string `json:"environment"` + Stage string `json:"stage"` + PromotionType string `json:"promotion_type,omitempty"` + IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` + ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` } diff --git a/apptrust/service/applications/application_service.go b/apptrust/service/applications/application_service.go index 94a3d77..475c53c 100644 --- a/apptrust/service/applications/application_service.go +++ b/apptrust/service/applications/application_service.go @@ -25,7 +25,7 @@ func NewApplicationService() ApplicationService { } func (as *applicationService) CreateApplication(ctx service.Context, requestBody *model.AppDescriptor) error { - response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications", requestBody) + response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications", requestBody, nil) if err != nil { return err } diff --git a/apptrust/service/applications/application_service_test.go b/apptrust/service/applications/application_service_test.go index 264c79d..9e5a338 100644 --- a/apptrust/service/applications/application_service_test.go +++ b/apptrust/service/applications/application_service_test.go @@ -49,7 +49,7 @@ func TestApplicationService_CreateApplication(t *testing.T) { defer ctrl.Finish() mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Post("/v1/applications", gomock.Any()).Return(tt.mockResponse, tt.mockBody, tt.mockError) + mockHttpClient.EXPECT().Post("/v1/applications", gomock.Any(), nil).Return(tt.mockResponse, tt.mockBody, tt.mockError) mockCtx := mockservice.NewMockContext(ctrl) mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) diff --git a/apptrust/service/packages/package_service.go b/apptrust/service/packages/package_service.go index 85c80c7..f548bd2 100644 --- a/apptrust/service/packages/package_service.go +++ b/apptrust/service/packages/package_service.go @@ -23,7 +23,7 @@ func NewPackageService() PackageService { func (ps *packageService) BindPackage(ctx service.Context, request *model.BindPackageRequest) error { endpoint := "/v1/package" - response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) if err != nil { return err } diff --git a/apptrust/service/packages/package_service_test.go b/apptrust/service/packages/package_service_test.go index 76196a9..7e2da9a 100644 --- a/apptrust/service/packages/package_service_test.go +++ b/apptrust/service/packages/package_service_test.go @@ -67,7 +67,7 @@ func TestBindPackage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Post("/v1/package", tt.request). + mockHttpClient.EXPECT().Post("/v1/package", tt.request, nil). Return(tt.mockResponse, []byte(""), tt.mockError).Times(1) mockCtx := mockservice.NewMockContext(ctrl) diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 6b38ed6..4336678 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -4,6 +4,7 @@ package versions import ( "fmt" + "strconv" "github.com/jfrog/jfrog-cli-application/apptrust/service" @@ -12,7 +13,7 @@ import ( type VersionService interface { CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest) error - PromoteAppVersion(ctx service.Context, payload *model.PromoteAppVersionRequest) error + PromoteAppVersion(ctx service.Context, applicationKey string, version string, payload *model.PromoteAppVersionRequest, sync bool) error DeleteAppVersion(ctx service.Context, applicationKey string, version string) error } @@ -23,7 +24,7 @@ func NewVersionService() VersionService { } func (vs *versionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest) error { - response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/version", request) + response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/version", request, nil) if err != nil { return err } @@ -36,8 +37,9 @@ func (vs *versionService) CreateAppVersion(ctx service.Context, request *model.C return nil } -func (vs *versionService) PromoteAppVersion(ctx service.Context, payload *model.PromoteAppVersionRequest) error { - response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/version/promote", payload) +func (vs *versionService) PromoteAppVersion(ctx service.Context, applicationKey, version string, request *model.PromoteAppVersionRequest, sync bool) error { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/promote", applicationKey, version) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, map[string]string{"async": strconv.FormatBool(!sync)}) if err != nil { return err } @@ -50,7 +52,7 @@ func (vs *versionService) PromoteAppVersion(ctx service.Context, payload *model. return nil } -func (vs *versionService) DeleteAppVersion(ctx service.Context, applicationKey string, version string) error { +func (vs *versionService) DeleteAppVersion(ctx service.Context, applicationKey, version string) error { url := fmt.Sprintf("/v1/applications/%s/versions/%s", applicationKey, version) response, responseBody, err := ctx.GetHttpClient().Delete(url, nil) if err != nil { diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 538564d..45aa9b5 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -3,6 +3,7 @@ package versions import ( "errors" "net/http" + "strconv" "testing" mockhttp "github.com/jfrog/jfrog-cli-application/apptrust/http/mocks" @@ -56,7 +57,7 @@ func TestCreateAppVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Post("/v1/applications/version", tt.request). + mockHttpClient.EXPECT().Post("/v1/applications/version", tt.request, nil). Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) mockCtx := mockservice.NewMockContext(ctrl) @@ -81,31 +82,75 @@ func TestPromoteAppVersion(t *testing.T) { tests := []struct { name string + applicationKey string + version string payload *model.PromoteAppVersionRequest + sync bool + expectedEndpoint string mockResponse *http.Response mockResponseBody string mockError error expectedError string }{ { - name: "success", - payload: &model.PromoteAppVersionRequest{}, + name: "success with sync=true", + applicationKey: "test-app", + version: "1.0.0", + payload: &model.PromoteAppVersionRequest{ + Stage: "prod", + PromotionType: model.PromotionTypeCopy, + IncludedRepositoryKeys: []string{"repo1", "repo2"}, + ExcludedRepositoryKeys: []string{"repo3"}, + }, + sync: true, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", mockResponse: &http.Response{StatusCode: 200}, mockResponseBody: "{}", mockError: nil, expectedError: "", }, { - name: "failure", - payload: &model.PromoteAppVersionRequest{}, + name: "success with sync=false", + applicationKey: "test-app", + version: "1.0.0", + payload: &model.PromoteAppVersionRequest{ + Stage: "prod", + PromotionType: model.PromotionTypeCopy, + IncludedRepositoryKeys: []string{"repo1", "repo2"}, + ExcludedRepositoryKeys: []string{"repo3"}, + }, + sync: false, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", + mockResponse: &http.Response{StatusCode: 202}, + mockResponseBody: "{}", + mockError: nil, + expectedError: "", + }, + { + name: "failure", + applicationKey: "test-app", + version: "1.0.0", + payload: &model.PromoteAppVersionRequest{ + Stage: "prod", + PromotionType: model.PromotionTypeCopy, + }, + sync: true, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", mockResponse: &http.Response{StatusCode: 400}, mockResponseBody: "error", mockError: nil, expectedError: "failed to promote app version", }, { - name: "http client error", - payload: &model.PromoteAppVersionRequest{}, + name: "http client error", + applicationKey: "test-app", + version: "1.0.0", + payload: &model.PromoteAppVersionRequest{ + Stage: "prod", + PromotionType: model.PromotionTypeCopy, + }, + sync: false, + expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/promote", mockResponse: nil, mockResponseBody: "", mockError: errors.New("http client error"), @@ -116,13 +161,13 @@ func TestPromoteAppVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Post("/v1/applications/version/promote", tt.payload). + mockHttpClient.EXPECT().Post(tt.expectedEndpoint, tt.payload, map[string]string{"async": strconv.FormatBool(!tt.sync)}). Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) mockCtx := mockservice.NewMockContext(ctrl) mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) - err := service.PromoteAppVersion(mockCtx, tt.payload) + err := service.PromoteAppVersion(mockCtx, tt.applicationKey, tt.version, tt.payload, tt.sync) if tt.expectedError == "" { assert.NoError(t, err) } else {