From 1efd928afe4555e27efee5d80aa57b64332598c1 Mon Sep 17 00:00:00 2001 From: Yuri Novo Date: Mon, 9 Feb 2026 12:12:41 +0200 Subject: [PATCH 1/3] APP-1675 - Refactor source and filter parsing into shared standalone functions --- .../version/create_app_version_cmd.go | 346 +---------------- .../version/create_app_version_cmd_test.go | 32 +- .../commands/version/version_source_parser.go | 356 ++++++++++++++++++ 3 files changed, 366 insertions(+), 368 deletions(-) create mode 100644 apptrust/commands/version/version_source_parser.go diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index 186dc64..b593a9d 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -1,8 +1,6 @@ package version import ( - "encoding/json" - "strconv" "strings" "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" @@ -17,9 +15,7 @@ import ( 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" - "github.com/jfrog/jfrog-client-go/utils/io/fileutils" ) type createAppVersionCommand struct { @@ -29,15 +25,6 @@ type createAppVersionCommand struct { sync bool } -type createVersionSpec struct { - Artifacts []model.CreateVersionArtifact `json:"artifacts,omitempty"` - Packages []model.CreateVersionPackage `json:"packages,omitempty"` - Builds []model.CreateVersionBuild `json:"builds,omitempty"` - ReleaseBundles []model.CreateVersionReleaseBundle `json:"release_bundles,omitempty"` - Versions []model.CreateVersionReference `json:"versions,omitempty"` - Filters *model.CreateVersionFilters `json:"filters,omitempty"` -} - func (cv *createAppVersionCommand) Run() error { ctx, err := service.NewContext(*cv.serverDetails) if err != nil { @@ -73,22 +60,7 @@ func (cv *createAppVersionCommand) prepareAndRunCommand(ctx *components.Context) } func (cv *createAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.CreateAppVersionRequest, error) { - var ( - sources *model.CreateVersionSources - filters *model.CreateVersionFilters - err error - ) - - if ctx.IsFlagSet(commands.SpecFlag) { - sources, filters, err = cv.loadFromSpec(ctx) - } else { - sources, err = cv.buildSourcesFromFlags(ctx) - if err != nil { - return nil, err - } - filters, err = cv.buildFiltersFromFlags(ctx) - } - + sources, filters, err := buildSourcesAndFiltersFromContext(ctx) if err != nil { return nil, err } @@ -103,313 +75,6 @@ func (cv *createAppVersionCommand) buildRequestPayload(ctx *components.Context) }, nil } -func (cv *createAppVersionCommand) buildSourcesFromFlags(ctx *components.Context) (*model.CreateVersionSources, error) { - sources := &model.CreateVersionSources{} - if buildsStr := ctx.GetStringFlagValue(commands.SourceTypeBuildsFlag); buildsStr != "" { - builds, err := cv.parseBuilds(buildsStr) - if err != nil { - return nil, err - } - sources.Builds = builds - } - if rbStr := ctx.GetStringFlagValue(commands.SourceTypeReleaseBundlesFlag); rbStr != "" { - releaseBundles, err := cv.parseReleaseBundles(rbStr) - if err != nil { - return nil, err - } - sources.ReleaseBundles = releaseBundles - } - if srcVersionsStr := ctx.GetStringFlagValue(commands.SourceTypeApplicationVersionsFlag); srcVersionsStr != "" { - sourceVersions, err := cv.parseSourceVersions(srcVersionsStr) - if err != nil { - return nil, err - } - sources.Versions = sourceVersions - } - if packagesStr := ctx.GetStringFlagValue(commands.SourceTypePackagesFlag); packagesStr != "" { - packages, err := cv.parsePackages(packagesStr) - if err != nil { - return nil, err - } - sources.Packages = packages - } - if artifactsStr := ctx.GetStringFlagValue(commands.SourceTypeArtifactsFlag); artifactsStr != "" { - artifacts, err := cv.parseArtifacts(artifactsStr) - if err != nil { - return nil, err - } - sources.Artifacts = artifacts - } - return sources, nil -} - -func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model.CreateVersionSources, *model.CreateVersionFilters, error) { - specFilePath := ctx.GetStringFlagValue(commands.SpecFlag) - spec := new(createVersionSpec) - specVars := coreutils.SpecVarsStringToMap(ctx.GetStringFlagValue(commands.SpecVarsFlag)) - content, err := fileutils.ReadFile(specFilePath) - if errorutils.CheckError(err) != nil { - return nil, nil, err - } - - if len(specVars) > 0 { - content = coreutils.ReplaceVars(content, specVars) - } - - err = json.Unmarshal(content, spec) - if errorutils.CheckError(err) != nil { - return nil, nil, err - } - - // Validation: if all sources are empty, return error - if (len(spec.Packages) == 0) && (len(spec.Builds) == 0) && (len(spec.ReleaseBundles) == 0) && (len(spec.Versions) == 0) && (len(spec.Artifacts) == 0) { - return nil, nil, errorutils.CheckErrorf("Spec file is empty: must provide at least one source (artifacts, packages, builds, release_bundles, or versions)") - } - - sources := &model.CreateVersionSources{ - Artifacts: spec.Artifacts, - Packages: spec.Packages, - Builds: spec.Builds, - ReleaseBundles: spec.ReleaseBundles, - Versions: spec.Versions, - } - - return sources, spec.Filters, nil -} - -func (cv *createAppVersionCommand) parseBuilds(buildsStr string) ([]model.CreateVersionBuild, error) { - const ( - nameField = "name" - idField = "id" - includeDepField = "include-deps" - repoKeyField = "repo-key" - startedField = "started" - ) - - var builds []model.CreateVersionBuild - buildEntries := utils.ParseSliceFlag(buildsStr) - for _, entry := range buildEntries { - buildEntryMap, err := utils.ParseKeyValueString(entry, ",") - if err != nil { - return nil, errorutils.CheckErrorf("invalid build format: %v", err) - } - err = validateRequiredFieldsInMap(buildEntryMap, nameField, idField) - if err != nil { - return nil, errorutils.CheckErrorf("invalid build format: %v", err) - } - build := model.CreateVersionBuild{ - Name: buildEntryMap[nameField], - Number: buildEntryMap[idField], - RepositoryKey: buildEntryMap[repoKeyField], - Started: buildEntryMap[startedField], - } - if _, ok := buildEntryMap[includeDepField]; ok { - includeDep, err := strconv.ParseBool(buildEntryMap[includeDepField]) - if err != nil { - return nil, errorutils.CheckErrorf("invalid build format: %v", err) - } - build.IncludeDependencies = includeDep - } - builds = append(builds, build) - } - return builds, nil -} - -func (cv *createAppVersionCommand) parseReleaseBundles(rbStr string) ([]model.CreateVersionReleaseBundle, error) { - const ( - projectKeyField = "project-key" - repoKeyField = "repo-key" - nameField = "name" - versionField = "version" - ) - - var bundles []model.CreateVersionReleaseBundle - releaseBundleEntries := utils.ParseSliceFlag(rbStr) - for _, entry := range releaseBundleEntries { - releaseBundleEntryMap, err := utils.ParseKeyValueString(entry, ",") - if err != nil { - return nil, errorutils.CheckErrorf("invalid release bundle format: %v", err) - } - err = validateRequiredFieldsInMap(releaseBundleEntryMap, nameField, versionField) - if err != nil { - return nil, errorutils.CheckErrorf("invalid release bundle format: %v", err) - } - bundles = append(bundles, model.CreateVersionReleaseBundle{ - ProjectKey: releaseBundleEntryMap[projectKeyField], - RepositoryKey: releaseBundleEntryMap[repoKeyField], - Name: releaseBundleEntryMap[nameField], - Version: releaseBundleEntryMap[versionField], - }) - } - return bundles, nil -} - -func (cv *createAppVersionCommand) parseSourceVersions(applicationVersionsStr string) ([]model.CreateVersionReference, error) { - const ( - applicationKeyField = "application-key" - versionField = "version" - ) - - var refs []model.CreateVersionReference - applicationVersionEntries := utils.ParseSliceFlag(applicationVersionsStr) - for _, entry := range applicationVersionEntries { - applicationVersionEntryMap, err := utils.ParseKeyValueString(entry, ",") - if err != nil { - return nil, errorutils.CheckErrorf("invalid application version format: %v", err) - } - err = validateRequiredFieldsInMap(applicationVersionEntryMap, applicationKeyField, versionField) - if err != nil { - return nil, errorutils.CheckErrorf("invalid application version format: %v", err) - } - refs = append(refs, model.CreateVersionReference{ - ApplicationKey: applicationVersionEntryMap[applicationKeyField], - Version: applicationVersionEntryMap[versionField], - }) - } - return refs, nil -} - -func (cv *createAppVersionCommand) parsePackages(packagesStr string) ([]model.CreateVersionPackage, error) { - const ( - typeField = "type" - nameField = "name" - versionField = "version" - repositoryField = "repo-key" - ) - - var packages []model.CreateVersionPackage - packageEntries := utils.ParseSliceFlag(packagesStr) - for _, entry := range packageEntries { - packageEntryMap, err := utils.ParseKeyValueString(entry, ",") - if err != nil { - return nil, errorutils.CheckErrorf("invalid package format: %v", err) - } - err = validateRequiredFieldsInMap(packageEntryMap, typeField, nameField, versionField, repositoryField) - if err != nil { - return nil, errorutils.CheckErrorf("invalid package format: %v", err) - } - packages = append(packages, model.CreateVersionPackage{ - Type: packageEntryMap[typeField], - Name: packageEntryMap[nameField], - Version: packageEntryMap[versionField], - Repository: packageEntryMap[repositoryField], - }) - } - return packages, nil -} - -func (cv *createAppVersionCommand) parseArtifacts(artifactsStr string) ([]model.CreateVersionArtifact, error) { - const ( - pathField = "path" - sha256Field = "sha256" - ) - - var artifacts []model.CreateVersionArtifact - artifactEntries := utils.ParseSliceFlag(artifactsStr) - for _, entry := range artifactEntries { - artifactEntryMap, err := utils.ParseKeyValueString(entry, ",") - if err != nil { - return nil, errorutils.CheckErrorf("invalid artifact format: %v", err) - } - err = validateRequiredFieldsInMap(artifactEntryMap, pathField) - if err != nil { - return nil, errorutils.CheckErrorf("invalid artifact format: %v", err) - } - artifact := model.CreateVersionArtifact{ - Path: artifactEntryMap[pathField], - SHA256: artifactEntryMap[sha256Field], - } - artifacts = append(artifacts, artifact) - } - return artifacts, nil -} - -func (cv *createAppVersionCommand) buildFiltersFromFlags(ctx *components.Context) (*model.CreateVersionFilters, error) { - includeFilterValues := ctx.GetStringsArrFlagValue(commands.IncludeFilterFlag) - excludeFilterValues := ctx.GetStringsArrFlagValue(commands.ExcludeFilterFlag) - - if len(includeFilterValues) == 0 && len(excludeFilterValues) == 0 { - return nil, nil - } - filters := &model.CreateVersionFilters{} - if includedFilters, err := cv.parseFilterValues(includeFilterValues); err != nil { - return nil, err - } else if len(includedFilters) > 0 { - filters.Included = includedFilters - } - if excludedFilters, err := cv.parseFilterValues(excludeFilterValues); err != nil { - return nil, err - } else if len(excludedFilters) > 0 { - filters.Excluded = excludedFilters - } - - return filters, nil -} - -func (cv *createAppVersionCommand) parseFilterValues(filterValues []string) ([]*model.CreateVersionSourceFilter, error) { - if len(filterValues) == 0 { - return nil, nil - } - return cv.parseFilters(filterValues) -} - -func (cv *createAppVersionCommand) parseFilters(filterStrings []string) ([]*model.CreateVersionSourceFilter, error) { - const ( - filterTypeField = "filter_type" - packageTypeField = "type" - packageNameField = "name" - packageVersionField = "version" - artifactPathField = "path" - artifactShaField = "sha256" - ) - - var filters []*model.CreateVersionSourceFilter - - for i, filterStr := range filterStrings { - filterMap, err := utils.ParseKeyValueString(filterStr, ",") - if err != nil { - return nil, errorutils.CheckErrorf("invalid filter format at index %d: %v", i, err) - } - filterType, ok := filterMap[filterTypeField] - if !ok { - return nil, errorutils.CheckErrorf("invalid filter format at index %d: missing 'filter_type' field", i) - } - filter := &model.CreateVersionSourceFilter{} - - switch filterType { - case "package": - if val, ok := filterMap[packageTypeField]; ok { - filter.PackageType = val - } - if val, ok := filterMap[packageNameField]; ok { - filter.PackageName = val - } - if val, ok := filterMap[packageVersionField]; ok { - filter.PackageVersion = val - } - if filter.PackageType == "" && filter.PackageName == "" && filter.PackageVersion == "" { - return nil, errorutils.CheckErrorf("invalid package filter at index %d: at least one of 'type', 'name', or 'version' must be specified", i) - } - case "artifact": - if val, ok := filterMap[artifactPathField]; ok { - filter.Path = val - } - if val, ok := filterMap[artifactShaField]; ok { - filter.SHA256 = val - } - if filter.Path == "" && filter.SHA256 == "" { - return nil, errorutils.CheckErrorf("invalid artifact filter at index %d: at least one of 'path' or 'sha256' must be specified", i) - } - default: - return nil, errorutils.CheckErrorf("invalid filter_type '%s' at index %d: must be 'package' or 'artifact'", filterType, i) - } - - filters = append(filters, filter) - } - - return filters, nil -} - func validateCreateAppVersionContext(ctx *components.Context) error { if err := validateNoSpecAndFlagsTogether(ctx); err != nil { return err @@ -418,14 +83,7 @@ func validateCreateAppVersionContext(ctx *components.Context) error { return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) } - hasSource := ctx.IsFlagSet(commands.SpecFlag) || - ctx.IsFlagSet(commands.SourceTypeBuildsFlag) || - ctx.IsFlagSet(commands.SourceTypeReleaseBundlesFlag) || - ctx.IsFlagSet(commands.SourceTypeApplicationVersionsFlag) || - ctx.IsFlagSet(commands.SourceTypePackagesFlag) || - ctx.IsFlagSet(commands.SourceTypeArtifactsFlag) - - if !hasSource { + if !hasSourceFlags(ctx) { return errorutils.CheckErrorf( "At least one source flag is required to create an application version. Please provide --%s or at least one of the following: --%s, --%s, --%s, --%s, --%s.", commands.SpecFlag, commands.SourceTypeBuildsFlag, commands.SourceTypeReleaseBundlesFlag, commands.SourceTypeApplicationVersionsFlag, commands.SourceTypePackagesFlag, commands.SourceTypeArtifactsFlag) diff --git a/apptrust/commands/version/create_app_version_cmd_test.go b/apptrust/commands/version/create_app_version_cmd_test.go index 618a4b2..5f5f294 100644 --- a/apptrust/commands/version/create_app_version_cmd_test.go +++ b/apptrust/commands/version/create_app_version_cmd_test.go @@ -222,8 +222,6 @@ func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { } func TestParseBuilds(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string input string @@ -275,7 +273,7 @@ func TestParseBuilds(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - builds, err := cmd.parseBuilds(tt.input) + builds, err := parseBuilds(tt.input) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -290,8 +288,6 @@ func TestParseBuilds(t *testing.T) { } func TestParseReleaseBundles(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string input string @@ -344,7 +340,7 @@ func TestParseReleaseBundles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rbs, err := cmd.parseReleaseBundles(tt.input) + rbs, err := parseReleaseBundles(tt.input) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -359,8 +355,6 @@ func TestParseReleaseBundles(t *testing.T) { } func TestParseSourceVersions(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string input string @@ -405,7 +399,7 @@ func TestParseSourceVersions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svs, err := cmd.parseSourceVersions(tt.input) + svs, err := parseSourceVersions(tt.input) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -420,8 +414,6 @@ func TestParseSourceVersions(t *testing.T) { } func TestParsePackages(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string input string @@ -478,7 +470,7 @@ func TestParsePackages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - packages, err := cmd.parsePackages(tt.input) + packages, err := parsePackages(tt.input) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -493,8 +485,6 @@ func TestParsePackages(t *testing.T) { } func TestParseArtifacts(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string input string @@ -533,7 +523,7 @@ func TestParseArtifacts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - artifacts, err := cmd.parseArtifacts(tt.input) + artifacts, err := parseArtifacts(tt.input) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -1182,8 +1172,6 @@ func TestValidateRequiredFieldsInMap(t *testing.T) { } func TestParseFilters(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string input []string @@ -1309,7 +1297,7 @@ func TestParseFilters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - filters, err := cmd.parseFilters(tt.input) + filters, err := parseFilters(tt.input) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -1331,8 +1319,6 @@ func TestParseFilters(t *testing.T) { } func TestBuildFiltersFromFlags(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string ctxSetup func(*components.Context) @@ -1423,7 +1409,7 @@ func TestBuildFiltersFromFlags(t *testing.T) { ctx := &components.Context{} tt.ctxSetup(ctx) - filters, err := cmd.buildFiltersFromFlags(ctx) + filters, err := buildFiltersFromFlags(ctx) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { @@ -1462,8 +1448,6 @@ func TestBuildFiltersFromFlags(t *testing.T) { } func TestLoadFromSpec_WithFilters(t *testing.T) { - cmd := &createAppVersionCommand{} - tests := []struct { name string specPath string @@ -1522,7 +1506,7 @@ func TestLoadFromSpec_WithFilters(t *testing.T) { ctx.AddStringFlag(commands.SpecFlag, tt.specPath) ctx.AddStringFlag("url", "https://example.com") - sources, filters, err := cmd.loadFromSpec(ctx) + sources, filters, err := loadSourcesFromSpec(ctx) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { diff --git a/apptrust/commands/version/version_source_parser.go b/apptrust/commands/version/version_source_parser.go new file mode 100644 index 0000000..b856ab9 --- /dev/null +++ b/apptrust/commands/version/version_source_parser.go @@ -0,0 +1,356 @@ +package version + +import ( + "encoding/json" + "strconv" + + "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/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" +) + +type versionSpec struct { + Artifacts []model.CreateVersionArtifact `json:"artifacts,omitempty"` + Packages []model.CreateVersionPackage `json:"packages,omitempty"` + Builds []model.CreateVersionBuild `json:"builds,omitempty"` + ReleaseBundles []model.CreateVersionReleaseBundle `json:"release_bundles,omitempty"` + Versions []model.CreateVersionReference `json:"versions,omitempty"` + Filters *model.CreateVersionFilters `json:"filters,omitempty"` +} + +// hasSourceFlags returns true if any source flag or --spec is set in the context. +func hasSourceFlags(ctx *components.Context) bool { + return ctx.IsFlagSet(commands.SpecFlag) || + ctx.IsFlagSet(commands.SourceTypeBuildsFlag) || + ctx.IsFlagSet(commands.SourceTypeReleaseBundlesFlag) || + ctx.IsFlagSet(commands.SourceTypeApplicationVersionsFlag) || + ctx.IsFlagSet(commands.SourceTypePackagesFlag) || + ctx.IsFlagSet(commands.SourceTypeArtifactsFlag) +} + +// buildSourcesAndFiltersFromContext parses sources and filters from either a spec file or CLI flags. +func buildSourcesAndFiltersFromContext(ctx *components.Context) (*model.CreateVersionSources, *model.CreateVersionFilters, error) { + if ctx.IsFlagSet(commands.SpecFlag) { + return loadSourcesFromSpec(ctx) + } + sources, err := buildSourcesFromFlags(ctx) + if err != nil { + return nil, nil, err + } + filters, err := buildFiltersFromFlags(ctx) + if err != nil { + return nil, nil, err + } + return sources, filters, nil +} + +func buildSourcesFromFlags(ctx *components.Context) (*model.CreateVersionSources, error) { + sources := &model.CreateVersionSources{} + if buildsStr := ctx.GetStringFlagValue(commands.SourceTypeBuildsFlag); buildsStr != "" { + builds, err := parseBuilds(buildsStr) + if err != nil { + return nil, err + } + sources.Builds = builds + } + if rbStr := ctx.GetStringFlagValue(commands.SourceTypeReleaseBundlesFlag); rbStr != "" { + releaseBundles, err := parseReleaseBundles(rbStr) + if err != nil { + return nil, err + } + sources.ReleaseBundles = releaseBundles + } + if srcVersionsStr := ctx.GetStringFlagValue(commands.SourceTypeApplicationVersionsFlag); srcVersionsStr != "" { + sourceVersions, err := parseSourceVersions(srcVersionsStr) + if err != nil { + return nil, err + } + sources.Versions = sourceVersions + } + if packagesStr := ctx.GetStringFlagValue(commands.SourceTypePackagesFlag); packagesStr != "" { + packages, err := parsePackages(packagesStr) + if err != nil { + return nil, err + } + sources.Packages = packages + } + if artifactsStr := ctx.GetStringFlagValue(commands.SourceTypeArtifactsFlag); artifactsStr != "" { + artifacts, err := parseArtifacts(artifactsStr) + if err != nil { + return nil, err + } + sources.Artifacts = artifacts + } + return sources, nil +} + +func loadSourcesFromSpec(ctx *components.Context) (*model.CreateVersionSources, *model.CreateVersionFilters, error) { + specFilePath := ctx.GetStringFlagValue(commands.SpecFlag) + spec := new(versionSpec) + specVars := coreutils.SpecVarsStringToMap(ctx.GetStringFlagValue(commands.SpecVarsFlag)) + content, err := fileutils.ReadFile(specFilePath) + if errorutils.CheckError(err) != nil { + return nil, nil, err + } + + if len(specVars) > 0 { + content = coreutils.ReplaceVars(content, specVars) + } + + err = json.Unmarshal(content, spec) + if errorutils.CheckError(err) != nil { + return nil, nil, err + } + + // Validation: if all sources are empty, return error + if (len(spec.Packages) == 0) && (len(spec.Builds) == 0) && (len(spec.ReleaseBundles) == 0) && (len(spec.Versions) == 0) && (len(spec.Artifacts) == 0) { + return nil, nil, errorutils.CheckErrorf("Spec file is empty: must provide at least one source (artifacts, packages, builds, release_bundles, or versions)") + } + + sources := &model.CreateVersionSources{ + Artifacts: spec.Artifacts, + Packages: spec.Packages, + Builds: spec.Builds, + ReleaseBundles: spec.ReleaseBundles, + Versions: spec.Versions, + } + + return sources, spec.Filters, nil +} + +func parseBuilds(buildsStr string) ([]model.CreateVersionBuild, error) { + const ( + nameField = "name" + idField = "id" + includeDepField = "include-deps" + repoKeyField = "repo-key" + startedField = "started" + ) + + var builds []model.CreateVersionBuild + buildEntries := utils.ParseSliceFlag(buildsStr) + for _, entry := range buildEntries { + buildEntryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid build format: %v", err) + } + err = validateRequiredFieldsInMap(buildEntryMap, nameField, idField) + if err != nil { + return nil, errorutils.CheckErrorf("invalid build format: %v", err) + } + build := model.CreateVersionBuild{ + Name: buildEntryMap[nameField], + Number: buildEntryMap[idField], + RepositoryKey: buildEntryMap[repoKeyField], + Started: buildEntryMap[startedField], + } + if _, ok := buildEntryMap[includeDepField]; ok { + includeDep, err := strconv.ParseBool(buildEntryMap[includeDepField]) + if err != nil { + return nil, errorutils.CheckErrorf("invalid build format: %v", err) + } + build.IncludeDependencies = includeDep + } + builds = append(builds, build) + } + return builds, nil +} + +func parseReleaseBundles(rbStr string) ([]model.CreateVersionReleaseBundle, error) { + const ( + projectKeyField = "project-key" + repoKeyField = "repo-key" + nameField = "name" + versionField = "version" + ) + + var bundles []model.CreateVersionReleaseBundle + releaseBundleEntries := utils.ParseSliceFlag(rbStr) + for _, entry := range releaseBundleEntries { + releaseBundleEntryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid release bundle format: %v", err) + } + err = validateRequiredFieldsInMap(releaseBundleEntryMap, nameField, versionField) + if err != nil { + return nil, errorutils.CheckErrorf("invalid release bundle format: %v", err) + } + bundles = append(bundles, model.CreateVersionReleaseBundle{ + ProjectKey: releaseBundleEntryMap[projectKeyField], + RepositoryKey: releaseBundleEntryMap[repoKeyField], + Name: releaseBundleEntryMap[nameField], + Version: releaseBundleEntryMap[versionField], + }) + } + return bundles, nil +} + +func parseSourceVersions(applicationVersionsStr string) ([]model.CreateVersionReference, error) { + const ( + applicationKeyField = "application-key" + versionField = "version" + ) + + var refs []model.CreateVersionReference + applicationVersionEntries := utils.ParseSliceFlag(applicationVersionsStr) + for _, entry := range applicationVersionEntries { + applicationVersionEntryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid application version format: %v", err) + } + err = validateRequiredFieldsInMap(applicationVersionEntryMap, applicationKeyField, versionField) + if err != nil { + return nil, errorutils.CheckErrorf("invalid application version format: %v", err) + } + refs = append(refs, model.CreateVersionReference{ + ApplicationKey: applicationVersionEntryMap[applicationKeyField], + Version: applicationVersionEntryMap[versionField], + }) + } + return refs, nil +} + +func parsePackages(packagesStr string) ([]model.CreateVersionPackage, error) { + const ( + typeField = "type" + nameField = "name" + versionField = "version" + repositoryField = "repo-key" + ) + + var packages []model.CreateVersionPackage + packageEntries := utils.ParseSliceFlag(packagesStr) + for _, entry := range packageEntries { + packageEntryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid package format: %v", err) + } + err = validateRequiredFieldsInMap(packageEntryMap, typeField, nameField, versionField, repositoryField) + if err != nil { + return nil, errorutils.CheckErrorf("invalid package format: %v", err) + } + packages = append(packages, model.CreateVersionPackage{ + Type: packageEntryMap[typeField], + Name: packageEntryMap[nameField], + Version: packageEntryMap[versionField], + Repository: packageEntryMap[repositoryField], + }) + } + return packages, nil +} + +func parseArtifacts(artifactsStr string) ([]model.CreateVersionArtifact, error) { + const ( + pathField = "path" + sha256Field = "sha256" + ) + + var artifacts []model.CreateVersionArtifact + artifactEntries := utils.ParseSliceFlag(artifactsStr) + for _, entry := range artifactEntries { + artifactEntryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid artifact format: %v", err) + } + err = validateRequiredFieldsInMap(artifactEntryMap, pathField) + if err != nil { + return nil, errorutils.CheckErrorf("invalid artifact format: %v", err) + } + artifact := model.CreateVersionArtifact{ + Path: artifactEntryMap[pathField], + SHA256: artifactEntryMap[sha256Field], + } + artifacts = append(artifacts, artifact) + } + return artifacts, nil +} + +func buildFiltersFromFlags(ctx *components.Context) (*model.CreateVersionFilters, error) { + includeFilterValues := ctx.GetStringsArrFlagValue(commands.IncludeFilterFlag) + excludeFilterValues := ctx.GetStringsArrFlagValue(commands.ExcludeFilterFlag) + + if len(includeFilterValues) == 0 && len(excludeFilterValues) == 0 { + return nil, nil + } + filters := &model.CreateVersionFilters{} + if includedFilters, err := parseFilterValues(includeFilterValues); err != nil { + return nil, err + } else if len(includedFilters) > 0 { + filters.Included = includedFilters + } + if excludedFilters, err := parseFilterValues(excludeFilterValues); err != nil { + return nil, err + } else if len(excludedFilters) > 0 { + filters.Excluded = excludedFilters + } + + return filters, nil +} + +func parseFilterValues(filterValues []string) ([]*model.CreateVersionSourceFilter, error) { + if len(filterValues) == 0 { + return nil, nil + } + return parseFilters(filterValues) +} + +func parseFilters(filterStrings []string) ([]*model.CreateVersionSourceFilter, error) { + const ( + filterTypeField = "filter_type" + packageTypeField = "type" + packageNameField = "name" + packageVersionField = "version" + artifactPathField = "path" + artifactShaField = "sha256" + ) + + var filters []*model.CreateVersionSourceFilter + + for i, filterStr := range filterStrings { + filterMap, err := utils.ParseKeyValueString(filterStr, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid filter format at index %d: %v", i, err) + } + filterType, ok := filterMap[filterTypeField] + if !ok { + return nil, errorutils.CheckErrorf("invalid filter format at index %d: missing 'filter_type' field", i) + } + filter := &model.CreateVersionSourceFilter{} + + switch filterType { + case "package": + if val, ok := filterMap[packageTypeField]; ok { + filter.PackageType = val + } + if val, ok := filterMap[packageNameField]; ok { + filter.PackageName = val + } + if val, ok := filterMap[packageVersionField]; ok { + filter.PackageVersion = val + } + if filter.PackageType == "" && filter.PackageName == "" && filter.PackageVersion == "" { + return nil, errorutils.CheckErrorf("invalid package filter at index %d: at least one of 'type', 'name', or 'version' must be specified", i) + } + case "artifact": + if val, ok := filterMap[artifactPathField]; ok { + filter.Path = val + } + if val, ok := filterMap[artifactShaField]; ok { + filter.SHA256 = val + } + if filter.Path == "" && filter.SHA256 == "" { + return nil, errorutils.CheckErrorf("invalid artifact filter at index %d: at least one of 'path' or 'sha256' must be specified", i) + } + default: + return nil, errorutils.CheckErrorf("invalid filter_type '%s' at index %d: must be 'package' or 'artifact'", filterType, i) + } + + filters = append(filters, filter) + } + + return filters, nil +} From fa03d380759476fa89d5df5ae1f3a64af21f4739 Mon Sep 17 00:00:00 2001 From: Yuri Novo Date: Mon, 9 Feb 2026 12:13:56 +0200 Subject: [PATCH 2/3] APP-1675 - Add version-update-sources command for draft version source updates --- apptrust/commands/flags.go | 45 ++- .../version/create_app_version_cmd.go | 47 +-- .../version/create_app_version_cmd_test.go | 4 +- .../version/update_app_version_sources_cmd.go | 134 +++++++++ .../update_app_version_sources_cmd_test.go | 274 ++++++++++++++++++ .../commands/version/version_source_parser.go | 48 +++ apptrust/http/http_client.go | 6 +- apptrust/http/mocks/http_client_mock.go | 8 +- .../model/update_version_sources_request.go | 6 + .../applications/application_service.go | 2 +- .../versions/mocks/version_service_mock.go | 14 + apptrust/service/versions/version_service.go | 32 +- .../service/versions/version_service_test.go | 135 ++++++++- cli/cli.go | 1 + 14 files changed, 686 insertions(+), 70 deletions(-) create mode 100644 apptrust/commands/version/update_app_version_sources_cmd.go create mode 100644 apptrust/commands/version/update_app_version_sources_cmd_test.go create mode 100644 apptrust/model/update_version_sources_request.go diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 172bf84..0f38c9f 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -8,18 +8,19 @@ import ( ) const ( - Ping = "ping" - VersionCreate = "version-create" - VersionPromote = "version-promote" - VersionRollback = "version-rollback" - VersionDelete = "version-delete" - VersionRelease = "version-release" - VersionUpdate = "version-update" - PackageBind = "package-bind" - PackageUnbind = "package-unbind" - AppCreate = "app-create" - AppUpdate = "app-update" - AppDelete = "app-delete" + Ping = "ping" + VersionCreate = "version-create" + VersionPromote = "version-promote" + VersionRollback = "version-rollback" + VersionDelete = "version-delete" + VersionRelease = "version-release" + VersionUpdate = "version-update" + VersionUpdateSources = "version-update-sources" + PackageBind = "package-bind" + PackageUnbind = "package-unbind" + AppCreate = "app-create" + AppUpdate = "app-update" + AppDelete = "app-delete" ) const ( @@ -44,6 +45,7 @@ const ( SyncFlag = "sync" PromotionTypeFlag = "promotion-type" DryRunFlag = "dry-run" + FailFastFlag = "fail-fast" ExcludeReposFlag = "exclude-repos" IncludeReposFlag = "include-repos" PropsFlag = "props" @@ -85,6 +87,7 @@ var flagsMap = map[string]components.Flag{ SyncFlag: components.NewBoolFlag(SyncFlag, "Whether to synchronize the operation.", components.WithBoolDefaultValueTrue()), PromotionTypeFlag: components.NewStringFlag(PromotionTypeFlag, "The promotion type. The following values are supported: "+coreutils.ListToText(model.PromotionTypeValues), func(f *components.StringFlag) { f.Mandatory = false; f.DefaultValue = model.PromotionTypeCopy }), DryRunFlag: components.NewBoolFlag(DryRunFlag, "Perform a simulation of the operation.", components.WithBoolDefaultValueFalse()), + FailFastFlag: components.NewBoolFlag(FailFastFlag, "Stop the operation on the first error. Only relevant when sources are provided.", components.WithBoolDefaultValueTrue()), ExcludeReposFlag: components.NewStringFlag(ExcludeReposFlag, "Semicolon-separated list of repositories to exclude.", func(f *components.StringFlag) { f.Mandatory = false }), IncludeReposFlag: components.NewStringFlag(IncludeReposFlag, "Semicolon-separated list of repositories to include.", func(f *components.StringFlag) { f.Mandatory = false }), PropsFlag: components.NewStringFlag(PropsFlag, "Semicolon-separated list of properties in the form of 'key1=value1;key2=value2;...' to be added to each artifact.", func(f *components.StringFlag) { f.Mandatory = false }), @@ -168,6 +171,24 @@ var commandFlags = map[string][]string{ PropertiesFlag, DeletePropertiesFlag, }, + VersionUpdateSources: { + url, + user, + accessToken, + serverId, + SyncFlag, + DryRunFlag, + FailFastFlag, + SourceTypeBuildsFlag, + SourceTypeReleaseBundlesFlag, + SourceTypeApplicationVersionsFlag, + SourceTypePackagesFlag, + SourceTypeArtifactsFlag, + SpecFlag, + SpecVarsFlag, + IncludeFilterFlag, + ExcludeFilterFlag, + }, PackageBind: { url, diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index b593a9d..45dd526 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -1,8 +1,6 @@ package version import ( - "strings" - "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" "github.com/jfrog/jfrog-cli-application/apptrust/app" @@ -82,14 +80,7 @@ func validateCreateAppVersionContext(ctx *components.Context) error { if len(ctx.Arguments) != 2 { return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) } - - if !hasSourceFlags(ctx) { - return errorutils.CheckErrorf( - "At least one source flag is required to create an application version. Please provide --%s or at least one of the following: --%s, --%s, --%s, --%s, --%s.", - commands.SpecFlag, commands.SourceTypeBuildsFlag, commands.SourceTypeReleaseBundlesFlag, commands.SourceTypeApplicationVersionsFlag, commands.SourceTypePackagesFlag, commands.SourceTypeArtifactsFlag) - } - - return nil + return validateAtLeastOneSourceFlag(ctx) } func GetCreateAppVersionCommand(appContext app.Context) components.Command { @@ -116,39 +107,3 @@ func GetCreateAppVersionCommand(appContext app.Context) components.Command { } } -// Returns error if both --spec and any other source flag or filter flag are set -func validateNoSpecAndFlagsTogether(ctx *components.Context) error { - if ctx.IsFlagSet(commands.SpecFlag) { - otherSourceFlags := []string{ - commands.SourceTypeBuildsFlag, - commands.SourceTypeReleaseBundlesFlag, - commands.SourceTypeApplicationVersionsFlag, - commands.SourceTypePackagesFlag, - commands.SourceTypeArtifactsFlag, - } - for _, flag := range otherSourceFlags { - if ctx.IsFlagSet(flag) { - return errorutils.CheckErrorf("--spec provided: all other source flags (e.g., --%s) are not allowed.", flag) - } - } - if ctx.IsFlagSet(commands.IncludeFilterFlag) { - return errorutils.CheckErrorf("--spec provided: filter flags (e.g., --%s) are not allowed.", commands.IncludeFilterFlag) - } - if ctx.IsFlagSet(commands.ExcludeFilterFlag) { - return errorutils.CheckErrorf("--spec provided: filter flags (e.g., --%s) are not allowed.", commands.ExcludeFilterFlag) - } - } - return nil -} - -func validateRequiredFieldsInMap(m map[string]string, requiredFields ...string) error { - if m == nil { - return errorutils.CheckErrorf("missing required fields: %v", strings.Join(requiredFields, ", ")) - } - for _, field := range requiredFields { - if _, exists := m[field]; !exists { - return errorutils.CheckErrorf("missing required field: %s", field) - } - } - return nil -} diff --git a/apptrust/commands/version/create_app_version_cmd_test.go b/apptrust/commands/version/create_app_version_cmd_test.go index 5f5f294..fdbb4e4 100644 --- a/apptrust/commands/version/create_app_version_cmd_test.go +++ b/apptrust/commands/version/create_app_version_cmd_test.go @@ -171,7 +171,7 @@ func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { }, expectsPayload: nil, expectsError: true, - errorContains: "At least one source flag is required to create an application version. Please provide --spec or at least one of the following: --source-type-builds, --source-type-release-bundles, --source-type-application-versions, --source-type-packages, --source-type-artifacts.", + errorContains: "At least one source flag is required.", }, { name: "empty flags", @@ -180,7 +180,7 @@ func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { }, expectsPayload: nil, expectsError: true, - errorContains: "At least one source flag is required to create an application version. Please provide --spec or at least one of the following: --source-type-builds, --source-type-release-bundles, --source-type-application-versions, --source-type-packages, --source-type-artifacts.", + errorContains: "At least one source flag is required.", }, } diff --git a/apptrust/commands/version/update_app_version_sources_cmd.go b/apptrust/commands/version/update_app_version_sources_cmd.go new file mode 100644 index 0000000..3a70187 --- /dev/null +++ b/apptrust/commands/version/update_app_version_sources_cmd.go @@ -0,0 +1,134 @@ +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" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type updateAppVersionSourcesCommand struct { + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + applicationKey string + version string + requestPayload *model.UpdateVersionSourcesRequest + sync bool + dryRun bool + failFast bool +} + +func (cmd *updateAppVersionSourcesCommand) Run() error { + ctx, err := service.NewContext(*cmd.serverDetails) + if err != nil { + log.Error("Failed to create service context:", err) + return err + } + + err = cmd.versionService.UpdateAppVersionSources(ctx, cmd.applicationKey, cmd.version, cmd.requestPayload, cmd.sync, cmd.dryRun, cmd.failFast) + if err != nil { + log.Error("Failed to update application version sources:", err) + return err + } + + return nil +} + +func (cmd *updateAppVersionSourcesCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return cmd.serverDetails, nil +} + +func (cmd *updateAppVersionSourcesCommand) CommandName() string { + return commands.VersionUpdateSources +} + +func (cmd *updateAppVersionSourcesCommand) prepareAndRunCommand(ctx *components.Context) error { + if err := validateUpdateSourcesContext(ctx); err != nil { + return err + } + + if err := cmd.parseFlagsAndSetFields(ctx); err != nil { + return err + } + + var err error + cmd.requestPayload, err = cmd.buildRequestPayload(ctx) + if errorutils.CheckError(err) != nil { + return err + } + + return commonCLiCommands.Exec(cmd) +} + +func validateUpdateSourcesContext(ctx *components.Context) error { + if len(ctx.Arguments) != 2 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + if err := validateNoSpecAndFlagsTogether(ctx); err != nil { + return err + } + return validateAtLeastOneSourceFlag(ctx) +} + +// parseFlagsAndSetFields parses CLI flags and sets struct fields accordingly. +func (cmd *updateAppVersionSourcesCommand) parseFlagsAndSetFields(ctx *components.Context) error { + cmd.applicationKey = ctx.Arguments[0] + cmd.version = ctx.Arguments[1] + + serverDetails, err := utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + cmd.serverDetails = serverDetails + + cmd.sync = ctx.GetBoolTFlagValue(commands.SyncFlag) + cmd.dryRun = ctx.GetBoolFlagValue(commands.DryRunFlag) + cmd.failFast = ctx.GetBoolTFlagValue(commands.FailFastFlag) + + return nil +} + +func (cmd *updateAppVersionSourcesCommand) buildRequestPayload(ctx *components.Context) (*model.UpdateVersionSourcesRequest, error) { + sources, filters, err := buildSourcesAndFiltersFromContext(ctx) + if err != nil { + return nil, err + } + + return &model.UpdateVersionSourcesRequest{ + AddSources: sources, + Filters: filters, + }, nil +} + +func GetUpdateAppVersionSourcesCommand(appContext app.Context) components.Command { + cmd := &updateAppVersionSourcesCommand{versionService: appContext.GetVersionService()} + return components.Command{ + Name: commands.VersionUpdateSources, + Description: "Updates the sources for a draft application version.", + Category: common.CategoryVersion, + Aliases: []string{"vus"}, + Arguments: []components.Argument{ + { + Name: "app-key", + Description: "The application key of the application for which the version sources are being updated.", + Optional: false, + }, + { + Name: "version", + Description: "The version number (in SemVer format) for the application version to update sources.", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.VersionUpdateSources), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/apptrust/commands/version/update_app_version_sources_cmd_test.go b/apptrust/commands/version/update_app_version_sources_cmd_test.go new file mode 100644 index 0000000..a7116ce --- /dev/null +++ b/apptrust/commands/version/update_app_version_sources_cmd_test.go @@ -0,0 +1,274 @@ +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 TestUpdateAppVersionSourcesCommand_Run(t *testing.T) { + tests := []struct { + name string + request *model.UpdateVersionSourcesRequest + shouldError bool + errorMessage string + }{ + { + name: "success", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "1.0.0", Repository: "npm-local"}, + }, + }, + }, + }, + { + name: "service error", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Builds: []model.CreateVersionBuild{ + {Name: "build1", Number: "100"}, + }, + }, + }, + shouldError: true, + errorMessage: "service error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockVersionService := mockversions.NewMockVersionService(ctrl) + if tt.shouldError { + mockVersionService.EXPECT().UpdateAppVersionSources(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New(tt.errorMessage)).Times(1) + } else { + mockVersionService.EXPECT().UpdateAppVersionSources(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil).Times(1) + } + + cmd := &updateAppVersionSourcesCommand{ + versionService: mockVersionService, + serverDetails: &config.ServerDetails{Url: "https://example.com"}, + applicationKey: "app-key", + version: "1.0.0", + requestPayload: tt.request, + sync: true, + dryRun: false, + failFast: true, + } + err := cmd.Run() + + if tt.shouldError { + assert.Error(t, err) + assert.Equal(t, tt.errorMessage, err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUpdateAppVersionSourcesCommand_SourceFlagsSuite(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectsError bool + errorContains string + expectsPayload *model.UpdateVersionSourcesRequest + expectsSync bool + expectsDryRun bool + expectsFailFast bool + }{ + { + name: "update with source-type-packages and default flags", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SourceTypePackagesFlag, "type=npm,name=pkg,version=2.0.0,repo-key=npm-local") + }, + expectsPayload: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "2.0.0", Repository: "npm-local"}, + }, + }, + }, + expectsSync: true, + expectsDryRun: false, + expectsFailFast: true, + }, + { + name: "update with source-type-builds", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SourceTypeBuildsFlag, "name=build1,id=100") + }, + expectsPayload: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Builds: []model.CreateVersionBuild{ + {Name: "build1", Number: "100"}, + }, + }, + }, + expectsSync: true, + expectsDryRun: false, + expectsFailFast: true, + }, + { + name: "update with explicit sync=false dry-run=true fail-fast=false", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SourceTypePackagesFlag, "type=npm,name=pkg,version=2.0.0,repo-key=npm-local") + ctx.AddBoolFlag(commands.SyncFlag, false) + ctx.AddBoolFlag(commands.DryRunFlag, true) + ctx.AddBoolFlag(commands.FailFastFlag, false) + }, + expectsPayload: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "2.0.0", Repository: "npm-local"}, + }, + }, + }, + expectsSync: false, + expectsDryRun: true, + expectsFailFast: false, + }, + { + name: "update with spec file", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + }, + expectsPayload: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg-min", Version: "0.1.0", Repository: "repo-min"}, + }, + }, + }, + expectsSync: true, + expectsDryRun: false, + expectsFailFast: true, + }, + { + name: "update with spec file containing sources and filters", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/filters-spec.json") + }, + expectsPayload: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg-with-filters", Version: "1.0.0", Repository: "repo-filters"}, + }, + }, + Filters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + { + PackageType: "docker", + PackageName: "frontend-*", + }, + }, + Excluded: []*model.CreateVersionSourceFilter{ + { + Path: "libs/vulnerable-*.jar", + }, + }, + }, + }, + expectsSync: true, + expectsDryRun: false, + expectsFailFast: true, + }, + { + name: "update with spec file and spec-vars", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/with-vars-spec.json") + ctx.AddStringFlag(commands.SpecVarsFlag, "PKG_NAME=my-package;PKG_VERSION=3.0.0;PKG_REPO=npm-prod") + }, + expectsPayload: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "my-package", Version: "3.0.0", Repository: "npm-prod"}, + }, + }, + }, + expectsSync: true, + expectsDryRun: false, + expectsFailFast: true, + }, + { + name: "spec and source flag together returns error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.SourceTypePackagesFlag, "type=npm,name=pkg,version=1.0.0,repo-key=repo") + }, + expectsError: true, + errorContains: "are not allowed", + }, + { + name: "no source flags returns error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + }, + expectsError: true, + errorContains: "At least one source flag is required", + }, + } + + 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.UpdateVersionSourcesRequest + var capturedSync, capturedDryRun, capturedFailFast bool + mockVersionService := mockversions.NewMockVersionService(ctrl) + if !tt.expectsError { + mockVersionService.EXPECT().UpdateAppVersionSources(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, _ string, _ string, req *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) error { + actualPayload = req + capturedSync = sync + capturedDryRun = dryRun + capturedFailFast = failFast + return nil + }).Times(1) + } + + cmd := &updateAppVersionSourcesCommand{ + 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) + assert.Equal(t, tt.expectsSync, capturedSync, "sync flag mismatch") + assert.Equal(t, tt.expectsDryRun, capturedDryRun, "dryRun flag mismatch") + assert.Equal(t, tt.expectsFailFast, capturedFailFast, "failFast flag mismatch") + } + }) + } +} diff --git a/apptrust/commands/version/version_source_parser.go b/apptrust/commands/version/version_source_parser.go index b856ab9..6be66eb 100644 --- a/apptrust/commands/version/version_source_parser.go +++ b/apptrust/commands/version/version_source_parser.go @@ -3,6 +3,7 @@ package version import ( "encoding/json" "strconv" + "strings" "github.com/jfrog/jfrog-cli-application/apptrust/commands" "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" @@ -22,6 +23,53 @@ type versionSpec struct { Filters *model.CreateVersionFilters `json:"filters,omitempty"` } +// validateNoSpecAndFlagsTogether returns error if both --spec and any other source flag or filter flag are set. +func validateNoSpecAndFlagsTogether(ctx *components.Context) error { + if ctx.IsFlagSet(commands.SpecFlag) { + otherSourceFlags := []string{ + commands.SourceTypeBuildsFlag, + commands.SourceTypeReleaseBundlesFlag, + commands.SourceTypeApplicationVersionsFlag, + commands.SourceTypePackagesFlag, + commands.SourceTypeArtifactsFlag, + } + for _, flag := range otherSourceFlags { + if ctx.IsFlagSet(flag) { + return errorutils.CheckErrorf("--spec provided: all other source flags (e.g., --%s) are not allowed.", flag) + } + } + if ctx.IsFlagSet(commands.IncludeFilterFlag) { + return errorutils.CheckErrorf("--spec provided: filter flags (e.g., --%s) are not allowed.", commands.IncludeFilterFlag) + } + if ctx.IsFlagSet(commands.ExcludeFilterFlag) { + return errorutils.CheckErrorf("--spec provided: filter flags (e.g., --%s) are not allowed.", commands.ExcludeFilterFlag) + } + } + return nil +} + +// validateAtLeastOneSourceFlag returns error if no source flags or --spec is set. +func validateAtLeastOneSourceFlag(ctx *components.Context) error { + if !hasSourceFlags(ctx) { + return errorutils.CheckErrorf( + "At least one source flag is required. Please provide --%s or at least one of the following: --%s, --%s, --%s, --%s, --%s.", + commands.SpecFlag, commands.SourceTypeBuildsFlag, commands.SourceTypeReleaseBundlesFlag, commands.SourceTypeApplicationVersionsFlag, commands.SourceTypePackagesFlag, commands.SourceTypeArtifactsFlag) + } + return nil +} + +func validateRequiredFieldsInMap(m map[string]string, requiredFields ...string) error { + if m == nil { + return errorutils.CheckErrorf("missing required fields: %v", strings.Join(requiredFields, ", ")) + } + for _, field := range requiredFields { + if _, exists := m[field]; !exists { + return errorutils.CheckErrorf("missing required field: %s", field) + } + } + return nil +} + // hasSourceFlags returns true if any source flag or --spec is set in the context. func hasSourceFlags(ctx *components.Context) bool { return ctx.IsFlagSet(commands.SpecFlag) || diff --git a/apptrust/http/http_client.go b/apptrust/http/http_client.go index 5515a1d..555f0f1 100644 --- a/apptrust/http/http_client.go +++ b/apptrust/http/http_client.go @@ -25,7 +25,7 @@ type ApptrustHttpClient interface { GetHttpClient() *jfroghttpclient.JfrogHttpClient Post(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) Get(path string, params map[string]string) (resp *http.Response, body []byte, err error) - Patch(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) + Patch(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) Delete(path string, params map[string]string) (resp *http.Response, body []byte, err error) } @@ -112,8 +112,8 @@ func (c *apptrustHttpClient) Get(path string, params map[string]string) (resp *h return response, body, err } -func (c *apptrustHttpClient) Patch(path string, requestBody interface{}) (resp *http.Response, body []byte, err error) { - url, err := utils.BuildUrl(c.serverDetails.Url, apptrustApiPath+path, nil) +func (c *apptrustHttpClient) Patch(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) { + url, err := utils.BuildUrl(c.serverDetails.Url, apptrustApiPath+path, params) if err != nil { return nil, nil, err } diff --git a/apptrust/http/mocks/http_client_mock.go b/apptrust/http/mocks/http_client_mock.go index fa81382..107ae4a 100644 --- a/apptrust/http/mocks/http_client_mock.go +++ b/apptrust/http/mocks/http_client_mock.go @@ -88,9 +88,9 @@ func (mr *MockApptrustHttpClientMockRecorder) GetHttpClient() *gomock.Call { } // Patch mocks base method. -func (m *MockApptrustHttpClient) Patch(path string, requestBody any) (*http.Response, []byte, error) { +func (m *MockApptrustHttpClient) Patch(path string, requestBody any, params map[string]string) (*http.Response, []byte, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Patch", path, requestBody) + ret := m.ctrl.Call(m, "Patch", path, requestBody, params) ret0, _ := ret[0].(*http.Response) ret1, _ := ret[1].([]byte) ret2, _ := ret[2].(error) @@ -98,9 +98,9 @@ func (m *MockApptrustHttpClient) Patch(path string, requestBody any) (*http.Resp } // Patch indicates an expected call of Patch. -func (mr *MockApptrustHttpClientMockRecorder) Patch(path, requestBody any) *gomock.Call { +func (mr *MockApptrustHttpClientMockRecorder) Patch(path, requestBody, params any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockApptrustHttpClient)(nil).Patch), path, requestBody) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockApptrustHttpClient)(nil).Patch), path, requestBody, params) } // Post mocks base method. diff --git a/apptrust/model/update_version_sources_request.go b/apptrust/model/update_version_sources_request.go new file mode 100644 index 0000000..ddf3e68 --- /dev/null +++ b/apptrust/model/update_version_sources_request.go @@ -0,0 +1,6 @@ +package model + +type UpdateVersionSourcesRequest struct { + AddSources *CreateVersionSources `json:"add_sources,omitempty"` + Filters *CreateVersionFilters `json:"filters,omitempty"` +} diff --git a/apptrust/service/applications/application_service.go b/apptrust/service/applications/application_service.go index f931c6b..2490b9b 100644 --- a/apptrust/service/applications/application_service.go +++ b/apptrust/service/applications/application_service.go @@ -43,7 +43,7 @@ func (as *applicationService) CreateApplication(ctx service.Context, requestBody func (as *applicationService) UpdateApplication(ctx service.Context, requestBody *model.AppDescriptor) error { endpoint := fmt.Sprintf("/v1/applications/%s", requestBody.ApplicationKey) - response, responseBody, err := ctx.GetHttpClient().Patch(endpoint, requestBody) + response, responseBody, err := ctx.GetHttpClient().Patch(endpoint, requestBody, nil) if err != nil { return err } diff --git a/apptrust/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index b6e9fcb..bc7834b 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -124,3 +124,17 @@ func (mr *MockVersionServiceMockRecorder) UpdateAppVersion(ctx, applicationKey, mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersion", reflect.TypeOf((*MockVersionService)(nil).UpdateAppVersion), ctx, applicationKey, version, request) } + +// UpdateAppVersionSources mocks base method. +func (m *MockVersionService) UpdateAppVersionSources(ctx service.Context, applicationKey, version string, request *model.UpdateVersionSourcesRequest, sync, dryRun, failFast bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAppVersionSources", ctx, applicationKey, version, request, sync, dryRun, failFast) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAppVersionSources indicates an expected call of UpdateAppVersionSources. +func (mr *MockVersionServiceMockRecorder) UpdateAppVersionSources(ctx, applicationKey, version, request, sync, dryRun, failFast any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersionSources", reflect.TypeOf((*MockVersionService)(nil).UpdateAppVersionSources), ctx, applicationKey, version, request, sync, dryRun, failFast) +} diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index bbb8c5b..4a60493 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -20,6 +20,7 @@ type VersionService interface { RollbackAppVersion(ctx service.Context, applicationKey string, version string, request *model.RollbackAppVersionRequest, sync bool) error DeleteAppVersion(ctx service.Context, applicationKey string, version string) error UpdateAppVersion(ctx service.Context, applicationKey string, version string, request *model.UpdateAppVersionRequest) error + UpdateAppVersionSources(ctx service.Context, applicationKey string, version string, request *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) error } type versionService struct{} @@ -122,7 +123,7 @@ func (vs *versionService) DeleteAppVersion(ctx service.Context, applicationKey, func (vs *versionService) UpdateAppVersion(ctx service.Context, applicationKey string, version string, request *model.UpdateAppVersionRequest) error { endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s", applicationKey, version) - response, responseBody, err := ctx.GetHttpClient().Patch(endpoint, request) + response, responseBody, err := ctx.GetHttpClient().Patch(endpoint, request, nil) if err != nil { return err } @@ -135,3 +136,32 @@ func (vs *versionService) UpdateAppVersion(ctx service.Context, applicationKey s log.Info("Application version updated successfully.") return nil } + +func (vs *versionService) UpdateAppVersionSources(ctx service.Context, applicationKey string, version string, request *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) error { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s", applicationKey, version) + + params := map[string]string{ + "async": strconv.FormatBool(!sync), + "dry_run": strconv.FormatBool(dryRun), + "fail_fast": strconv.FormatBool(failFast), + } + + response, responseBody, err := ctx.GetHttpClient().Patch(endpoint, request, params) + if err != nil { + return err + } + + expectedStatusCode := http.StatusOK + if !sync { + expectedStatusCode = http.StatusAccepted + } + + if response.StatusCode != expectedStatusCode { + return fmt.Errorf("failed to update app version sources. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + log.Info("Application version sources updated successfully.") + log.Output(string(responseBody)) + return nil +} diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index ae05abc..b5b4534 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -414,7 +414,7 @@ func TestUpdateAppVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Patch("/v1/applications/test-app/versions/1.0.0", tt.request). + mockHttpClient.EXPECT().Patch("/v1/applications/test-app/versions/1.0.0", tt.request, nil). Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) mockCtx := mockservice.NewMockContext(ctrl) @@ -525,3 +525,136 @@ func TestRollbackAppVersion(t *testing.T) { }) } } + +func TestUpdateAppVersionSources(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + + tests := []struct { + name string + request *model.UpdateVersionSourcesRequest + sync bool + dryRun bool + failFast bool + mockResponse *http.Response + mockResponseBody string + mockError error + expectError bool + errorMsg string + expectedParams map[string]string + }{ + { + name: "success - sync with packages", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "1.0.0", Repository: "npm-local"}, + }, + }, + }, + sync: true, + dryRun: false, + failFast: true, + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockResponseBody: "{}", + expectError: false, + expectedParams: map[string]string{"async": "false", "dry_run": "false", "fail_fast": "true"}, + }, + { + name: "success - async with builds", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Builds: []model.CreateVersionBuild{ + {Name: "build1", Number: "100"}, + }, + }, + }, + sync: false, + dryRun: false, + failFast: true, + mockResponse: &http.Response{StatusCode: http.StatusAccepted}, + mockResponseBody: `{"operation_id":"op-123"}`, + expectError: false, + expectedParams: map[string]string{"async": "true", "dry_run": "false", "fail_fast": "true"}, + }, + { + name: "success - dry run", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "1.0.0", Repository: "npm-local"}, + }, + }, + }, + sync: true, + dryRun: true, + failFast: false, + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockResponseBody: "{}", + expectError: false, + expectedParams: map[string]string{"async": "false", "dry_run": "true", "fail_fast": "false"}, + }, + { + name: "failure - 400", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "1.0.0", Repository: "npm-local"}, + }, + }, + }, + sync: true, + dryRun: false, + failFast: true, + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockResponseBody: "bad request", + expectError: true, + errorMsg: "failed to update app version sources", + expectedParams: map[string]string{"async": "false", "dry_run": "false", "fail_fast": "true"}, + }, + { + name: "http client error", + request: &model.UpdateVersionSourcesRequest{ + AddSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Type: "npm", Name: "pkg", Version: "1.0.0", Repository: "npm-local"}, + }, + }, + }, + sync: true, + dryRun: false, + failFast: true, + mockResponse: nil, + mockError: errors.New("http client error"), + expectError: true, + errorMsg: "http client error", + expectedParams: map[string]string{"async": "false", "dry_run": "false", "fail_fast": "true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Patch( + "/v1/applications/test-app/versions/1.0.0", + tt.request, + tt.expectedParams, + ).Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).AnyTimes() + + err := service.UpdateAppVersionSources(mockCtx, "test-app", "1.0.0", tt.request, tt.sync, tt.dryRun, tt.failFast) + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cli/cli.go b/cli/cli.go index 780b6fb..c1eb8d8 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -27,6 +27,7 @@ func GetJfrogCliApptrustApp() components.App { version.GetReleaseAppVersionCommand(appContext), version.GetDeleteAppVersionCommand(appContext), version.GetUpdateAppVersionCommand(appContext), + version.GetUpdateAppVersionSourcesCommand(appContext), packagecmds.GetBindPackageCommand(appContext), packagecmds.GetUnbindPackageCommand(appContext), application.GetCreateAppCommand(appContext), From 2ec6391f8340c04dbbd68e90595fda8f4c3a3eb4 Mon Sep 17 00:00:00 2001 From: Yuri Novo Date: Tue, 10 Feb 2026 13:37:03 +0200 Subject: [PATCH 3/3] APP-1673 - Added e2e for updating draft version sources --- .../version/create_app_version_cmd.go | 1 - e2e/utils/artifactory_utils.go | 50 +++++++++++++++++++ e2e/utils/e2e_utils.go | 13 ++++- e2e/utils/project_utils.go | 1 + e2e/version_test.go | 46 +++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index 45dd526..5482d36 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -106,4 +106,3 @@ func GetCreateAppVersionCommand(appContext app.Context) components.Command { Action: cmd.prepareAndRunCommand, } } - diff --git a/e2e/utils/artifactory_utils.go b/e2e/utils/artifactory_utils.go index 6deae6b..10d9ff1 100644 --- a/e2e/utils/artifactory_utils.go +++ b/e2e/utils/artifactory_utils.go @@ -5,6 +5,7 @@ package utils import ( "fmt" "net/http" + "os" "path/filepath" "runtime" "strings" @@ -33,6 +34,30 @@ func createNpmRepo(t *testing.T) string { return repoKey } +var genericRepoKey string + +func createGenericRepo(t *testing.T) string { + servicesManager := getArtifactoryServicesManager(t) + genericRepoKey = GetTestProjectKey(t) + "-generic-local" + localRepoConfig := services.NewGenericLocalRepositoryParams() + localRepoConfig.ProjectKey = GetTestProjectKey(t) + localRepoConfig.Key = genericRepoKey + err := servicesManager.CreateLocalRepository().Generic(localRepoConfig) + require.NoError(t, err) + return genericRepoKey +} + +func deleteGenericRepo() { + if genericRepoKey == "" || artifactoryServicesManager == nil { + return + } + + err := artifactoryServicesManager.DeleteRepository(genericRepoKey) + if err != nil { + log.Error("Failed to delete generic repo", err) + } +} + func deleteNpmRepo() { if testPackageRes == nil || artifactoryServicesManager == nil { return @@ -100,6 +125,31 @@ func uploadPackageToArtifactory(t *testing.T, repoKey, buildName, buildNumber st return artifactDetails.Checksums.Sha256 } +func uploadSimpleFileToArtifactory(t *testing.T, repoKey, targetFileName string) string { + tmpFile, err := os.CreateTemp("", "e2e-artifact-*.txt") + require.NoError(t, err) + _, err = tmpFile.WriteString("test-artifact-content") + require.NoError(t, err) + err = tmpFile.Close() + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + targetPath := repoKey + "/" + targetFileName + servicesManager := getArtifactoryServicesManager(t) + uploadParams := services.NewUploadParams() + uploadParams.Pattern = tmpFile.Name() + uploadParams.Target = targetPath + uploadParams.Flat = true + summary, err := servicesManager.UploadFilesWithSummary(artifactory.UploadServiceOptions{FailFast: false}, uploadParams) + require.NoError(t, err) + require.Equal(t, 1, summary.TotalSucceeded, "Expected exactly one uploaded file") + require.Equal(t, 0, summary.TotalFailed, "Expected zero failed uploads") + err = summary.Close() + require.NoError(t, err) + + return targetPath +} + func reindexRepo(t *testing.T, repoKey string) { log.Info(fmt.Sprintf("Reindexing repository %s", repoKey)) diff --git a/e2e/utils/e2e_utils.go b/e2e/utils/e2e_utils.go index 933ca3d..dea23ee 100644 --- a/e2e/utils/e2e_utils.go +++ b/e2e/utils/e2e_utils.go @@ -37,8 +37,9 @@ var ( AppTrustCli *coreTests.JfrogCli - testProjectKey string - testPackageRes *TestPackageResources + testProjectKey string + testPackageRes *TestPackageResources + testArtifactPath string ) func LoadCredentials() string { @@ -80,6 +81,14 @@ func GetTestPackage(t *testing.T) *TestPackageResources { return testPackageRes } +func GetTestArtifact(t *testing.T) string { + if testArtifactPath == "" { + repoKey := createGenericRepo(t) + testArtifactPath = uploadSimpleFileToArtifactory(t, repoKey, "test-artifact.txt") + } + return testArtifactPath +} + func GenerateUniqueKey(prefix string) string { timestamp := strconv.FormatInt(time.Now().Unix(), 10) return fmt.Sprintf("%s-%s", prefix, timestamp) diff --git a/e2e/utils/project_utils.go b/e2e/utils/project_utils.go index 312288f..759fb65 100644 --- a/e2e/utils/project_utils.go +++ b/e2e/utils/project_utils.go @@ -32,6 +32,7 @@ func DeleteTestProject() { } deleteBuild() deleteNpmRepo() + deleteGenericRepo() accessManager, err := utils.CreateAccessServiceManager(serverDetails, false) if err != nil { log.Error("Failed to create Access service manager", err) diff --git a/e2e/version_test.go b/e2e/version_test.go index 17609be..683c1a5 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "time" @@ -234,6 +235,51 @@ func TestUpdateVersion(t *testing.T) { assert.Equal(t, tag, versionContent.Tag) } +func TestUpdateDraftVersionSources(t *testing.T) { + appKey := utils.GenerateUniqueKey("app-version-update-sources") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + testPackage := utils.GetTestPackage(t) + version := "1.0.6" + 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, "--draft") + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + artifactPath := utils.GetTestArtifact(t) + artifactFlag := fmt.Sprintf("--source-type-artifacts=path=%s", artifactPath) + + err = utils.AppTrustCli.Exec("version-update-sources", appKey, version, artifactFlag) + require.NoError(t, err) + + versionContent, statusCode, err := utils.GetApplicationVersion(appKey, version) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + require.NotNil(t, versionContent) + assert.Equal(t, appKey, versionContent.ApplicationKey) + assert.Equal(t, version, versionContent.Version) + assert.Contains(t, utils.StatusDraft, versionContent.Status) + var artifactPaths []string + for _, r := range versionContent.Releasables { + for _, a := range r.Artifacts { + artifactPaths = append(artifactPaths, a.Path) + } + } + assert.True(t, containsPath(artifactPaths, testPackage.PackagePath), + "expected package path %q in version releasables (got %v)", testPackage.PackagePath, artifactPaths) + assert.True(t, containsPath(artifactPaths, artifactPath), + "expected artifact path %q in version releasables (got %v)", artifactPath, artifactPaths) +} + +func containsPath(paths []string, target string) bool { + for _, path := range paths { + if strings.Contains(target, path) { + return true + } + } + return false +} + func TestDeleteVersion(t *testing.T) { // Prepare appKey := utils.GenerateUniqueKey("app-version-delete")