diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index d3b2017..5c4d1e0 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -62,6 +62,7 @@ const ( DeletePropertiesFlag = "delete-properties" IncludeFilterFlag = "include-filter" ExcludeFilterFlag = "exclude-filter" + ConflictResolutionFlag = "conflict-resolution" PathMappingFlag = "path-mapping" ) @@ -106,6 +107,7 @@ var flagsMap = map[string]components.Flag{ SourceTypeArtifactsFlag: components.NewStringFlag(SourceTypeArtifactsFlag, "List of semicolon-separated (;) artifacts in the form of 'path=repo/path/to/artifact1[, sha256=hash1]; path=repo/path/to/artifact2[, sha256=hash2]' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), PropertiesFlag: components.NewStringFlag(PropertiesFlag, "Sets or updates custom properties for the application version in format 'key1=value1[,value2,...];key2=value3[,value4,...]'", func(f *components.StringFlag) { f.Mandatory = false }), DeletePropertiesFlag: components.NewStringFlag(DeletePropertiesFlag, "Remove a property key and all its values", func(f *components.StringFlag) { f.Mandatory = false }), + ConflictResolutionFlag: components.NewStringFlag(ConflictResolutionFlag, "How to resolve source conflicts when the same artifact path appears in multiple sources. Supported values: 'automatic' (pick the newest artifact by creation date, this is the default in Artifactory), 'manual' (reject the request if conflicts exist; use --include-filter / --exclude-filter to resolve).", func(f *components.StringFlag) { f.Mandatory = false }), PathMappingFlag: components.NewStringFlag(PathMappingFlag, "List of semicolon-separated (;) path mapping rules in the form of 'input=(.*), output=stable-release/$1[, package-type=.*]; input=(.*\\.jar), output=jars/$1, package-type=maven'. Note: quote the value to prevent shell expansion of $1.", func(f *components.StringFlag) { f.Mandatory = false }), } @@ -119,6 +121,7 @@ var commandFlags = map[string][]string{ TagFlag, DraftFlag, SkipUnassignedFlag, + ConflictResolutionFlag, SourceTypeBuildsFlag, SourceTypeReleaseBundlesFlag, SourceTypeApplicationVersionsFlag, diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index 2997697..95ae09b 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -20,12 +20,13 @@ import ( ) type createAppVersionCommand struct { - versionService versions.VersionService - serverDetails *coreConfig.ServerDetails - requestPayload *model.CreateAppVersionRequest - sync bool - dryRun bool - responseBody []byte + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + requestPayload *model.CreateAppVersionRequest + sync bool + dryRun bool + conflictResolution string + responseBody []byte } func (cv *createAppVersionCommand) Run() error { @@ -34,7 +35,7 @@ func (cv *createAppVersionCommand) Run() error { return err } - cv.responseBody, err = cv.versionService.CreateAppVersion(ctx, cv.requestPayload, cv.sync, cv.dryRun) + cv.responseBody, err = cv.versionService.CreateAppVersion(ctx, cv.requestPayload, cv.sync, cv.dryRun, cv.conflictResolution) return err } @@ -61,6 +62,7 @@ func (cv *createAppVersionCommand) prepareAndRunCommand(ctx *components.Context) return err } cv.dryRun = ctx.GetBoolFlagValue(commands.DryRunFlag) + cv.conflictResolution = ctx.GetStringFlagValue(commands.ConflictResolutionFlag) outputFormat, err := ctx.GetOutputFormat() if err != nil { @@ -122,6 +124,7 @@ Common patterns: $ jf apptrust version-create my-app 1.0.0 --source-type-packages="type=docker, name=my-image, version=1.0.0, repo-key=docker-local" $ jf apptrust version-create my-app 1.0.0 --spec=version-spec.json --spec-vars="BUILD=42" $ jf apptrust version-create my-app 1.0.0 --source-type-builds="name=b, id=1" --draft --dry-run + $ jf apptrust version-create my-app 1.0.0 --source-type-builds="name=b, id=1" --conflict-resolution=automatic Gotchas: - The version should follow SemVer convention (e.g. 1.0.0, 1.2.3-rc1); the CLI does not validate the format, but the platform may reject non-conforming values. diff --git a/apptrust/commands/version/create_app_version_cmd_test.go b/apptrust/commands/version/create_app_version_cmd_test.go index c6366b0..8ab84e0 100644 --- a/apptrust/commands/version/create_app_version_cmd_test.go +++ b/apptrust/commands/version/create_app_version_cmd_test.go @@ -76,10 +76,10 @@ func TestCreateAppVersionCommand(t *testing.T) { mockVersionService := mockversions.NewMockVersionService(ctrl) if tt.shouldError { - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), tt.request, true, tt.dryRun). + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), tt.request, true, tt.dryRun, ""). Return(nil, errors.New(tt.errorMessage)).Times(1) } else { - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), tt.request, true, tt.dryRun). + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), tt.request, true, tt.dryRun, ""). Return(nil, nil).Times(1) } @@ -234,8 +234,8 @@ func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { var actualPayload *model.CreateAppVersionRequest mockVersionService := mockversions.NewMockVersionService(ctrl) if !tt.expectsError { - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest, _ bool, _ bool) ([]byte, error) { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest, _ bool, _ bool, _ string) ([]byte, error) { actualPayload = req return nil, nil }).Times(1) @@ -845,8 +845,8 @@ func TestCreateAppVersionCommand_SpecFileSuite(t *testing.T) { var capturedSync bool mockVersionService := mockversions.NewMockVersionService(ctrl) if !tt.expectsError { - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest, sync, dryRun bool) ([]byte, error) { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest, sync, dryRun bool, _ string) ([]byte, error) { actualPayload = req capturedSync = sync return nil, nil @@ -914,8 +914,8 @@ func TestCreateAppVersionCommand_SyncFlagSuite(t *testing.T) { var capturedSync bool mockVersionService := mockversions.NewMockVersionService(ctrl) - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest, sync, dryRun bool) ([]byte, error) { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest, sync, dryRun bool, _ string) ([]byte, error) { capturedSync = sync return nil, nil }).Times(1) diff --git a/apptrust/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index 4cd178b..6ea3cf6 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -42,18 +42,18 @@ func (m *MockVersionService) EXPECT() *MockVersionServiceMockRecorder { } // CreateAppVersion mocks base method. -func (m *MockVersionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest, sync, dryRun bool) ([]byte, error) { +func (m *MockVersionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest, sync, dryRun bool, conflictResolution string) ([]byte, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAppVersion", ctx, request, sync, dryRun) + ret := m.ctrl.Call(m, "CreateAppVersion", ctx, request, sync, dryRun, conflictResolution) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateAppVersion indicates an expected call of CreateAppVersion. -func (mr *MockVersionServiceMockRecorder) CreateAppVersion(ctx, request, sync, dryRun any) *gomock.Call { +func (mr *MockVersionServiceMockRecorder) CreateAppVersion(ctx, request, sync, dryRun, conflictResolution any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersion", reflect.TypeOf((*MockVersionService)(nil).CreateAppVersion), ctx, request, sync, dryRun) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersion", reflect.TypeOf((*MockVersionService)(nil).CreateAppVersion), ctx, request, sync, dryRun, conflictResolution) } // DeleteAppVersion mocks base method. diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 298544f..bbe15a2 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -15,7 +15,7 @@ import ( ) type VersionService interface { - CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest, sync, dryRun bool) ([]byte, error) + CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest, sync, dryRun bool, conflictResolution string) ([]byte, error) PromoteAppVersion(ctx service.Context, applicationKey string, version string, payload *model.PromoteAppVersionRequest, sync bool) ([]byte, error) ReleaseAppVersion(ctx service.Context, applicationKey string, version string, request *model.ReleaseAppVersionRequest, sync bool) ([]byte, error) RollbackAppVersion(ctx service.Context, applicationKey string, version string, request *model.RollbackAppVersionRequest, sync bool) ([]byte, error) @@ -30,10 +30,16 @@ func NewVersionService() VersionService { return &versionService{} } -func (vs *versionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest, sync, dryRun bool) ([]byte, error) { +func (vs *versionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest, sync, dryRun bool, conflictResolution string) ([]byte, error) { endpoint := fmt.Sprintf("/v1/applications/%s/versions/", request.ApplicationKey) - response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, - map[string]string{"async": strconv.FormatBool(!sync), "dry_run": strconv.FormatBool(dryRun)}) + params := map[string]string{ + "async": strconv.FormatBool(!sync), + "dry_run": strconv.FormatBool(dryRun), + } + if conflictResolution != "" { + params["conflict_resolution"] = conflictResolution + } + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, params) if err != nil { return nil, err } diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index a4b1fb7..8d12428 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -101,7 +101,7 @@ func TestCreateAppVersion(t *testing.T) { mockCtx := mockservice.NewMockContext(ctrl) mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) - _, err := service.CreateAppVersion(mockCtx, tt.request, tt.sync, tt.dryRun) + _, err := service.CreateAppVersion(mockCtx, tt.request, tt.sync, tt.dryRun, "") if tt.expectedError == "" { assert.NoError(t, err) } else { @@ -112,6 +112,50 @@ func TestCreateAppVersion(t *testing.T) { } } +func TestCreateAppVersionWithConflictResolution(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + + tests := []struct { + name string + conflictResolution string + expectedParams map[string]string + }{ + { + name: "automatic", + conflictResolution: "automatic", + expectedParams: map[string]string{"async": "false", "dry_run": "false", "conflict_resolution": "automatic"}, + }, + { + name: "manual", + conflictResolution: "manual", + expectedParams: map[string]string{"async": "false", "dry_run": "false", "conflict_resolution": "manual"}, + }, + { + name: "empty (omitted)", + conflictResolution: "", + expectedParams: map[string]string{"async": "false", "dry_run": "false"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &model.CreateAppVersionRequest{ApplicationKey: "test-app", Version: "1.0.0"} + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post("/v1/applications/test-app/versions/", request, tt.expectedParams). + Return(&http.Response{StatusCode: 201}, []byte("{}"), nil).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + _, err := service.CreateAppVersion(mockCtx, request, true, false, tt.conflictResolution) + assert.NoError(t, err) + }) + } +} + func TestPromoteAppVersion(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/e2e/version_test.go b/e2e/version_test.go index 4fd1f25..d7742cb 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -282,6 +282,62 @@ func TestCreateVersion_Async(t *testing.T) { assert.Contains(t, []string{utils.StatusInProgress, utils.StatusStarted}, response.Status) } +func TestCreateVersion_ConflictResolution_Automatic(t *testing.T) { + t.Skip("Skipping: conflict_resolution support requires a platform version not yet released") + appKey := utils.GenerateUniqueKey("app-cr-auto") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.0" + + packageFlag := fmt.Sprintf("--source-type-packages=type=%s, name=%s, version=%s, repo-key=%s", + testPackage.PackageType, testPackage.PackageName, testPackage.PackageVersion, testPackage.RepoKey) + err := utils.AppTrustCli.Exec("version-create", appKey, version, packageFlag, "--conflict-resolution=automatic") + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + + versionContent, statusCode, err := utils.GetApplicationVersion(appKey, version) + require.NoError(t, err) + assertVersionContent(t, testPackage, versionContent, statusCode, appKey, version) +} + +func TestCreateVersion_ConflictResolution_Manual(t *testing.T) { + t.Skip("Skipping: conflict_resolution support requires a platform version not yet released") + appKey := utils.GenerateUniqueKey("app-cr-manual") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.0" + + packageFlag := fmt.Sprintf("--source-type-packages=type=%s, name=%s, version=%s, repo-key=%s", + testPackage.PackageType, testPackage.PackageName, testPackage.PackageVersion, testPackage.RepoKey) + err := utils.AppTrustCli.Exec("version-create", appKey, version, packageFlag, "--conflict-resolution=manual") + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + + versionContent, statusCode, err := utils.GetApplicationVersion(appKey, version) + require.NoError(t, err) + assertVersionContent(t, testPackage, versionContent, statusCode, appKey, version) +} + +func TestCreateVersion_ConflictResolution_Invalid(t *testing.T) { + t.Skip("Skipping: conflict_resolution support requires a platform version not yet released") + appKey := utils.GenerateUniqueKey("app-cr-invalid") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.0" + + packageFlag := fmt.Sprintf("--source-type-packages=type=%s, name=%s, version=%s, repo-key=%s", + testPackage.PackageType, testPackage.PackageName, testPackage.PackageVersion, testPackage.RepoKey) + err := utils.AppTrustCli.Exec("version-create", appKey, version, packageFlag, "--conflict-resolution=bogus") + assert.Error(t, err) + assert.Contains(t, err.Error(), "400") +} + func assertVersionContent(t *testing.T, expectedPackage *utils.TestPackageResources, versionContent *utils.VersionContentResponse, statusCode int, appKey, appVersion string) { assert.Equal(t, http.StatusOK, statusCode) require.NotNil(t, versionContent)