diff --git a/.gitignore b/.gitignore index 0908a7b..c12055f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ *.iml bin .tools + +# Test reports +e2e-tests-report.xml diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index a5fdaa1..0bccd25 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -61,6 +61,7 @@ const ( DeletePropertiesFlag = "delete-properties" IncludeFilterFlag = "include-filter" ExcludeFilterFlag = "exclude-filter" + PathMappingFlag = "path-mapping" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -103,6 +104,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 }), + 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 }), } var commandFlags = map[string][]string{ @@ -137,6 +139,7 @@ var commandFlags = map[string][]string{ IncludeReposFlag, PropsFlag, OverwriteStrategyFlag, + PathMappingFlag, }, VersionRelease: { url, @@ -149,6 +152,7 @@ var commandFlags = map[string][]string{ IncludeReposFlag, PropsFlag, OverwriteStrategyFlag, + PathMappingFlag, }, VersionDelete: { url, diff --git a/apptrust/commands/version/promote_app_version_cmd.go b/apptrust/commands/version/promote_app_version_cmd.go index 9353f77..a8fee52 100644 --- a/apptrust/commands/version/promote_app_version_cmd.go +++ b/apptrust/commands/version/promote_app_version_cmd.go @@ -109,6 +109,11 @@ func (pv *promoteAppVersionCommand) buildRequestPayload(ctx *components.Context) return nil, err } + modifications, err := ParsePathMappings(ctx) + if err != nil { + return nil, err + } + return &model.PromoteAppVersionRequest{ Stage: stage, CommonPromoteAppVersion: model.CommonPromoteAppVersion{ @@ -117,6 +122,7 @@ func (pv *promoteAppVersionCommand) buildRequestPayload(ctx *components.Context) ExcludedRepositoryKeys: excludedRepos, ArtifactAdditionalProperties: artifactProps, OverwriteStrategy: overwriteStrategy, + Modifications: modifications, }, }, nil } diff --git a/apptrust/commands/version/release_app_version_cmd.go b/apptrust/commands/version/release_app_version_cmd.go index edf267e..22a36cd 100644 --- a/apptrust/commands/version/release_app_version_cmd.go +++ b/apptrust/commands/version/release_app_version_cmd.go @@ -106,12 +106,18 @@ func (rv *releaseAppVersionCommand) buildRequestPayload(ctx *components.Context) return nil, err } + modifications, err := ParsePathMappings(ctx) + if err != nil { + return nil, err + } + return model.NewReleaseAppVersionRequest( promotionType, includedRepos, excludedRepos, artifactProps, overwriteStrategy, + modifications, ), nil } diff --git a/apptrust/commands/version/release_app_version_cmd_test.go b/apptrust/commands/version/release_app_version_cmd_test.go index 87f25bd..05247f9 100644 --- a/apptrust/commands/version/release_app_version_cmd_test.go +++ b/apptrust/commands/version/release_app_version_cmd_test.go @@ -57,6 +57,7 @@ func TestReleaseAppVersionCommand_Run(t *testing.T) { nil, // excludedRepos nil, // artifactProps tt.overwriteStrategy, + nil, // modifications ) mockVersionService := mockversions.NewMockVersionService(ctrl) @@ -91,6 +92,7 @@ func TestReleaseAppVersionCommand_Run_Error(t *testing.T) { nil, // excludedRepos nil, // artifactProps "", // overwriteStrategy + nil, // modifications ) expectedError := errors.New("service error occurred") diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 359d25e..48dd737 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -75,3 +75,55 @@ func ParseOverwriteStrategy(ctx *components.Context) (string, error) { // Convert to uppercase for API request return strings.ToUpper(validatedStrategy), nil } + +// ParsePathMappings extracts path mapping rules from the --path-mapping flag. +// Format: "input=(.*), output=stable-release/$1[, package-type=.*]; input=(...), output=..." +// Returns nil if flag is not provided. +func ParsePathMappings(ctx *components.Context) (*model.PromotionModifications, error) { + const ( + inputField = "input" + outputField = "output" + packageTypeField = "package-type" + ) + + flagValue := ctx.GetStringFlagValue(commands.PathMappingFlag) + if flagValue == "" { + return nil, nil + } + + entries := utils.ParseSliceFlag(flagValue) + var mappings []model.PromotionPathMapping + + for i, entry := range entries { + if entry == "" { + return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.PathMappingFlag, i+1) + } + + entryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("--%s entry %d: %s", commands.PathMappingFlag, i+1, err.Error()) + } + + input, hasInput := entryMap[inputField] + output, hasOutput := entryMap[outputField] + + if !hasInput || input == "" { + return nil, errorutils.CheckErrorf("--%s entry %d: '%s' is required", commands.PathMappingFlag, i+1, inputField) + } + if !hasOutput || output == "" { + return nil, errorutils.CheckErrorf("--%s entry %d: '%s' is required", commands.PathMappingFlag, i+1, outputField) + } + + mapping := model.PromotionPathMapping{ + Input: input, + Output: output, + } + if pt, ok := entryMap[packageTypeField]; ok { + mapping.PackageType = pt + } + + mappings = append(mappings, mapping) + } + + return &model.PromotionModifications{Mappings: mappings}, nil +} diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 3db8a85..e21eb7b 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -7,6 +7,7 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseOverwriteStrategy(t *testing.T) { @@ -162,3 +163,100 @@ func TestBuildPromotionParams(t *testing.T) { }) } } + +func TestParsePathMappings(t *testing.T) { + tests := []struct { + name string + flagValue string + expected *model.PromotionModifications + expectError bool + errContains string + }{ + { + name: "no flag - returns nil", + expected: nil, + }, + { + name: "single mapping without package type", + flagValue: "input=(.*), output=stable-release/$1", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {Input: "(.*)", Output: "stable-release/$1"}, + }, + }, + }, + { + name: "single mapping with package type", + flagValue: "input=(.*), output=stable-release/$1, package-type=.*", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {PackageType: ".*", Input: "(.*)", Output: "stable-release/$1"}, + }, + }, + }, + { + name: "multiple mappings", + flagValue: "input=(.*), output=release/$1, package-type=.*; input=(.*\\.jar), output=jars/$1, package-type=maven", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {PackageType: ".*", Input: "(.*)", Output: "release/$1"}, + {PackageType: "maven", Input: "(.*\\.jar)", Output: "jars/$1"}, + }, + }, + }, + { + name: "mapping without package-type field", + flagValue: "input=(.*), output=release/$1; input=(.*\\.jar), output=jars/$1", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {Input: "(.*)", Output: "release/$1"}, + {Input: "(.*\\.jar)", Output: "jars/$1"}, + }, + }, + }, + { + name: "missing input field - error", + flagValue: "output=target/$1", + expectError: true, + errContains: "'input' is required", + }, + { + name: "missing output field - error", + flagValue: "input=(.*)", + expectError: true, + errContains: "'output' is required", + }, + { + name: "empty entry from trailing semicolon - error", + flagValue: "input=(.*), output=release/$1;", + expectError: true, + errContains: "entry 2 is empty", + }, + { + name: "invalid key-value format - error", + flagValue: "not-a-valid-format", + expectError: true, + errContains: "entry 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + if tt.flagValue != "" { + ctx.AddStringFlag(commands.PathMappingFlag, tt.flagValue) + } + + result, err := ParsePathMappings(ctx) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/apptrust/model/promote_app_version_request.go b/apptrust/model/promote_app_version_request.go index 08b0abe..7feca1c 100644 --- a/apptrust/model/promote_app_version_request.go +++ b/apptrust/model/promote_app_version_request.go @@ -28,12 +28,23 @@ var OverwriteStrategyValues = []string{ OverwriteStrategyAll, } +type PromotionPathMapping struct { + PackageType string `json:"package_type,omitempty"` + Input string `json:"input"` + Output string `json:"output"` +} + +type PromotionModifications struct { + Mappings []PromotionPathMapping `json:"mappings"` +} + type CommonPromoteAppVersion struct { - PromotionType string `json:"promotion_type,omitempty"` - IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` - ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` - ArtifactAdditionalProperties []ArtifactProperty `json:"artifact_additional_properties,omitempty"` - OverwriteStrategy string `json:"overwrite_strategy,omitempty"` + PromotionType string `json:"promotion_type,omitempty"` + IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` + ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` + ArtifactAdditionalProperties []ArtifactProperty `json:"artifact_additional_properties,omitempty"` + OverwriteStrategy string `json:"overwrite_strategy,omitempty"` + Modifications *PromotionModifications `json:"modifications,omitempty"` } type ArtifactProperty struct { diff --git a/apptrust/model/release_app_version_request.go b/apptrust/model/release_app_version_request.go index 92a2679..19e62e6 100644 --- a/apptrust/model/release_app_version_request.go +++ b/apptrust/model/release_app_version_request.go @@ -15,6 +15,7 @@ func NewReleaseAppVersionRequest( excludedRepositoryKeys []string, artifactProperties []ArtifactProperty, overwriteStrategy string, + modifications *PromotionModifications, ) *ReleaseAppVersionRequest { return &ReleaseAppVersionRequest{ CommonPromoteAppVersion: CommonPromoteAppVersion{ @@ -23,6 +24,7 @@ func NewReleaseAppVersionRequest( ExcludedRepositoryKeys: excludedRepositoryKeys, ArtifactAdditionalProperties: artifactProperties, OverwriteStrategy: overwriteStrategy, + Modifications: modifications, }, } } diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 634e69a..a4b1fb7 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -252,6 +252,7 @@ func TestReleaseAppVersion(t *testing.T) { []string{"repo3"}, []model.ArtifactProperty{{Key: "key1", Values: []string{"value1"}}}, "", + nil, ), sync: true, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", @@ -270,6 +271,7 @@ func TestReleaseAppVersion(t *testing.T) { []string{"repo3"}, []model.ArtifactProperty{{Key: "key1", Values: []string{"value1"}}}, "", + nil, ), sync: false, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", @@ -288,6 +290,7 @@ func TestReleaseAppVersion(t *testing.T) { nil, nil, "", + nil, ), sync: true, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", @@ -306,6 +309,7 @@ func TestReleaseAppVersion(t *testing.T) { nil, nil, "", + nil, ), sync: false, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", diff --git a/e2e/version_test.go b/e2e/version_test.go index b37555f..a2eaa3c 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -375,6 +375,60 @@ func TestPromoteVersion(t *testing.T) { assert.Equal(t, targetStage, versionContent.CurrentStage) } +func TestPromoteVersion_WithPathMappings(t *testing.T) { + t.Skip("Skipping until path-mapping backend is deployed") + // Prepare + appKey := utils.GenerateUniqueKey("app-promote-mapping") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.60" + + 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) + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + + // Execute - promote with path mapping flags + targetStage := "DEV" + err = utils.AppTrustCli.Exec("version-promote", appKey, version, targetStage, + `--path-mapping=input=(.*), output=promoted/$1, package-type=.*`) + require.NoError(t, err) + + // Assert - promotion succeeded with mappings applied + versionContent, statusCode, err := utils.GetApplicationVersion(appKey, version) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + require.NotNil(t, versionContent) + assert.Equal(t, appKey, versionContent.ApplicationKey) + assert.Equal(t, version, versionContent.Version) + assert.Equal(t, targetStage, versionContent.CurrentStage) +} + +func TestPromoteVersion_WithPathMappings_InvalidRegex(t *testing.T) { + t.Skip("Skipping until path-mapping backend is deployed") + // Prepare + appKey := utils.GenerateUniqueKey("app-promote-map-err") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.61" + + 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) + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + + targetStage := "DEV" + err = utils.AppTrustCli.Exec("version-promote", appKey, version, targetStage, + `--path-mapping=input=[unclosed, output=target/$1`) + assert.Error(t, err) +} + func TestReleaseVersion(t *testing.T) { // Prepare appKey := utils.GenerateUniqueKey("app-version-release")