From b939cc29da83c93481e76a7db7b350bbacecb6df Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Sun, 5 Jul 2026 14:05:16 +0300 Subject: [PATCH 01/22] Distribute and remotely delete application versions --- apptrust/commands/flags.go | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index a76a754..38b5fbe 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -16,6 +16,8 @@ const ( VersionRelease = "version-release" VersionUpdate = "version-update" VersionUpdateSources = "version-update-sources" + VersionDistribute = "version-distribute" + VersionRemoteDelete = "version-delete-remote" PackageBind = "package-bind" PackageUnbind = "package-unbind" AppCreate = "app-create" @@ -64,6 +66,15 @@ const ( ExcludeFilterFlag = "exclude-filter" ConflictResolutionFlag = "conflict-resolution" PathMappingFlag = "path-mapping" + DistRulesFlag = "dist-rules" + SiteFlag = "site" + CityFlag = "city" + CountryCodesFlag = "country-codes" + CreateRepoFlag = "create-repo" + MappingPatternFlag = "mapping-pattern" + MappingTargetFlag = "mapping-target" + MaxWaitMinutesFlag = "max-wait-minutes" + QuietFlag = "quiet" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -109,6 +120,15 @@ var flagsMap = map[string]components.Flag{ 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: "+coreutils.ListToText(model.ConflictResolutionValues)+".", 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 }), + DistRulesFlag: components.NewStringFlag(DistRulesFlag, "Path to distribution rules.", func(f *components.StringFlag) { f.Mandatory = false }), + SiteFlag: components.NewStringFlag(SiteFlag, "Wildcard filter for site name.", func(f *components.StringFlag) { f.Mandatory = false }), + CityFlag: components.NewStringFlag(CityFlag, "Wildcard filter for site city name.", func(f *components.StringFlag) { f.Mandatory = false }), + CountryCodesFlag: components.NewStringFlag(CountryCodesFlag, "List of semicolon-separated (;) wildcard filters for site country codes.", func(f *components.StringFlag) { f.Mandatory = false }), + CreateRepoFlag: components.NewBoolFlag(CreateRepoFlag, "Set to true to create the repository on the edge if it does not exist.", components.WithBoolDefaultValueFalse()), + MappingPatternFlag: components.NewStringFlag(MappingPatternFlag, "Specify along with "+MappingTargetFlag+" to distribute artifacts to a different path on the edge node. You can use wildcards to specify multiple artifacts.", func(f *components.StringFlag) { f.Mandatory = false }), + MappingTargetFlag: components.NewStringFlag(MappingTargetFlag, "The target path for distributed artifacts on the edge node. If not specified, the artifacts will have the same path and name on the edge node, as on the source Artifactory server. For flexibility in specifying the distribution path, you can include placeholders in the form of {1}, {2} which are replaced by corresponding tokens in the pattern path that are enclosed in parenthesis.", func(f *components.StringFlag) { f.Mandatory = false }), + MaxWaitMinutesFlag: components.NewStringFlag(MaxWaitMinutesFlag, "Max minutes to wait for sync distribution.", func(f *components.StringFlag) { f.Mandatory = false }), + QuietFlag: components.NewBoolFlag(QuietFlag, "Set to true to skip the confirmation message.", components.WithBoolDefaultValueFalse()), } var commandFlags = map[string][]string{ @@ -201,6 +221,37 @@ var commandFlags = map[string][]string{ ExcludeFilterFlag, }, + VersionDistribute: { + url, + user, + accessToken, + serverId, + DistRulesFlag, + SiteFlag, + CityFlag, + CountryCodesFlag, + DryRunFlag, + CreateRepoFlag, + MappingPatternFlag, + MappingTargetFlag, + SyncFlag, + MaxWaitMinutesFlag, + }, + VersionRemoteDelete: { + url, + user, + accessToken, + serverId, + QuietFlag, + DryRunFlag, + DistRulesFlag, + SiteFlag, + CityFlag, + CountryCodesFlag, + SyncFlag, + MaxWaitMinutesFlag, + }, + PackageBind: { url, user, From 9edd56270a87bb32398735c9724cc5caa3bb35c0 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Sun, 5 Jul 2026 15:52:10 +0300 Subject: [PATCH 02/22] Distribute and remotely delete application versions --- apptrust/commands/version/version_utils.go | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 76ec564..1078755 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -137,3 +137,68 @@ func ParsePathMappings(ctx *components.Context) (*model.PromotionModifications, return &model.PromotionModifications{Mappings: mappings}, nil } + +func ValidateDistributionFlags(ctx *components.Context) error { + if ctx.IsFlagSet(commands.DistRulesFlag) && + (ctx.IsFlagSet(commands.SiteFlag) || ctx.IsFlagSet(commands.CityFlag) || ctx.IsFlagSet(commands.CountryCodesFlag)) { + return errorutils.CheckErrorf("the --%s option can't be used with --%s, --%s or --%s", + commands.DistRulesFlag, commands.SiteFlag, commands.CityFlag, commands.CountryCodesFlag) + } + + if ctx.IsFlagSet(commands.MaxWaitMinutesFlag) && !ctx.IsFlagSet(commands.SyncFlag) { + return errorutils.CheckErrorf("the --%s option can't be used without --%s", + commands.MaxWaitMinutesFlag, commands.SyncFlag) + } + + return nil +} + +// BuildDistributionRules builds the distribution rules for a distribution operation. +// When --dist-rules is provided, the rules are read from the given file. Otherwise a +// single rule is built from the --site/--city/--country-codes flags. +func BuildDistributionRules(ctx *components.Context) ([]*distribution.DistributionCommonParams, error) { + var distributionRules *spec.DistributionRules + if ctx.IsFlagSet(commands.DistRulesFlag) { + var err error + distributionRules, err = spec.CreateDistributionRulesFromFile(ctx.GetStringFlagValue(commands.DistRulesFlag)) + if err != nil { + return nil, err + } + } else { + distributionRules = &spec.DistributionRules{ + DistributionRules: []spec.DistributionRule{{ + SiteName: ctx.GetStringFlagValue(commands.SiteFlag), + CityName: ctx.GetStringFlagValue(commands.CityFlag), + CountryCodes: ctx.GetStringsArrFlagValue(commands.CountryCodesFlag), + }}, + } + } + + commonParams := make([]*distribution.DistributionCommonParams, 0, len(distributionRules.DistributionRules)) + for i := range distributionRules.DistributionRules { + commonParams = append(commonParams, distributionRules.DistributionRules[i].ToDistributionCommonParams()) + } + return commonParams, nil +} + +// ParseDistributeModifications builds the path-mapping modifications for the +// version-distribute command from the --mapping-pattern/--mapping-target flags. +// Both flags must be provided together; if neither is provided, empty +// modifications are returned. +func ParseDistributeModifications(ctx *components.Context) (lifecycleServices.Modifications, error) { + pattern := ctx.GetStringFlagValue(commands.MappingPatternFlag) + target := ctx.GetStringFlagValue(commands.MappingTargetFlag) + + if pattern == "" && target == "" { + return lifecycleServices.Modifications{}, nil + } + if pattern == "" || target == "" { + return lifecycleServices.Modifications{}, errorutils.CheckErrorf( + "the --%s and --%s options must be provided together", + commands.MappingPatternFlag, commands.MappingTargetFlag) + } + + return lifecycleServices.Modifications{ + PathMappings: distribution.CreatePathMappingsFromPatternAndTarget(pattern, target), + }, nil +} From 2c88c20d9bcc1b5489e9a77f25da483623110014 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Sun, 5 Jul 2026 16:01:15 +0300 Subject: [PATCH 03/22] Distribute and remotely delete application versions --- .../commands/version/version_utils_test.go | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 275186e..156f4ea 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -314,3 +314,196 @@ func TestParsePathMappings(t *testing.T) { }) } } + +func TestValidateDistributionFlags(t *testing.T) { + tests := []struct { + name string + distRules bool + site bool + city bool + country bool + maxWait bool + sync bool + expectError bool + }{ + { + name: "no flags", + }, + { + name: "dist-rules only", + distRules: true, + }, + { + name: "site/city/country only", + site: true, + city: true, + country: true, + }, + { + name: "dist-rules with site", + distRules: true, + site: true, + expectError: true, + }, + { + name: "dist-rules with city", + distRules: true, + city: true, + expectError: true, + }, + { + name: "dist-rules with country-codes", + distRules: true, + country: true, + expectError: true, + }, + { + name: "max-wait-minutes without sync", + maxWait: true, + expectError: true, + }, + { + name: "max-wait-minutes with sync", + maxWait: true, + sync: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + if tt.distRules { + ctx.AddStringFlag(commands.DistRulesFlag, "rules.json") + } + if tt.site { + ctx.AddStringFlag(commands.SiteFlag, "edge-*") + } + if tt.city { + ctx.AddStringFlag(commands.CityFlag, "NYC") + } + if tt.country { + ctx.AddStringFlag(commands.CountryCodesFlag, "US") + } + if tt.maxWait { + ctx.AddStringFlag(commands.MaxWaitMinutesFlag, "60") + } + if tt.sync { + ctx.AddBoolFlag(commands.SyncFlag, true) + } + + err := ValidateDistributionFlags(ctx) + + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestBuildDistributionRules(t *testing.T) { + t.Run("from site/city/country flags", func(t *testing.T) { + ctx := &components.Context{} + ctx.AddStringFlag(commands.SiteFlag, "edge-*") + ctx.AddStringFlag(commands.CityFlag, "NYC") + ctx.AddStringFlag(commands.CountryCodesFlag, "US;CA") + + rules, err := BuildDistributionRules(ctx) + require.NoError(t, err) + require.Len(t, rules, 1) + assert.Equal(t, "edge-*", rules[0].SiteName) + assert.Equal(t, "NYC", rules[0].CityName) + assert.Equal(t, []string{"US", "CA"}, rules[0].CountryCodes) + }) + + t.Run("no flags returns a single empty rule", func(t *testing.T) { + ctx := &components.Context{} + + rules, err := BuildDistributionRules(ctx) + require.NoError(t, err) + require.Len(t, rules, 1) + assert.Equal(t, "", rules[0].SiteName) + assert.Equal(t, "", rules[0].CityName) + assert.Empty(t, rules[0].CountryCodes) + }) + + t.Run("from dist-rules file", func(t *testing.T) { + content := `{"distribution_rules":[{"site_name":"site-1","city_name":"city-1","country_codes":["US"]},{"site_name":"site-2"}]}` + filePath := filepath.Join(t.TempDir(), "dist-rules.json") + require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + + ctx := &components.Context{} + ctx.AddStringFlag(commands.DistRulesFlag, filePath) + + rules, err := BuildDistributionRules(ctx) + require.NoError(t, err) + require.Len(t, rules, 2) + assert.Equal(t, "site-1", rules[0].SiteName) + assert.Equal(t, "city-1", rules[0].CityName) + assert.Equal(t, []string{"US"}, rules[0].CountryCodes) + assert.Equal(t, "site-2", rules[1].SiteName) + }) + + t.Run("missing dist-rules file returns error", func(t *testing.T) { + ctx := &components.Context{} + ctx.AddStringFlag(commands.DistRulesFlag, filepath.Join(t.TempDir(), "does-not-exist.json")) + + _, err := BuildDistributionRules(ctx) + assert.Error(t, err) + }) +} + +func TestParseDistributeModifications(t *testing.T) { + tests := []struct { + name string + pattern string + target string + expected []artUtils.PathMapping + expectError bool + }{ + { + name: "no mapping flags", + expected: nil, + }, + { + name: "pattern and target provided", + pattern: "my-repo/(*)", + target: "edge/{1}", + expected: []artUtils.PathMapping{ + {Input: "^my-repo/(.*)$", Output: "edge/$1"}, + }, + }, + { + name: "only pattern provided", + pattern: "my-repo/(*)", + expectError: true, + }, + { + name: "only target provided", + target: "edge/{1}", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + if tt.pattern != "" { + ctx.AddStringFlag(commands.MappingPatternFlag, tt.pattern) + } + if tt.target != "" { + ctx.AddStringFlag(commands.MappingTargetFlag, tt.target) + } + + result, err := ParseDistributeModifications(ctx) + + if tt.expectError { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, result.PathMappings) + }) + } +} From 8d75c98b00a03c4785bb588e4f21e103e679f411 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Sun, 5 Jul 2026 16:21:07 +0300 Subject: [PATCH 04/22] Distribute and remotely delete application versions --- apptrust/commands/version/version_utils.go | 5 ++-- .../commands/version/version_utils_test.go | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 1078755..18a019d 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -6,7 +6,9 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/commands" "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-core/v2/common/spec" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/distribution" "github.com/jfrog/jfrog-client-go/utils/errorutils" ) @@ -153,9 +155,6 @@ func ValidateDistributionFlags(ctx *components.Context) error { return nil } -// BuildDistributionRules builds the distribution rules for a distribution operation. -// When --dist-rules is provided, the rules are read from the given file. Otherwise a -// single rule is built from the --site/--city/--country-codes flags. func BuildDistributionRules(ctx *components.Context) ([]*distribution.DistributionCommonParams, error) { var distributionRules *spec.DistributionRules if ctx.IsFlagSet(commands.DistRulesFlag) { diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 156f4ea..296de3b 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -1,11 +1,14 @@ package version import ( + "os" + "path/filepath" "testing" "github.com/jfrog/jfrog-cli-application/apptrust/commands" "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/distribution" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -411,10 +414,10 @@ func TestBuildDistributionRules(t *testing.T) { rules, err := BuildDistributionRules(ctx) require.NoError(t, err) - require.Len(t, rules, 1) - assert.Equal(t, "edge-*", rules[0].SiteName) - assert.Equal(t, "NYC", rules[0].CityName) - assert.Equal(t, []string{"US", "CA"}, rules[0].CountryCodes) + expected := []*distribution.DistributionCommonParams{ + {SiteName: "edge-*", CityName: "NYC", CountryCodes: []string{"US", "CA"}}, + } + assert.Equal(t, expected, rules) }) t.Run("no flags returns a single empty rule", func(t *testing.T) { @@ -422,10 +425,10 @@ func TestBuildDistributionRules(t *testing.T) { rules, err := BuildDistributionRules(ctx) require.NoError(t, err) - require.Len(t, rules, 1) - assert.Equal(t, "", rules[0].SiteName) - assert.Equal(t, "", rules[0].CityName) - assert.Empty(t, rules[0].CountryCodes) + expected := []*distribution.DistributionCommonParams{ + {}, + } + assert.Equal(t, expected, rules) }) t.Run("from dist-rules file", func(t *testing.T) { @@ -438,11 +441,11 @@ func TestBuildDistributionRules(t *testing.T) { rules, err := BuildDistributionRules(ctx) require.NoError(t, err) - require.Len(t, rules, 2) - assert.Equal(t, "site-1", rules[0].SiteName) - assert.Equal(t, "city-1", rules[0].CityName) - assert.Equal(t, []string{"US"}, rules[0].CountryCodes) - assert.Equal(t, "site-2", rules[1].SiteName) + expected := []*distribution.DistributionCommonParams{ + {SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}, + {SiteName: "site-2"}, + } + assert.Equal(t, expected, rules) }) t.Run("missing dist-rules file returns error", func(t *testing.T) { From 8e265e5da13256087132b395c33a888544ac8326 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Sun, 5 Jul 2026 16:30:46 +0300 Subject: [PATCH 05/22] Distribute and remotely delete application versions --- apptrust/commands/version/version_utils.go | 5 +---- apptrust/commands/version/version_utils_test.go | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 18a019d..287648f 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -8,6 +8,7 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/common/spec" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + lifecycleServices "github.com/jfrog/jfrog-client-go/lifecycle/services" "github.com/jfrog/jfrog-client-go/utils/distribution" "github.com/jfrog/jfrog-client-go/utils/errorutils" ) @@ -180,10 +181,6 @@ func BuildDistributionRules(ctx *components.Context) ([]*distribution.Distributi return commonParams, nil } -// ParseDistributeModifications builds the path-mapping modifications for the -// version-distribute command from the --mapping-pattern/--mapping-target flags. -// Both flags must be provided together; if neither is provided, empty -// modifications are returned. func ParseDistributeModifications(ctx *components.Context) (lifecycleServices.Modifications, error) { pattern := ctx.GetStringFlagValue(commands.MappingPatternFlag) target := ctx.GetStringFlagValue(commands.MappingTargetFlag) diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 296de3b..c9dfdbb 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -8,6 +8,7 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/commands" "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + artUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" "github.com/jfrog/jfrog-client-go/utils/distribution" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" From 7818e7a83b07366174f8b63a7945d2d76a664fe3 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 09:56:39 +0300 Subject: [PATCH 06/22] Distribute and remotely delete application versions --- .../model/distribute_app_version_request.go | 23 +++ .../remote_delete_app_version_request.go | 7 + .../versions/mocks/version_service_mock.go | 30 ++++ apptrust/service/versions/version_service.go | 32 ++++ .../service/versions/version_service_test.go | 140 ++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 apptrust/model/distribute_app_version_request.go create mode 100644 apptrust/model/remote_delete_app_version_request.go diff --git a/apptrust/model/distribute_app_version_request.go b/apptrust/model/distribute_app_version_request.go new file mode 100644 index 0000000..92af8e1 --- /dev/null +++ b/apptrust/model/distribute_app_version_request.go @@ -0,0 +1,23 @@ +package model + +type DistributionRule struct { + SiteName string `json:"site_name,omitempty"` + CityName string `json:"city_name,omitempty"` + CountryCodes []string `json:"country_codes,omitempty"` +} + +type DistributionPathMapping struct { + Input string `json:"input"` + Output string `json:"output"` +} + +type DistributionModifications struct { + PathMappings []DistributionPathMapping `json:"mappings"` +} + +type DistributeAppVersionRequest struct { + DryRun bool `json:"dry_run"` + DistributionRules []DistributionRule `json:"distribution_rules"` + AutoCreateRepo bool `json:"auto_create_missing_repositories,omitempty"` + Modifications DistributionModifications `json:"modifications"` +} diff --git a/apptrust/model/remote_delete_app_version_request.go b/apptrust/model/remote_delete_app_version_request.go new file mode 100644 index 0000000..6314ca3 --- /dev/null +++ b/apptrust/model/remote_delete_app_version_request.go @@ -0,0 +1,7 @@ +package model + +type RemoteDeleteAppVersionRequest struct { + DryRun bool `json:"dry_run"` + DistributionRules []DistributionRule `json:"distribution_rules"` + AutoCreateRepo bool `json:"auto_create_missing_repositories,omitempty"` +} diff --git a/apptrust/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index 6ea3cf6..0341560 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -70,6 +70,21 @@ func (mr *MockVersionServiceMockRecorder) DeleteAppVersion(ctx, applicationKey, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAppVersion", reflect.TypeOf((*MockVersionService)(nil).DeleteAppVersion), ctx, applicationKey, version) } +// DistributeAppVersion mocks base method. +func (m *MockVersionService) DistributeAppVersion(ctx service.Context, applicationKey, version string, request *model.DistributeAppVersionRequest) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DistributeAppVersion", ctx, applicationKey, version, request) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DistributeAppVersion indicates an expected call of DistributeAppVersion. +func (mr *MockVersionServiceMockRecorder) DistributeAppVersion(ctx, applicationKey, version, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DistributeAppVersion", reflect.TypeOf((*MockVersionService)(nil).DistributeAppVersion), ctx, applicationKey, version, request) +} + // PromoteAppVersion mocks base method. func (m *MockVersionService) PromoteAppVersion(ctx service.Context, applicationKey, version string, payload *model.PromoteAppVersionRequest, sync bool) ([]byte, error) { m.ctrl.T.Helper() @@ -100,6 +115,21 @@ func (mr *MockVersionServiceMockRecorder) ReleaseAppVersion(ctx, applicationKey, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReleaseAppVersion", reflect.TypeOf((*MockVersionService)(nil).ReleaseAppVersion), ctx, applicationKey, version, request, sync) } +// RemoteDeleteAppVersion mocks base method. +func (m *MockVersionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoteDeleteAppVersion", ctx, applicationKey, version, request) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoteDeleteAppVersion indicates an expected call of RemoteDeleteAppVersion. +func (mr *MockVersionServiceMockRecorder) RemoteDeleteAppVersion(ctx, applicationKey, version, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteDeleteAppVersion", reflect.TypeOf((*MockVersionService)(nil).RemoteDeleteAppVersion), ctx, applicationKey, version, request) +} + // RollbackAppVersion mocks base method. func (m *MockVersionService) RollbackAppVersion(ctx service.Context, applicationKey, version string, request *model.RollbackAppVersionRequest, sync bool) ([]byte, error) { m.ctrl.T.Helper() diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index bbe15a2..3263f85 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -22,6 +22,8 @@ type VersionService interface { DeleteAppVersion(ctx service.Context, applicationKey string, version string) error UpdateAppVersion(ctx service.Context, applicationKey string, version string, request *model.UpdateAppVersionRequest) ([]byte, error) UpdateAppVersionSources(ctx service.Context, applicationKey string, version string, request *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) ([]byte, error) + DistributeAppVersion(ctx service.Context, applicationKey string, version string, request *model.DistributeAppVersionRequest) ([]byte, error) + RemoteDeleteAppVersion(ctx service.Context, applicationKey string, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) } type versionService struct{} @@ -158,6 +160,36 @@ func (vs *versionService) UpdateAppVersionSources(ctx service.Context, applicati return responseBody, nil } +func (vs *versionService) DistributeAppVersion(ctx service.Context, applicationKey, version string, request *model.DistributeAppVersionRequest) ([]byte, error) { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/distribute", applicationKey, version) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) + if err != nil { + return nil, err + } + + if !apphttp.IsSuccessStatusCode(response.StatusCode) { + return nil, fmt.Errorf("failed to distribute application version. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + return responseBody, nil +} + +func (vs *versionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/remote-delete", applicationKey, version) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) + if err != nil { + return nil, err + } + + if !apphttp.IsSuccessStatusCode(response.StatusCode) { + return nil, fmt.Errorf("failed to delete application version remotely. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + return responseBody, nil +} + func logSuccessMessage(sync bool, request *model.CreateAppVersionRequest, dryRun bool) { if !sync { log.Info(fmt.Sprintf("Application version creation initiated: %s:%s", request.ApplicationKey, request.Version)) diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 8d12428..1e57110 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -709,3 +709,143 @@ func TestUpdateAppVersionSources(t *testing.T) { }) } } + +func TestDistributeAppVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + + rules := []model.DistributionRule{{SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}} + mappings := []model.DistributionPathMapping{{Input: "source/(.*)", Output: "target/$1"}} + request := &model.DistributeAppVersionRequest{ + AutoCreateRepo: true, + DistributionRules: rules, + Modifications: model.DistributionModifications{ + PathMappings: mappings, + }, + } + + tests := []struct { + name string + applicationKey string + version string + mockResponse *http.Response + mockResponseBody string + mockError error + expectedError string + }{ + { + name: "success", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockResponseBody: "{}", + expectedError: "", + }, + { + name: "failure", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockResponseBody: "error", + expectedError: "failed to distribute application version", + }, + { + name: "http client error", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: nil, + mockError: errors.New("http client error"), + expectedError: "http client error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedEndpoint := "/v1/applications/" + tt.applicationKey + "/versions/" + tt.version + "/distribute" + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post(expectedEndpoint, request, nil). + Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + _, err := service.DistributeAppVersion(mockCtx, tt.applicationKey, tt.version, request) + if tt.expectedError == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} + +func TestRemoteDeleteAppVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + + rules := []model.DistributionRule{{SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}} + request := &model.RemoteDeleteAppVersionRequest{ + AutoCreateRepo: true, + DistributionRules: rules, + } + + tests := []struct { + name string + applicationKey string + version string + mockResponse *http.Response + mockResponseBody string + mockError error + expectedError string + }{ + { + name: "success", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusAccepted}, + mockResponseBody: "{}", + expectedError: "", + }, + { + name: "failure", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockResponseBody: "error", + expectedError: "failed to delete application version remotely", + }, + { + name: "http client error", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: nil, + mockError: errors.New("http client error"), + expectedError: "http client error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedEndpoint := "/v1/applications/" + tt.applicationKey + "/versions/" + tt.version + "/remote-delete" + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post(expectedEndpoint, request, nil). + Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + _, err := service.RemoteDeleteAppVersion(mockCtx, tt.applicationKey, tt.version, request) + if tt.expectedError == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} From 15331d9e966f8a4e162b7898d32e2f351e97b075 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 10:52:25 +0300 Subject: [PATCH 07/22] Distribute and remotely delete application versions --- apptrust/commands/version/version_utils.go | 57 ++++++++++--------- .../commands/version/version_utils_test.go | 12 ++-- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 287648f..ad003f6 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -8,7 +8,6 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/common/spec" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" - lifecycleServices "github.com/jfrog/jfrog-client-go/lifecycle/services" "github.com/jfrog/jfrog-client-go/utils/distribution" "github.com/jfrog/jfrog-client-go/utils/errorutils" ) @@ -156,45 +155,51 @@ func ValidateDistributionFlags(ctx *components.Context) error { return nil } -func BuildDistributionRules(ctx *components.Context) ([]*distribution.DistributionCommonParams, error) { - var distributionRules *spec.DistributionRules - if ctx.IsFlagSet(commands.DistRulesFlag) { - var err error - distributionRules, err = spec.CreateDistributionRulesFromFile(ctx.GetStringFlagValue(commands.DistRulesFlag)) - if err != nil { - return nil, err - } - } else { - distributionRules = &spec.DistributionRules{ - DistributionRules: []spec.DistributionRule{{ - SiteName: ctx.GetStringFlagValue(commands.SiteFlag), - CityName: ctx.GetStringFlagValue(commands.CityFlag), - CountryCodes: ctx.GetStringsArrFlagValue(commands.CountryCodesFlag), - }}, - } +func BuildDistributionRules(ctx *components.Context) ([]model.DistributionRule, error) { + if !ctx.IsFlagSet(commands.DistRulesFlag) { + return []model.DistributionRule{{ + SiteName: ctx.GetStringFlagValue(commands.SiteFlag), + CityName: ctx.GetStringFlagValue(commands.CityFlag), + CountryCodes: ctx.GetStringsArrFlagValue(commands.CountryCodesFlag), + }}, nil } - commonParams := make([]*distribution.DistributionCommonParams, 0, len(distributionRules.DistributionRules)) + distributionRules, err := spec.CreateDistributionRulesFromFile(ctx.GetStringFlagValue(commands.DistRulesFlag)) + if err != nil { + return nil, err + } + + modelRules := make([]model.DistributionRule, 0, len(distributionRules.DistributionRules)) for i := range distributionRules.DistributionRules { - commonParams = append(commonParams, distributionRules.DistributionRules[i].ToDistributionCommonParams()) + modelRules = append(modelRules, model.DistributionRule{ + SiteName: distributionRules.DistributionRules[i].SiteName, + CityName: distributionRules.DistributionRules[i].CityName, + CountryCodes: distributionRules.DistributionRules[i].CountryCodes, + }) } - return commonParams, nil + return modelRules, nil } -func ParseDistributeModifications(ctx *components.Context) (lifecycleServices.Modifications, error) { +func ParseDistributeModifications(ctx *components.Context) (model.DistributionModifications, error) { pattern := ctx.GetStringFlagValue(commands.MappingPatternFlag) target := ctx.GetStringFlagValue(commands.MappingTargetFlag) if pattern == "" && target == "" { - return lifecycleServices.Modifications{}, nil + return model.DistributionModifications{}, nil } if pattern == "" || target == "" { - return lifecycleServices.Modifications{}, errorutils.CheckErrorf( + return model.DistributionModifications{}, errorutils.CheckErrorf( "the --%s and --%s options must be provided together", commands.MappingPatternFlag, commands.MappingTargetFlag) } - return lifecycleServices.Modifications{ - PathMappings: distribution.CreatePathMappingsFromPatternAndTarget(pattern, target), - }, nil + pathMappings := distribution.CreatePathMappingsFromPatternAndTarget(pattern, target) + modelMappings := make([]model.DistributionPathMapping, 0, len(pathMappings)) + for _, mapping := range pathMappings { + modelMappings = append(modelMappings, model.DistributionPathMapping{ + Input: mapping.Input, + Output: mapping.Output, + }) + } + return model.DistributionModifications{PathMappings: modelMappings}, nil } diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index c9dfdbb..610f57e 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -8,8 +8,6 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/commands" "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" - artUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" - "github.com/jfrog/jfrog-client-go/utils/distribution" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -415,7 +413,7 @@ func TestBuildDistributionRules(t *testing.T) { rules, err := BuildDistributionRules(ctx) require.NoError(t, err) - expected := []*distribution.DistributionCommonParams{ + expected := []model.DistributionRule{ {SiteName: "edge-*", CityName: "NYC", CountryCodes: []string{"US", "CA"}}, } assert.Equal(t, expected, rules) @@ -426,7 +424,7 @@ func TestBuildDistributionRules(t *testing.T) { rules, err := BuildDistributionRules(ctx) require.NoError(t, err) - expected := []*distribution.DistributionCommonParams{ + expected := []model.DistributionRule{ {}, } assert.Equal(t, expected, rules) @@ -442,7 +440,7 @@ func TestBuildDistributionRules(t *testing.T) { rules, err := BuildDistributionRules(ctx) require.NoError(t, err) - expected := []*distribution.DistributionCommonParams{ + expected := []model.DistributionRule{ {SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}, {SiteName: "site-2"}, } @@ -463,7 +461,7 @@ func TestParseDistributeModifications(t *testing.T) { name string pattern string target string - expected []artUtils.PathMapping + expected []model.DistributionPathMapping expectError bool }{ { @@ -474,7 +472,7 @@ func TestParseDistributeModifications(t *testing.T) { name: "pattern and target provided", pattern: "my-repo/(*)", target: "edge/{1}", - expected: []artUtils.PathMapping{ + expected: []model.DistributionPathMapping{ {Input: "^my-repo/(.*)$", Output: "edge/$1"}, }, }, From 0ba6d1e08421069e23886b0cdb8ec2726b1a7894 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 13:01:09 +0300 Subject: [PATCH 08/22] Distribute and remotely delete application versions --- .../service/versions/mocks/version_service_mock.go | 7 +++---- apptrust/service/versions/version_service.go | 10 +++++----- apptrust/service/versions/version_service_test.go | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apptrust/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index 0341560..c422aa9 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -71,12 +71,11 @@ func (mr *MockVersionServiceMockRecorder) DeleteAppVersion(ctx, applicationKey, } // DistributeAppVersion mocks base method. -func (m *MockVersionService) DistributeAppVersion(ctx service.Context, applicationKey, version string, request *model.DistributeAppVersionRequest) ([]byte, error) { +func (m *MockVersionService) DistributeAppVersion(ctx service.Context, applicationKey, version string, request *model.DistributeAppVersionRequest) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DistributeAppVersion", ctx, applicationKey, version, request) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret0, _ := ret[0].(error) + return ret0 } // DistributeAppVersion indicates an expected call of DistributeAppVersion. diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 3263f85..7062de8 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -22,7 +22,7 @@ type VersionService interface { DeleteAppVersion(ctx service.Context, applicationKey string, version string) error UpdateAppVersion(ctx service.Context, applicationKey string, version string, request *model.UpdateAppVersionRequest) ([]byte, error) UpdateAppVersionSources(ctx service.Context, applicationKey string, version string, request *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) ([]byte, error) - DistributeAppVersion(ctx service.Context, applicationKey string, version string, request *model.DistributeAppVersionRequest) ([]byte, error) + DistributeAppVersion(ctx service.Context, applicationKey string, version string, request *model.DistributeAppVersionRequest) error RemoteDeleteAppVersion(ctx service.Context, applicationKey string, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) } @@ -160,19 +160,19 @@ func (vs *versionService) UpdateAppVersionSources(ctx service.Context, applicati return responseBody, nil } -func (vs *versionService) DistributeAppVersion(ctx service.Context, applicationKey, version string, request *model.DistributeAppVersionRequest) ([]byte, error) { +func (vs *versionService) DistributeAppVersion(ctx service.Context, applicationKey, version string, request *model.DistributeAppVersionRequest) error { endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/distribute", applicationKey, version) response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) if err != nil { - return nil, err + return err } if !apphttp.IsSuccessStatusCode(response.StatusCode) { - return nil, fmt.Errorf("failed to distribute application version. Status code: %d. \n%s", + return fmt.Errorf("failed to distribute application version. Status code: %d. \n%s", response.StatusCode, responseBody) } - return responseBody, nil + return nil } func (vs *versionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) { diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 1e57110..0d7619e 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -771,7 +771,7 @@ func TestDistributeAppVersion(t *testing.T) { mockCtx := mockservice.NewMockContext(ctrl) mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) - _, err := service.DistributeAppVersion(mockCtx, tt.applicationKey, tt.version, request) + err := service.DistributeAppVersion(mockCtx, tt.applicationKey, tt.version, request) if tt.expectedError == "" { assert.NoError(t, err) } else { From 58ffdaba7c2abfa21ddfb5979a181a2814bb5d6b Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 13:46:01 +0300 Subject: [PATCH 09/22] Distribute and remotely delete application versions --- apptrust/commands/version/version_utils.go | 4 ++-- apptrust/commands/version/version_utils_test.go | 14 +++++++------- apptrust/service/versions/version_service.go | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index ad003f6..0a4b3fd 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -155,7 +155,7 @@ func ValidateDistributionFlags(ctx *components.Context) error { return nil } -func BuildDistributionRules(ctx *components.Context) ([]model.DistributionRule, error) { +func ParseDistributionRules(ctx *components.Context) ([]model.DistributionRule, error) { if !ctx.IsFlagSet(commands.DistRulesFlag) { return []model.DistributionRule{{ SiteName: ctx.GetStringFlagValue(commands.SiteFlag), @@ -180,7 +180,7 @@ func BuildDistributionRules(ctx *components.Context) ([]model.DistributionRule, return modelRules, nil } -func ParseDistributeModifications(ctx *components.Context) (model.DistributionModifications, error) { +func ParseDistributionModifications(ctx *components.Context) (model.DistributionModifications, error) { pattern := ctx.GetStringFlagValue(commands.MappingPatternFlag) target := ctx.GetStringFlagValue(commands.MappingTargetFlag) diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 610f57e..f1d26f7 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -404,14 +404,14 @@ func TestValidateDistributionFlags(t *testing.T) { } } -func TestBuildDistributionRules(t *testing.T) { +func TestParseDistributionRules(t *testing.T) { t.Run("from site/city/country flags", func(t *testing.T) { ctx := &components.Context{} ctx.AddStringFlag(commands.SiteFlag, "edge-*") ctx.AddStringFlag(commands.CityFlag, "NYC") ctx.AddStringFlag(commands.CountryCodesFlag, "US;CA") - rules, err := BuildDistributionRules(ctx) + rules, err := ParseDistributionRules(ctx) require.NoError(t, err) expected := []model.DistributionRule{ {SiteName: "edge-*", CityName: "NYC", CountryCodes: []string{"US", "CA"}}, @@ -422,7 +422,7 @@ func TestBuildDistributionRules(t *testing.T) { t.Run("no flags returns a single empty rule", func(t *testing.T) { ctx := &components.Context{} - rules, err := BuildDistributionRules(ctx) + rules, err := ParseDistributionRules(ctx) require.NoError(t, err) expected := []model.DistributionRule{ {}, @@ -438,7 +438,7 @@ func TestBuildDistributionRules(t *testing.T) { ctx := &components.Context{} ctx.AddStringFlag(commands.DistRulesFlag, filePath) - rules, err := BuildDistributionRules(ctx) + rules, err := ParseDistributionRules(ctx) require.NoError(t, err) expected := []model.DistributionRule{ {SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}, @@ -451,12 +451,12 @@ func TestBuildDistributionRules(t *testing.T) { ctx := &components.Context{} ctx.AddStringFlag(commands.DistRulesFlag, filepath.Join(t.TempDir(), "does-not-exist.json")) - _, err := BuildDistributionRules(ctx) + _, err := ParseDistributionRules(ctx) assert.Error(t, err) }) } -func TestParseDistributeModifications(t *testing.T) { +func TestParseDistributionModifications(t *testing.T) { tests := []struct { name string pattern string @@ -498,7 +498,7 @@ func TestParseDistributeModifications(t *testing.T) { ctx.AddStringFlag(commands.MappingTargetFlag, tt.target) } - result, err := ParseDistributeModifications(ctx) + result, err := ParseDistributionModifications(ctx) if tt.expectError { assert.Error(t, err) diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 7062de8..7d61389 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -172,6 +172,7 @@ func (vs *versionService) DistributeAppVersion(ctx service.Context, applicationK response.StatusCode, responseBody) } + log.Info(fmt.Sprintf("Distribution of application version '%s/%s' triggered successfully.", applicationKey, version)) return nil } From 91f85167912f6cb89831ab36841e86e9de8fcb71 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 13:54:41 +0300 Subject: [PATCH 10/22] Distribute and remotely delete application versions --- .../version/distribute_app_version_cmd.go | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 apptrust/commands/version/distribute_app_version_cmd.go diff --git a/apptrust/commands/version/distribute_app_version_cmd.go b/apptrust/commands/version/distribute_app_version_cmd.go new file mode 100644 index 0000000..05a31e9 --- /dev/null +++ b/apptrust/commands/version/distribute_app_version_cmd.go @@ -0,0 +1,138 @@ +package version + +import ( + "github.com/jfrog/jfrog-cli-application/apptrust/app" + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +type distributeAppVersionCommand struct { + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + applicationKey string + version string + requestPayload *model.DistributeAppVersionRequest +} + +func (dv *distributeAppVersionCommand) Run() error { + ctx, err := service.NewContext(*dv.serverDetails) + if err != nil { + return err + } + + return dv.versionService.DistributeAppVersion(ctx, dv.applicationKey, dv.version, dv.requestPayload) +} + +func (dv *distributeAppVersionCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return dv.serverDetails, nil +} + +func (dv *distributeAppVersionCommand) CommandName() string { + return commands.VersionDistribute +} + +func (dv *distributeAppVersionCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) != 2 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + dv.applicationKey = ctx.Arguments[0] + dv.version = ctx.Arguments[1] + + if err := ValidateDistributionFlags(ctx); err != nil { + return err + } + + serverDetails, err := utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + dv.serverDetails = serverDetails + + dv.requestPayload, err = dv.buildRequestPayload(ctx) + if errorutils.CheckError(err) != nil { + return err + } + + return commonCLiCommands.Exec(dv) +} + +func (dv *distributeAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.DistributeAppVersionRequest, error) { + distributionRules, err := ParseDistributionRules(ctx) + if err != nil { + return nil, err + } + + modifications, err := ParseDistributionModifications(ctx) + if err != nil { + return nil, err + } + + return &model.DistributeAppVersionRequest{ + DryRun: ctx.GetBoolFlagValue(commands.DryRunFlag), + DistributionRules: distributionRules, + AutoCreateRepo: ctx.GetBoolFlagValue(commands.CreateRepoFlag), + Modifications: modifications, + }, nil +} + +func GetDistributeAppVersionCommand(appContext app.Context) components.Command { + cmd := &distributeAppVersionCommand{ + versionService: appContext.GetVersionService(), + } + return components.Command{ + Name: commands.VersionDistribute, + Description: "Distribute application version to distribution targets.", + AIDescription: `Distribute an application version's artifacts to one or more distribution targets according to distribution rules. + +When to use: +- Make an application version's artifacts available on distribution targets close to consumers. +- Replicate a released version to remote sites for faster, local access. + +Prerequisites: +- The application version must already exist. +- Configured server and distribution permission on the application's project. +- Reachable distribution target(s) matching the provided distribution rules. + +Common patterns: + $ jf apptrust version-distribute my-app 1.0.0 + $ jf apptrust version-distribute my-app 1.0.0 --site="us-*" --country-codes="US;CA" + $ jf apptrust version-distribute my-app 1.0.0 --dist-rules=/path/to/dist-rules.json + $ jf apptrust version-distribute my-app 1.0.0 --mapping-pattern="repo/(*)" --mapping-target="target/{1}" + $ jf apptrust version-distribute my-app 1.0.0 --create-repo + $ jf apptrust version-distribute my-app 1.0.0 --dry-run + +Gotchas: +- Distribution is asynchronous: a successful result means it was triggered, not that it has completed on the distribution targets. +- --dist-rules can't be combined with --site, --city or --country-codes. +- --mapping-pattern and --mapping-target must be provided together. +- --max-wait-minutes requires --sync. +- Use --dry-run to validate the distribution without transferring any artifacts. + +Related: jf apptrust version-delete-remote, jf apptrust version-release`, + Category: common.CategoryVersion, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The application key.", + Optional: false, + }, + { + Name: "version", + Description: "The version to distribute.", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.VersionDistribute), + Action: cmd.prepareAndRunCommand, + } +} From 533e3feabc7efec349f5018d69538b54999c5eb9 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 14:12:22 +0300 Subject: [PATCH 11/22] Distribute and remotely delete application versions --- apptrust/commands/flags.go | 7 ------- .../version/distribute_app_version_cmd.go | 4 ---- apptrust/commands/version/version_utils.go | 5 ----- .../commands/version/version_utils_test.go | 18 ------------------ .../model/distribute_app_version_request.go | 1 - 5 files changed, 35 deletions(-) diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 38b5fbe..1a1c07c 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -73,7 +73,6 @@ const ( CreateRepoFlag = "create-repo" MappingPatternFlag = "mapping-pattern" MappingTargetFlag = "mapping-target" - MaxWaitMinutesFlag = "max-wait-minutes" QuietFlag = "quiet" ) @@ -127,7 +126,6 @@ var flagsMap = map[string]components.Flag{ CreateRepoFlag: components.NewBoolFlag(CreateRepoFlag, "Set to true to create the repository on the edge if it does not exist.", components.WithBoolDefaultValueFalse()), MappingPatternFlag: components.NewStringFlag(MappingPatternFlag, "Specify along with "+MappingTargetFlag+" to distribute artifacts to a different path on the edge node. You can use wildcards to specify multiple artifacts.", func(f *components.StringFlag) { f.Mandatory = false }), MappingTargetFlag: components.NewStringFlag(MappingTargetFlag, "The target path for distributed artifacts on the edge node. If not specified, the artifacts will have the same path and name on the edge node, as on the source Artifactory server. For flexibility in specifying the distribution path, you can include placeholders in the form of {1}, {2} which are replaced by corresponding tokens in the pattern path that are enclosed in parenthesis.", func(f *components.StringFlag) { f.Mandatory = false }), - MaxWaitMinutesFlag: components.NewStringFlag(MaxWaitMinutesFlag, "Max minutes to wait for sync distribution.", func(f *components.StringFlag) { f.Mandatory = false }), QuietFlag: components.NewBoolFlag(QuietFlag, "Set to true to skip the confirmation message.", components.WithBoolDefaultValueFalse()), } @@ -230,12 +228,9 @@ var commandFlags = map[string][]string{ SiteFlag, CityFlag, CountryCodesFlag, - DryRunFlag, CreateRepoFlag, MappingPatternFlag, MappingTargetFlag, - SyncFlag, - MaxWaitMinutesFlag, }, VersionRemoteDelete: { url, @@ -248,8 +243,6 @@ var commandFlags = map[string][]string{ SiteFlag, CityFlag, CountryCodesFlag, - SyncFlag, - MaxWaitMinutesFlag, }, PackageBind: { diff --git a/apptrust/commands/version/distribute_app_version_cmd.go b/apptrust/commands/version/distribute_app_version_cmd.go index 05a31e9..d927f23 100644 --- a/apptrust/commands/version/distribute_app_version_cmd.go +++ b/apptrust/commands/version/distribute_app_version_cmd.go @@ -78,7 +78,6 @@ func (dv *distributeAppVersionCommand) buildRequestPayload(ctx *components.Conte } return &model.DistributeAppVersionRequest{ - DryRun: ctx.GetBoolFlagValue(commands.DryRunFlag), DistributionRules: distributionRules, AutoCreateRepo: ctx.GetBoolFlagValue(commands.CreateRepoFlag), Modifications: modifications, @@ -109,14 +108,11 @@ Common patterns: $ jf apptrust version-distribute my-app 1.0.0 --dist-rules=/path/to/dist-rules.json $ jf apptrust version-distribute my-app 1.0.0 --mapping-pattern="repo/(*)" --mapping-target="target/{1}" $ jf apptrust version-distribute my-app 1.0.0 --create-repo - $ jf apptrust version-distribute my-app 1.0.0 --dry-run Gotchas: - Distribution is asynchronous: a successful result means it was triggered, not that it has completed on the distribution targets. - --dist-rules can't be combined with --site, --city or --country-codes. - --mapping-pattern and --mapping-target must be provided together. -- --max-wait-minutes requires --sync. -- Use --dry-run to validate the distribution without transferring any artifacts. Related: jf apptrust version-delete-remote, jf apptrust version-release`, Category: common.CategoryVersion, diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 0a4b3fd..6efcf0d 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -147,11 +147,6 @@ func ValidateDistributionFlags(ctx *components.Context) error { commands.DistRulesFlag, commands.SiteFlag, commands.CityFlag, commands.CountryCodesFlag) } - if ctx.IsFlagSet(commands.MaxWaitMinutesFlag) && !ctx.IsFlagSet(commands.SyncFlag) { - return errorutils.CheckErrorf("the --%s option can't be used without --%s", - commands.MaxWaitMinutesFlag, commands.SyncFlag) - } - return nil } diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index f1d26f7..4878cbe 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -324,8 +324,6 @@ func TestValidateDistributionFlags(t *testing.T) { site bool city bool country bool - maxWait bool - sync bool expectError bool }{ { @@ -359,16 +357,6 @@ func TestValidateDistributionFlags(t *testing.T) { country: true, expectError: true, }, - { - name: "max-wait-minutes without sync", - maxWait: true, - expectError: true, - }, - { - name: "max-wait-minutes with sync", - maxWait: true, - sync: true, - }, } for _, tt := range tests { @@ -386,12 +374,6 @@ func TestValidateDistributionFlags(t *testing.T) { if tt.country { ctx.AddStringFlag(commands.CountryCodesFlag, "US") } - if tt.maxWait { - ctx.AddStringFlag(commands.MaxWaitMinutesFlag, "60") - } - if tt.sync { - ctx.AddBoolFlag(commands.SyncFlag, true) - } err := ValidateDistributionFlags(ctx) diff --git a/apptrust/model/distribute_app_version_request.go b/apptrust/model/distribute_app_version_request.go index 92af8e1..4723e41 100644 --- a/apptrust/model/distribute_app_version_request.go +++ b/apptrust/model/distribute_app_version_request.go @@ -16,7 +16,6 @@ type DistributionModifications struct { } type DistributeAppVersionRequest struct { - DryRun bool `json:"dry_run"` DistributionRules []DistributionRule `json:"distribution_rules"` AutoCreateRepo bool `json:"auto_create_missing_repositories,omitempty"` Modifications DistributionModifications `json:"modifications"` From 4a1d65200a30daf5f0a05fa1793feeb7d24d5ee5 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 14:22:31 +0300 Subject: [PATCH 12/22] Distribute and remotely delete application versions --- .../distribute_app_version_cmd_test.go | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 apptrust/commands/version/distribute_app_version_cmd_test.go diff --git a/apptrust/commands/version/distribute_app_version_cmd_test.go b/apptrust/commands/version/distribute_app_version_cmd_test.go new file mode 100644 index 0000000..3df542e --- /dev/null +++ b/apptrust/commands/version/distribute_app_version_cmd_test.go @@ -0,0 +1,170 @@ +package version + +import ( + "errors" + "testing" + + mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" + "go.uber.org/mock/gomock" + + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" +) + +func TestDistributeAppVersionCommand_Run(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.DistributeAppVersionRequest{ + DistributionRules: []model.DistributionRule{{SiteName: "edge-*"}}, + } + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().DistributeAppVersion(gomock.Any(), applicationKey, version, requestPayload). + Return(nil).Times(1) + + cmd := &distributeAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + } + + err := cmd.Run() + assert.NoError(t, err) +} + +func TestDistributeAppVersionCommand_Run_Error(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.DistributeAppVersionRequest{ + DistributionRules: []model.DistributionRule{{SiteName: "edge-*"}}, + } + expectedError := errors.New("service error occurred") + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().DistributeAppVersion(gomock.Any(), applicationKey, version, requestPayload). + Return(expectedError).Times(1) + + cmd := &distributeAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "service error occurred") +} + +func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectsError bool + errorContains string + expectsPayload *model.DistributeAppVersionRequest + }{ + { + name: "site, city and country codes with create-repo", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SiteFlag, "edge-*") + ctx.AddStringFlag(commands.CityFlag, "NYC") + ctx.AddStringFlag(commands.CountryCodesFlag, "US;CA") + ctx.AddBoolFlag(commands.CreateRepoFlag, true) + }, + expectsPayload: &model.DistributeAppVersionRequest{ + DistributionRules: []model.DistributionRule{ + {SiteName: "edge-*", CityName: "NYC", CountryCodes: []string{"US", "CA"}}, + }, + AutoCreateRepo: true, + Modifications: model.DistributionModifications{}, + }, + }, + { + name: "mapping pattern and target", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.MappingPatternFlag, "my-repo/(*)") + ctx.AddStringFlag(commands.MappingTargetFlag, "edge/{1}") + }, + expectsPayload: &model.DistributeAppVersionRequest{ + DistributionRules: []model.DistributionRule{{}}, + Modifications: model.DistributionModifications{ + PathMappings: []model.DistributionPathMapping{ + {Input: "^my-repo/(.*)$", Output: "edge/$1"}, + }, + }, + }, + }, + { + name: "no distribution flags produces a single empty rule", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + }, + expectsPayload: &model.DistributeAppVersionRequest{ + DistributionRules: []model.DistributionRule{{}}, + Modifications: model.DistributionModifications{}, + }, + }, + { + name: "only mapping-pattern provided", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.MappingPatternFlag, "my-repo/(*)") + }, + expectsError: true, + errorContains: "must be provided together", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + tt.ctxSetup(ctx) + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.DistributeAppVersionRequest + mockVersionService := mockversions.NewMockVersionService(ctrl) + if !tt.expectsError { + mockVersionService.EXPECT().DistributeAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, _ string, _ string, req *model.DistributeAppVersionRequest) error { + actualPayload = req + return nil + }).Times(1) + } + + cmd := &distributeAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + if tt.expectsError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectsPayload, actualPayload) + } + }) + } +} From 5750c499bfeb812f51460fcb92ab37a6602026d8 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 14:39:04 +0300 Subject: [PATCH 13/22] Distribute and remotely delete application versions --- .../distribute_app_version_cmd_test.go | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/apptrust/commands/version/distribute_app_version_cmd_test.go b/apptrust/commands/version/distribute_app_version_cmd_test.go index 3df542e..6003a01 100644 --- a/apptrust/commands/version/distribute_app_version_cmd_test.go +++ b/apptrust/commands/version/distribute_app_version_cmd_test.go @@ -2,6 +2,8 @@ package version import ( "errors" + "os" + "path/filepath" "testing" mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" @@ -12,6 +14,7 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDistributeAppVersionCommand_Run(t *testing.T) { @@ -168,3 +171,120 @@ func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { }) } } + +func TestDistributeAppVersionCommand_SpecAndFlags(t *testing.T) { + tests := []struct { + name string + flagName string + }{ + {name: "dist-rules with site", flagName: commands.SiteFlag}, + {name: "dist-rules with city", flagName: commands.CityFlag}, + {name: "dist-rules with country-codes", flagName: commands.CountryCodesFlag}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.DistRulesFlag, "rules.json") + ctx.AddStringFlag(tt.flagName, "value") + ctx.AddStringFlag("url", "https://example.com") + + cmd := &distributeAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "can't be used with") + }) + } +} + +func TestDistributeAppVersionCommand_SpecFileSuite(t *testing.T) { + t.Run("payload built from dist-rules file", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + content := `{"distribution_rules":[{"site_name":"site-1","city_name":"city-1","country_codes":["US"]},{"site_name":"site-2"}]}` + filePath := filepath.Join(t.TempDir(), "dist-rules.json") + require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.DistRulesFlag, filePath) + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.DistributeAppVersionRequest + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().DistributeAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, _ string, _ string, req *model.DistributeAppVersionRequest) error { + actualPayload = req + return nil + }).Times(1) + + cmd := &distributeAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + require.NoError(t, err) + assert.Equal(t, &model.DistributeAppVersionRequest{ + DistributionRules: []model.DistributionRule{ + {SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}, + {SiteName: "site-2"}, + }, + Modifications: model.DistributionModifications{}, + }, actualPayload) + }) + + t.Run("missing dist-rules file returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.DistRulesFlag, filepath.Join(t.TempDir(), "does-not-exist.json")) + ctx.AddStringFlag("url", "https://example.com") + + cmd := &distributeAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + }) +} + +func TestDistributeAppVersionCommand_WrongNumberOfArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + }{ + {name: "no arguments", arguments: []string{}}, + {name: "single argument", arguments: []string{"app-key"}}, + {name: "too many arguments", arguments: []string{"app-key", "1.0.0", "extra"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + PrintCommandHelp: func(string) error { return nil }, + } + ctx.Arguments = tt.arguments + + cmd := &distributeAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + }) + } +} From c68f42ba3e85979e096c0a440ecbb01a2df6af4e Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 15:00:33 +0300 Subject: [PATCH 14/22] Distribute and remotely delete application versions --- .../version/remote_delete_app_version_cmd.go | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 apptrust/commands/version/remote_delete_app_version_cmd.go diff --git a/apptrust/commands/version/remote_delete_app_version_cmd.go b/apptrust/commands/version/remote_delete_app_version_cmd.go new file mode 100644 index 0000000..3f4168b --- /dev/null +++ b/apptrust/commands/version/remote_delete_app_version_cmd.go @@ -0,0 +1,148 @@ +package version + +import ( + "fmt" + + "github.com/jfrog/jfrog-cli-application/apptrust/app" + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +type remoteDeleteAppVersionCommand struct { + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + applicationKey string + version string + requestPayload *model.RemoteDeleteAppVersionRequest + quiet bool +} + +func (rd *remoteDeleteAppVersionCommand) Run() error { + ctx, err := service.NewContext(*rd.serverDetails) + if err != nil { + return err + } + + return rd.versionService.RemoteDeleteAppVersion(ctx, rd.applicationKey, rd.version, rd.requestPayload) +} + +func (rd *remoteDeleteAppVersionCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return rd.serverDetails, nil +} + +func (rd *remoteDeleteAppVersionCommand) CommandName() string { + return commands.VersionRemoteDelete +} + +func (rd *remoteDeleteAppVersionCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) != 2 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + rd.applicationKey = ctx.Arguments[0] + rd.version = ctx.Arguments[1] + rd.quiet = ctx.GetBoolFlagValue(commands.QuietFlag) + + if err := ValidateDistributionFlags(ctx); err != nil { + return err + } + + serverDetails, err := utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + rd.serverDetails = serverDetails + + rd.requestPayload, err = rd.buildRequestPayload(ctx) + if errorutils.CheckError(err) != nil { + return err + } + + if !rd.confirmRemoteDelete() { + return nil + } + + return commonCLiCommands.Exec(rd) +} + +func (rd *remoteDeleteAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.RemoteDeleteAppVersionRequest, error) { + distributionRules, err := ParseDistributionRules(ctx) + if err != nil { + return nil, err + } + + return &model.RemoteDeleteAppVersionRequest{ + DryRun: ctx.GetBoolFlagValue(commands.DryRunFlag), + DistributionRules: distributionRules, + }, nil +} + +func (rd *remoteDeleteAppVersionCommand) confirmRemoteDelete() bool { + if rd.quiet { + return true + } + + message := fmt.Sprintf("Are you sure you want to delete the application version '%s/%s' remotely from the distribution targets?", + rd.applicationKey, rd.version) + return coreutils.AskYesNo(message, false) +} + +func GetRemoteDeleteAppVersionCommand(appContext app.Context) components.Command { + cmd := &remoteDeleteAppVersionCommand{ + versionService: appContext.GetVersionService(), + } + return components.Command{ + Name: commands.VersionRemoteDelete, + Description: "Delete an application version from distribution targets.", + AIDescription: `Delete a previously distributed application version's artifacts from one or more distribution targets according to distribution rules. + +When to use: +- Reclaim space on distribution targets by removing artifacts of a version that is no longer needed there. +- Roll back a distribution without deleting the application version itself. + +Prerequisites: +- The application version must have been distributed to the targeted distribution target(s). +- Configured server and distribution permission on the application's project. + +Common patterns: + $ jf apptrust version-delete-remote my-app 1.0.0 + $ jf apptrust version-delete-remote my-app 1.0.0 --quiet + $ jf apptrust version-delete-remote my-app 1.0.0 --site="us-*" --country-codes="US;CA" + $ jf apptrust version-delete-remote my-app 1.0.0 --dist-rules=/path/to/dist-rules.json + $ jf apptrust version-delete-remote my-app 1.0.0 --dry-run + +Gotchas: +- This only removes the version from distribution targets; the application version itself remains. To delete the version record, use version-delete. +- A confirmation prompt is shown before deletion; pass --quiet to skip it (e.g. in scripts). +- --dist-rules can't be combined with --site, --city or --country-codes. +- --dry-run simulates the operation without deleting anything. + +Related: jf apptrust version-distribute, jf apptrust version-delete`, + Category: common.CategoryVersion, + Aliases: []string{"vdelr"}, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The application key.", + Optional: false, + }, + { + Name: "version", + Description: "The version to delete from the distribution targets.", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.VersionRemoteDelete), + Action: cmd.prepareAndRunCommand, + } +} From e8567d02c1e5b8105b68b701b0480544ac2ef3e3 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 15:02:18 +0300 Subject: [PATCH 15/22] Distribute and remotely delete application versions --- apptrust/commands/version/remote_delete_app_version_cmd.go | 1 - 1 file changed, 1 deletion(-) diff --git a/apptrust/commands/version/remote_delete_app_version_cmd.go b/apptrust/commands/version/remote_delete_app_version_cmd.go index 3f4168b..b0582a1 100644 --- a/apptrust/commands/version/remote_delete_app_version_cmd.go +++ b/apptrust/commands/version/remote_delete_app_version_cmd.go @@ -129,7 +129,6 @@ Gotchas: Related: jf apptrust version-distribute, jf apptrust version-delete`, Category: common.CategoryVersion, - Aliases: []string{"vdelr"}, Arguments: []components.Argument{ { Name: "application-key", From 1c48732c1660eb52c6b5a22faafa34fab0a61c2b Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 15:16:57 +0300 Subject: [PATCH 16/22] Distribute and remotely delete application versions --- .../version/remote_delete_app_version_cmd.go | 26 ++++++++++++++----- .../versions/mocks/version_service_mock.go | 7 +++-- apptrust/service/versions/version_service.go | 11 ++++---- .../service/versions/version_service_test.go | 2 +- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/apptrust/commands/version/remote_delete_app_version_cmd.go b/apptrust/commands/version/remote_delete_app_version_cmd.go index b0582a1..b49ff99 100644 --- a/apptrust/commands/version/remote_delete_app_version_cmd.go +++ b/apptrust/commands/version/remote_delete_app_version_cmd.go @@ -68,8 +68,9 @@ func (rd *remoteDeleteAppVersionCommand) prepareAndRunCommand(ctx *components.Co return err } - if !rd.confirmRemoteDelete() { - return nil + confirmed, err := rd.confirmRemoteDelete() + if err != nil || !confirmed { + return err } return commonCLiCommands.Exec(rd) @@ -89,12 +90,23 @@ func (rd *remoteDeleteAppVersionCommand) buildRequestPayload(ctx *components.Con func (rd *remoteDeleteAppVersionCommand) confirmRemoteDelete() bool { if rd.quiet { - return true + return true, nil + } + + message := fmt.Sprintf("Are you sure you want to delete the application version '%s/%s' remotely ", rd.applicationKey, rd.version) + if rd.distributionRulesEmpty() { + message += "from all distribution targets?" + } else { + bytes, err := json.Marshal(rd.requestPayload.DistributionRules) + if err != nil { + return false, errorutils.CheckError(err) + } + + log.Output(clientutils.IndentJson(bytes)) + message += "from all targets with the above distribution rules?" } - message := fmt.Sprintf("Are you sure you want to delete the application version '%s/%s' remotely from the distribution targets?", - rd.applicationKey, rd.version) - return coreutils.AskYesNo(message, false) + return coreutils.AskYesNo(message, false), nil } func GetRemoteDeleteAppVersionCommand(appContext app.Context) components.Command { @@ -107,7 +119,7 @@ func GetRemoteDeleteAppVersionCommand(appContext app.Context) components.Command AIDescription: `Delete a previously distributed application version's artifacts from one or more distribution targets according to distribution rules. When to use: -- Reclaim space on distribution targets by removing artifacts of a version that is no longer needed there. +- Remove a version that was distributed to distribution targets and should no longer be available there. - Roll back a distribution without deleting the application version itself. Prerequisites: diff --git a/apptrust/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index c422aa9..fb10857 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -115,12 +115,11 @@ func (mr *MockVersionServiceMockRecorder) ReleaseAppVersion(ctx, applicationKey, } // RemoteDeleteAppVersion mocks base method. -func (m *MockVersionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) { +func (m *MockVersionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoteDeleteAppVersion", ctx, applicationKey, version, request) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret0, _ := ret[0].(error) + return ret0 } // RemoteDeleteAppVersion indicates an expected call of RemoteDeleteAppVersion. diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 7d61389..06fd0e9 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -23,7 +23,7 @@ type VersionService interface { UpdateAppVersion(ctx service.Context, applicationKey string, version string, request *model.UpdateAppVersionRequest) ([]byte, error) UpdateAppVersionSources(ctx service.Context, applicationKey string, version string, request *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) ([]byte, error) DistributeAppVersion(ctx service.Context, applicationKey string, version string, request *model.DistributeAppVersionRequest) error - RemoteDeleteAppVersion(ctx service.Context, applicationKey string, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) + RemoteDeleteAppVersion(ctx service.Context, applicationKey string, version string, request *model.RemoteDeleteAppVersionRequest) error } type versionService struct{} @@ -176,19 +176,20 @@ func (vs *versionService) DistributeAppVersion(ctx service.Context, applicationK return nil } -func (vs *versionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) ([]byte, error) { +func (vs *versionService) RemoteDeleteAppVersion(ctx service.Context, applicationKey, version string, request *model.RemoteDeleteAppVersionRequest) error { endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/remote-delete", applicationKey, version) response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) if err != nil { - return nil, err + return err } if !apphttp.IsSuccessStatusCode(response.StatusCode) { - return nil, fmt.Errorf("failed to delete application version remotely. Status code: %d. \n%s", + return fmt.Errorf("failed to delete application version remotely. Status code: %d. \n%s", response.StatusCode, responseBody) } - return responseBody, nil + log.Info(fmt.Sprintf("Remote deletion of application version '%s/%s' triggered successfully.", applicationKey, version)) + return nil } func logSuccessMessage(sync bool, request *model.CreateAppVersionRequest, dryRun bool) { diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 0d7619e..b5d93cf 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -839,7 +839,7 @@ func TestRemoteDeleteAppVersion(t *testing.T) { mockCtx := mockservice.NewMockContext(ctrl) mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) - _, err := service.RemoteDeleteAppVersion(mockCtx, tt.applicationKey, tt.version, request) + err := service.RemoteDeleteAppVersion(mockCtx, tt.applicationKey, tt.version, request) if tt.expectedError == "" { assert.NoError(t, err) } else { From 5a0b541806989f8ff61f791f3d092935d3795cd7 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 15:47:42 +0300 Subject: [PATCH 17/22] Distribute and remotely delete application versions --- .../version/remote_delete_app_version_cmd.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apptrust/commands/version/remote_delete_app_version_cmd.go b/apptrust/commands/version/remote_delete_app_version_cmd.go index b49ff99..da00fdf 100644 --- a/apptrust/commands/version/remote_delete_app_version_cmd.go +++ b/apptrust/commands/version/remote_delete_app_version_cmd.go @@ -1,6 +1,7 @@ package version import ( + "encoding/json" "fmt" "github.com/jfrog/jfrog-cli-application/apptrust/app" @@ -15,7 +16,9 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/plugins/components" coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + clientutils "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" ) type remoteDeleteAppVersionCommand struct { @@ -88,7 +91,19 @@ func (rd *remoteDeleteAppVersionCommand) buildRequestPayload(ctx *components.Con }, nil } -func (rd *remoteDeleteAppVersionCommand) confirmRemoteDelete() bool { +func (rd *remoteDeleteAppVersionCommand) distributionRulesEmpty() bool { + rules := rd.requestPayload.DistributionRules + if len(rules) == 0 { + return true + } + if len(rules) == 1 { + rule := rules[0] + return rule.SiteName == "" && rule.CityName == "" && len(rule.CountryCodes) == 0 + } + return false +} + +func (rd *remoteDeleteAppVersionCommand) confirmRemoteDelete() (bool, error) { if rd.quiet { return true, nil } From b1353f86e321648469868a3067e0b4ee1eaa4dce Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 15:52:24 +0300 Subject: [PATCH 18/22] Distribute and remotely delete application versions --- .../remote_delete_app_version_cmd_test.go | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 apptrust/commands/version/remote_delete_app_version_cmd_test.go diff --git a/apptrust/commands/version/remote_delete_app_version_cmd_test.go b/apptrust/commands/version/remote_delete_app_version_cmd_test.go new file mode 100644 index 0000000..8bde09f --- /dev/null +++ b/apptrust/commands/version/remote_delete_app_version_cmd_test.go @@ -0,0 +1,368 @@ +package version + +import ( + "errors" + "os" + "path/filepath" + "testing" + + mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" + "go.uber.org/mock/gomock" + + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemoteDeleteAppVersionCommand_Run(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.RemoteDeleteAppVersionRequest{ + DistributionRules: []model.DistributionRule{{SiteName: "edge-*"}}, + } + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().RemoteDeleteAppVersion(gomock.Any(), applicationKey, version, requestPayload). + Return(nil).Times(1) + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + } + + err := cmd.Run() + assert.NoError(t, err) +} + +func TestRemoteDeleteAppVersionCommand_Run_Error(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.RemoteDeleteAppVersionRequest{ + DistributionRules: []model.DistributionRule{{SiteName: "edge-*"}}, + } + expectedError := errors.New("service error occurred") + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().RemoteDeleteAppVersion(gomock.Any(), applicationKey, version, requestPayload). + Return(expectedError).Times(1) + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + requestPayload: requestPayload, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "service error occurred") +} + +func TestRemoteDeleteAppVersionCommand_FlagsSuite(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectsError bool + errorContains string + expectsPayload *model.RemoteDeleteAppVersionRequest + }{ + { + name: "site, city and country codes", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SiteFlag, "edge-*") + ctx.AddStringFlag(commands.CityFlag, "NYC") + ctx.AddStringFlag(commands.CountryCodesFlag, "US;CA") + }, + expectsPayload: &model.RemoteDeleteAppVersionRequest{ + DistributionRules: []model.DistributionRule{ + {SiteName: "edge-*", CityName: "NYC", CountryCodes: []string{"US", "CA"}}, + }, + }, + }, + { + name: "dry-run flag", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddBoolFlag(commands.DryRunFlag, true) + }, + expectsPayload: &model.RemoteDeleteAppVersionRequest{ + DryRun: true, + DistributionRules: []model.DistributionRule{{}}, + }, + }, + { + name: "no distribution flags produces a single empty rule", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + }, + expectsPayload: &model.RemoteDeleteAppVersionRequest{ + DistributionRules: []model.DistributionRule{{}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + tt.ctxSetup(ctx) + + // Skip the interactive confirmation prompt + ctx.AddBoolFlag(commands.QuietFlag, true) + + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.RemoteDeleteAppVersionRequest + mockVersionService := mockversions.NewMockVersionService(ctrl) + if !tt.expectsError { + mockVersionService.EXPECT().RemoteDeleteAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, _ string, _ string, req *model.RemoteDeleteAppVersionRequest) error { + actualPayload = req + return nil + }).Times(1) + } + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + if tt.expectsError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectsPayload, actualPayload) + } + }) + } +} + +func TestRemoteDeleteAppVersionCommand_QuietSuite(t *testing.T) { + tests := []struct { + name string + quiet bool + expectsCall bool + }{ + {name: "quiet skips confirmation and calls service", quiet: true, expectsCall: true}, + {name: "without quiet the default confirmation aborts", quiet: false, expectsCall: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag("url", "https://example.com") + if tt.quiet { + ctx.AddBoolFlag(commands.QuietFlag, true) + } + + mockVersionService := mockversions.NewMockVersionService(ctrl) + if tt.expectsCall { + mockVersionService.EXPECT().RemoteDeleteAppVersion(gomock.Any(), "app-key", "1.0.0", gomock.Any()). + Return(nil).Times(1) + } + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + assert.NoError(t, err) + }) + } +} + +func TestRemoteDeleteAppVersionCommand_SpecAndFlags(t *testing.T) { + tests := []struct { + name string + flagName string + }{ + {name: "dist-rules with site", flagName: commands.SiteFlag}, + {name: "dist-rules with city", flagName: commands.CityFlag}, + {name: "dist-rules with country-codes", flagName: commands.CountryCodesFlag}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.DistRulesFlag, "rules.json") + ctx.AddStringFlag(tt.flagName, "value") + ctx.AddStringFlag("url", "https://example.com") + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "can't be used with") + }) + } +} + +func TestRemoteDeleteAppVersionCommand_SpecFileSuite(t *testing.T) { + t.Run("payload built from dist-rules file", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + content := `{"distribution_rules":[{"site_name":"site-1","city_name":"city-1","country_codes":["US"]},{"site_name":"site-2"}]}` + filePath := filepath.Join(t.TempDir(), "dist-rules.json") + require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.DistRulesFlag, filePath) + ctx.AddBoolFlag(commands.QuietFlag, true) + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.RemoteDeleteAppVersionRequest + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().RemoteDeleteAppVersion(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, _ string, _ string, req *model.RemoteDeleteAppVersionRequest) error { + actualPayload = req + return nil + }).Times(1) + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + require.NoError(t, err) + assert.Equal(t, &model.RemoteDeleteAppVersionRequest{ + DistributionRules: []model.DistributionRule{ + {SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}, + {SiteName: "site-2"}, + }, + }, actualPayload) + }) + + t.Run("missing dist-rules file returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.DistRulesFlag, filepath.Join(t.TempDir(), "does-not-exist.json")) + ctx.AddBoolFlag(commands.QuietFlag, true) + ctx.AddStringFlag("url", "https://example.com") + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + }) +} + +func TestRemoteDeleteAppVersionCommand_DistributionRulesEmpty(t *testing.T) { + tests := []struct { + name string + rules []model.DistributionRule + empty bool + }{ + { + name: "nil rules", + rules: nil, + empty: true, + }, + { + name: "no rules", + rules: []model.DistributionRule{}, + empty: true, + }, + { + name: "single empty rule", + rules: []model.DistributionRule{{}}, + empty: true, + }, + { + name: "single rule with site", + rules: []model.DistributionRule{{SiteName: "edge-*"}}, + empty: false, + }, + { + name: "single rule with city", + rules: []model.DistributionRule{{CityName: "NYC"}}, + empty: false, + }, + { + name: "single rule with country codes", + rules: []model.DistributionRule{{CountryCodes: []string{"US"}}}, + empty: false, + }, + { + name: "multiple rules", + rules: []model.DistributionRule{{SiteName: "site-1"}, {SiteName: "site-2"}}, + empty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &remoteDeleteAppVersionCommand{ + requestPayload: &model.RemoteDeleteAppVersionRequest{DistributionRules: tt.rules}, + } + assert.Equal(t, tt.empty, cmd.distributionRulesEmpty()) + }) + } +} + +func TestRemoteDeleteAppVersionCommand_WrongNumberOfArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + }{ + {name: "no arguments", arguments: []string{}}, + {name: "single argument", arguments: []string{"app-key"}}, + {name: "too many arguments", arguments: []string{"app-key", "1.0.0", "extra"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + PrintCommandHelp: func(string) error { return nil }, + } + ctx.Arguments = tt.arguments + + cmd := &remoteDeleteAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + }) + } +} From 86699cb154be433f997beca1b4301289cf243962 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 16:27:50 +0300 Subject: [PATCH 19/22] Distribute and remotely delete application versions --- .../commands/version/distribute_app_version_cmd_test.go | 5 +---- apptrust/model/distribute_app_version_request.go | 8 ++++---- apptrust/service/versions/version_service_test.go | 2 +- cli/cli.go | 2 ++ 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apptrust/commands/version/distribute_app_version_cmd_test.go b/apptrust/commands/version/distribute_app_version_cmd_test.go index 6003a01..b2d86ae 100644 --- a/apptrust/commands/version/distribute_app_version_cmd_test.go +++ b/apptrust/commands/version/distribute_app_version_cmd_test.go @@ -95,7 +95,6 @@ func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { {SiteName: "edge-*", CityName: "NYC", CountryCodes: []string{"US", "CA"}}, }, AutoCreateRepo: true, - Modifications: model.DistributionModifications{}, }, }, { @@ -107,7 +106,7 @@ func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { }, expectsPayload: &model.DistributeAppVersionRequest{ DistributionRules: []model.DistributionRule{{}}, - Modifications: model.DistributionModifications{ + Modifications: &model.DistributionModifications{ PathMappings: []model.DistributionPathMapping{ {Input: "^my-repo/(.*)$", Output: "edge/$1"}, }, @@ -121,7 +120,6 @@ func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { }, expectsPayload: &model.DistributeAppVersionRequest{ DistributionRules: []model.DistributionRule{{}}, - Modifications: model.DistributionModifications{}, }, }, { @@ -237,7 +235,6 @@ func TestDistributeAppVersionCommand_SpecFileSuite(t *testing.T) { {SiteName: "site-1", CityName: "city-1", CountryCodes: []string{"US"}}, {SiteName: "site-2"}, }, - Modifications: model.DistributionModifications{}, }, actualPayload) }) diff --git a/apptrust/model/distribute_app_version_request.go b/apptrust/model/distribute_app_version_request.go index 4723e41..cbc1f39 100644 --- a/apptrust/model/distribute_app_version_request.go +++ b/apptrust/model/distribute_app_version_request.go @@ -12,11 +12,11 @@ type DistributionPathMapping struct { } type DistributionModifications struct { - PathMappings []DistributionPathMapping `json:"mappings"` + PathMappings []DistributionPathMapping `json:"mappings,omitempty"` } type DistributeAppVersionRequest struct { - DistributionRules []DistributionRule `json:"distribution_rules"` - AutoCreateRepo bool `json:"auto_create_missing_repositories,omitempty"` - Modifications DistributionModifications `json:"modifications"` + DistributionRules []DistributionRule `json:"distribution_rules"` + AutoCreateRepo bool `json:"auto_create_missing_repositories,omitempty"` + Modifications *DistributionModifications `json:"modifications,omitempty"` } diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index b5d93cf..e8faab5 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -721,7 +721,7 @@ func TestDistributeAppVersion(t *testing.T) { request := &model.DistributeAppVersionRequest{ AutoCreateRepo: true, DistributionRules: rules, - Modifications: model.DistributionModifications{ + Modifications: &model.DistributionModifications{ PathMappings: mappings, }, } diff --git a/cli/cli.go b/cli/cli.go index b015fc3..fb68ad6 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -45,6 +45,8 @@ Related: jf rt, jf release-bundle commands.`, version.GetRollbackAppVersionCommand(appContext), version.GetReleaseAppVersionCommand(appContext), version.GetDeleteAppVersionCommand(appContext), + version.GetDistributeAppVersionCommand(appContext), + version.GetRemoteDeleteAppVersionCommand(appContext), version.GetUpdateAppVersionCommand(appContext), version.GetUpdateAppVersionSourcesCommand(appContext), packagecmds.GetBindPackageCommand(appContext), From aee8ae90bf2edebe9f5239aa960502997f066135 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 16:44:21 +0300 Subject: [PATCH 20/22] Distribute and remotely delete application versions --- .../distribute_app_version_cmd_test.go | 6 ++--- .../remote_delete_app_version_cmd_test.go | 6 ++--- apptrust/commands/version/version_utils.go | 20 ++++++++++++----- .../commands/version/version_utils_test.go | 22 +++++++++++++++++-- cli/cli.go | 1 + 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/apptrust/commands/version/distribute_app_version_cmd_test.go b/apptrust/commands/version/distribute_app_version_cmd_test.go index b2d86ae..f20f3b6 100644 --- a/apptrust/commands/version/distribute_app_version_cmd_test.go +++ b/apptrust/commands/version/distribute_app_version_cmd_test.go @@ -105,7 +105,7 @@ func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { ctx.AddStringFlag(commands.MappingTargetFlag, "edge/{1}") }, expectsPayload: &model.DistributeAppVersionRequest{ - DistributionRules: []model.DistributionRule{{}}, + DistributionRules: []model.DistributionRule{{SiteName: "*"}}, Modifications: &model.DistributionModifications{ PathMappings: []model.DistributionPathMapping{ {Input: "^my-repo/(.*)$", Output: "edge/$1"}, @@ -114,12 +114,12 @@ func TestDistributeAppVersionCommand_FlagsSuite(t *testing.T) { }, }, { - name: "no distribution flags produces a single empty rule", + name: "no distribution flags defaults to distributing to all targets", ctxSetup: func(ctx *components.Context) { ctx.Arguments = []string{"app-key", "1.0.0"} }, expectsPayload: &model.DistributeAppVersionRequest{ - DistributionRules: []model.DistributionRule{{}}, + DistributionRules: []model.DistributionRule{{SiteName: "*"}}, }, }, { diff --git a/apptrust/commands/version/remote_delete_app_version_cmd_test.go b/apptrust/commands/version/remote_delete_app_version_cmd_test.go index 8bde09f..d70724e 100644 --- a/apptrust/commands/version/remote_delete_app_version_cmd_test.go +++ b/apptrust/commands/version/remote_delete_app_version_cmd_test.go @@ -103,16 +103,16 @@ func TestRemoteDeleteAppVersionCommand_FlagsSuite(t *testing.T) { }, expectsPayload: &model.RemoteDeleteAppVersionRequest{ DryRun: true, - DistributionRules: []model.DistributionRule{{}}, + DistributionRules: []model.DistributionRule{{SiteName: "*"}}, }, }, { - name: "no distribution flags produces a single empty rule", + name: "no distribution flags defaults to distributing to all targets", ctxSetup: func(ctx *components.Context) { ctx.Arguments = []string{"app-key", "1.0.0"} }, expectsPayload: &model.RemoteDeleteAppVersionRequest{ - DistributionRules: []model.DistributionRule{{}}, + DistributionRules: []model.DistributionRule{{SiteName: "*"}}, }, }, } diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 6efcf0d..a120f01 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -150,13 +150,21 @@ func ValidateDistributionFlags(ctx *components.Context) error { return nil } +const allSiteName = "*" + func ParseDistributionRules(ctx *components.Context) ([]model.DistributionRule, error) { if !ctx.IsFlagSet(commands.DistRulesFlag) { - return []model.DistributionRule{{ + rule := model.DistributionRule{ SiteName: ctx.GetStringFlagValue(commands.SiteFlag), CityName: ctx.GetStringFlagValue(commands.CityFlag), CountryCodes: ctx.GetStringsArrFlagValue(commands.CountryCodesFlag), - }}, nil + } + + // If no site, city or country codes were provided, default to distributing to all targets. + if rule.SiteName == "" && rule.CityName == "" && len(rule.CountryCodes) == 0 { + rule.SiteName = allSiteName + } + return []model.DistributionRule{rule}, nil } distributionRules, err := spec.CreateDistributionRulesFromFile(ctx.GetStringFlagValue(commands.DistRulesFlag)) @@ -175,15 +183,15 @@ func ParseDistributionRules(ctx *components.Context) ([]model.DistributionRule, return modelRules, nil } -func ParseDistributionModifications(ctx *components.Context) (model.DistributionModifications, error) { +func ParseDistributionModifications(ctx *components.Context) (*model.DistributionModifications, error) { pattern := ctx.GetStringFlagValue(commands.MappingPatternFlag) target := ctx.GetStringFlagValue(commands.MappingTargetFlag) if pattern == "" && target == "" { - return model.DistributionModifications{}, nil + return nil, nil } if pattern == "" || target == "" { - return model.DistributionModifications{}, errorutils.CheckErrorf( + return nil, errorutils.CheckErrorf( "the --%s and --%s options must be provided together", commands.MappingPatternFlag, commands.MappingTargetFlag) } @@ -196,5 +204,5 @@ func ParseDistributionModifications(ctx *components.Context) (model.Distribution Output: mapping.Output, }) } - return model.DistributionModifications{PathMappings: modelMappings}, nil + return &model.DistributionModifications{PathMappings: modelMappings}, nil } diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 4878cbe..ff48b7c 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -401,13 +401,13 @@ func TestParseDistributionRules(t *testing.T) { assert.Equal(t, expected, rules) }) - t.Run("no flags returns a single empty rule", func(t *testing.T) { + t.Run("no flags defaults to distributing to all targets", func(t *testing.T) { ctx := &components.Context{} rules, err := ParseDistributionRules(ctx) require.NoError(t, err) expected := []model.DistributionRule{ - {}, + {SiteName: "*"}, } assert.Equal(t, expected, rules) }) @@ -429,6 +429,19 @@ func TestParseDistributionRules(t *testing.T) { assert.Equal(t, expected, rules) }) + t.Run("empty dist-rules file returns no rules", func(t *testing.T) { + content := `{"distribution_rules":[]}` + filePath := filepath.Join(t.TempDir(), "dist-rules.json") + require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + + ctx := &components.Context{} + ctx.AddStringFlag(commands.DistRulesFlag, filePath) + + rules, err := ParseDistributionRules(ctx) + require.NoError(t, err) + assert.Empty(t, rules) + }) + t.Run("missing dist-rules file returns error", func(t *testing.T) { ctx := &components.Context{} ctx.AddStringFlag(commands.DistRulesFlag, filepath.Join(t.TempDir(), "does-not-exist.json")) @@ -487,6 +500,11 @@ func TestParseDistributionModifications(t *testing.T) { return } require.NoError(t, err) + if tt.expected == nil { + assert.Nil(t, result) + return + } + require.NotNil(t, result) assert.Equal(t, tt.expected, result.PathMappings) }) } diff --git a/cli/cli.go b/cli/cli.go index fb68ad6..f34f52e 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -35,6 +35,7 @@ Common patterns: $ jf apptrust app-create my-app --project=default $ jf apptrust version-create my-app 1.0.0 --source-type-builds="name=my-build, id=1" $ jf apptrust version-promote my-app 1.0.0 PROD + $ jf apptrust version-distribute my-app 1.0.0 Related: jf rt, jf release-bundle commands.`, Category: "Command Namespaces", From 336e742df161744eb52cf3238408c94de8fa0beb Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 17:21:46 +0300 Subject: [PATCH 21/22] Distribute and remotely delete application versions --- e2e/version_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/e2e/version_test.go b/e2e/version_test.go index 1f41f2c..f79ecf4 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -559,6 +559,54 @@ func TestReleaseVersion(t *testing.T) { assert.Equal(t, "PROD", versionContent.CurrentStage) } +func TestDistributeVersion(t *testing.T) { + t.Skip("Skipping: requires Distribution service not yet available in the test environment") + + // Prepare + appKey := utils.GenerateUniqueKey("app-version-distribute") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.11" + + // Create a version first + 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 + err = utils.AppTrustCli.Exec("version-distribute", appKey, version) + require.NoError(t, err) +} + +func TestDeleteRemoteVersion(t *testing.T) { + t.Skip("Skipping: requires Distribution service not yet available in the test environment") + + // Prepare + appKey := utils.GenerateUniqueKey("app-version-delete-remote") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.12" + + // Create a version first and distribute it + 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) + err = utils.AppTrustCli.Exec("version-distribute", appKey, version) + require.NoError(t, err) + + // Execute + err = utils.AppTrustCli.Exec("version-delete-remote", appKey, version, "--dry-run", "--quiet") + require.NoError(t, err) +} + func TestRollbackVersion(t *testing.T) { // Prepare appKey := utils.GenerateUniqueKey("app-version-rollback") From ff07745c63a616537abcafd9155b71acc81cbd75 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 6 Jul 2026 17:27:36 +0300 Subject: [PATCH 22/22] Distribute and remotely delete application versions --- apptrust/commands/version/distribute_app_version_cmd_test.go | 2 +- .../commands/version/remote_delete_app_version_cmd_test.go | 2 +- apptrust/commands/version/version_utils_test.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apptrust/commands/version/distribute_app_version_cmd_test.go b/apptrust/commands/version/distribute_app_version_cmd_test.go index f20f3b6..da36ace 100644 --- a/apptrust/commands/version/distribute_app_version_cmd_test.go +++ b/apptrust/commands/version/distribute_app_version_cmd_test.go @@ -209,7 +209,7 @@ func TestDistributeAppVersionCommand_SpecFileSuite(t *testing.T) { content := `{"distribution_rules":[{"site_name":"site-1","city_name":"city-1","country_codes":["US"]},{"site_name":"site-2"}]}` filePath := filepath.Join(t.TempDir(), "dist-rules.json") - require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + require.NoError(t, os.WriteFile(filePath, []byte(content), 0o600)) ctx := &components.Context{} ctx.Arguments = []string{"app-key", "1.0.0"} diff --git a/apptrust/commands/version/remote_delete_app_version_cmd_test.go b/apptrust/commands/version/remote_delete_app_version_cmd_test.go index d70724e..77f2900 100644 --- a/apptrust/commands/version/remote_delete_app_version_cmd_test.go +++ b/apptrust/commands/version/remote_delete_app_version_cmd_test.go @@ -235,7 +235,7 @@ func TestRemoteDeleteAppVersionCommand_SpecFileSuite(t *testing.T) { content := `{"distribution_rules":[{"site_name":"site-1","city_name":"city-1","country_codes":["US"]},{"site_name":"site-2"}]}` filePath := filepath.Join(t.TempDir(), "dist-rules.json") - require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + require.NoError(t, os.WriteFile(filePath, []byte(content), 0o600)) ctx := &components.Context{} ctx.Arguments = []string{"app-key", "1.0.0"} diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index ff48b7c..4bae059 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -415,7 +415,7 @@ func TestParseDistributionRules(t *testing.T) { t.Run("from dist-rules file", func(t *testing.T) { content := `{"distribution_rules":[{"site_name":"site-1","city_name":"city-1","country_codes":["US"]},{"site_name":"site-2"}]}` filePath := filepath.Join(t.TempDir(), "dist-rules.json") - require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + require.NoError(t, os.WriteFile(filePath, []byte(content), 0o600)) ctx := &components.Context{} ctx.AddStringFlag(commands.DistRulesFlag, filePath) @@ -432,7 +432,7 @@ func TestParseDistributionRules(t *testing.T) { t.Run("empty dist-rules file returns no rules", func(t *testing.T) { content := `{"distribution_rules":[]}` filePath := filepath.Join(t.TempDir(), "dist-rules.json") - require.NoError(t, os.WriteFile(filePath, []byte(content), 0600)) + require.NoError(t, os.WriteFile(filePath, []byte(content), 0o600)) ctx := &components.Context{} ctx.AddStringFlag(commands.DistRulesFlag, filePath)