diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index ec39b79..25674bb 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -28,35 +28,29 @@ const ( accessToken = "access-token" ProjectFlag = "project" - ApplicationKeyFlag = "application-key" - PackageTypeFlag = "package-type" - PackageNameFlag = "package-name" - PackageVersionFlag = "package-version" - PackageRepositoryFlag = "package-repository" - SpecFlag = "spec" - SpecVarsFlag = "spec-vars" - StageVarsFlag = "stage" - ApplicationNameFlag = "application-name" - DescriptionFlag = "desc" - BusinessCriticalityFlag = "business-criticality" - MaturityLevelFlag = "maturity-level" - LabelsFlag = "labels" - UserOwnersFlag = "user-owners" - GroupOwnersFlag = "group-owners" - SigningKeyFlag = "signing-key" - SyncFlag = "sync" - PromotionTypeFlag = "promotion-type" - DryRunFlag = "dry-run" - ExcludeReposFlag = "exclude-repos" - IncludeReposFlag = "include-repos" - PropsFlag = "props" - TagFlag = "tag" - BuildsFlag = "builds" - ReleaseBundlesFlag = "release-bundles" - SourceVersionFlag = "source-version" - PackagesFlag = "packages" - PropertiesFlag = "properties" - DeletePropertyFlag = "delete-property" + ApplicationKeyFlag = "application-key" + SpecFlag = "spec" + SpecVarsFlag = "spec-vars" + StageVarsFlag = "stage" + ApplicationNameFlag = "application-name" + DescriptionFlag = "desc" + BusinessCriticalityFlag = "business-criticality" + MaturityLevelFlag = "maturity-level" + LabelsFlag = "labels" + UserOwnersFlag = "user-owners" + GroupOwnersFlag = "group-owners" + SyncFlag = "sync" + PromotionTypeFlag = "promotion-type" + DryRunFlag = "dry-run" + ExcludeReposFlag = "exclude-repos" + IncludeReposFlag = "include-repos" + PropsFlag = "props" + TagFlag = "tag" + SourceTypeBuildsFlag = "source-type-builds" + SourceTypeReleaseBundlesFlag = "source-type-release-bundles" + SourceTypeApplicationVersionsFlag = "source-type-application-versions" + PropertiesFlag = "properties" + DeletePropertyFlag = "delete-property" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -66,37 +60,31 @@ var flagsMap = map[string]components.Flag{ url: components.NewStringFlag(url, "JFrog Platform URL.", func(f *components.StringFlag) { f.Mandatory = false }), user: components.NewStringFlag(user, "JFrog username.", func(f *components.StringFlag) { f.Mandatory = false }), accessToken: components.NewStringFlag(accessToken, "JFrog access token.", func(f *components.StringFlag) { f.Mandatory = false }), - ProjectFlag: components.NewStringFlag(ProjectFlag, "Project key associated with the application.", func(f *components.StringFlag) { f.Mandatory = false }), + ProjectFlag: components.NewStringFlag(ProjectFlag, "Project key associated with the application. This flag is mandatory when the --spec flag is not provided.", func(f *components.StringFlag) { f.Mandatory = false }), - ApplicationKeyFlag: components.NewStringFlag(ApplicationKeyFlag, "Application key.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageTypeFlag: components.NewStringFlag(PackageTypeFlag, "Package type.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageNameFlag: components.NewStringFlag(PackageNameFlag, "Package name.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageVersionFlag: components.NewStringFlag(PackageVersionFlag, "Package version.", func(f *components.StringFlag) { f.Mandatory = false }), - PackageRepositoryFlag: components.NewStringFlag(PackageRepositoryFlag, "Package storing repository.", func(f *components.StringFlag) { f.Mandatory = false }), - SpecFlag: components.NewStringFlag(SpecFlag, "A path to the specification file.", func(f *components.StringFlag) { f.Mandatory = false }), - SpecVarsFlag: components.NewStringFlag(SpecVarsFlag, "List of semicolon-separated (;) variables in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes) to be replaced in the File Spec. In the File Spec, the variables should be used as follows: ${key1}.", func(f *components.StringFlag) { f.Mandatory = false }), - StageVarsFlag: components.NewStringFlag(StageVarsFlag, "Promotion stage.", func(f *components.StringFlag) { f.Mandatory = true }), - ApplicationNameFlag: components.NewStringFlag(ApplicationNameFlag, "The display name of the application.", func(f *components.StringFlag) { f.Mandatory = false }), - DescriptionFlag: components.NewStringFlag(DescriptionFlag, "The description of the application.", func(f *components.StringFlag) { f.Mandatory = false }), - BusinessCriticalityFlag: components.NewStringFlag(BusinessCriticalityFlag, "The business criticality level. The following values are supported: "+coreutils.ListToText(model.BusinessCriticalityValues), func(f *components.StringFlag) { f.Mandatory = false }), - MaturityLevelFlag: components.NewStringFlag(MaturityLevelFlag, "The maturity level.", func(f *components.StringFlag) { f.Mandatory = false }), - LabelsFlag: components.NewStringFlag(LabelsFlag, "List of semicolon-separated (;) labels in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes).", func(f *components.StringFlag) { f.Mandatory = false }), - UserOwnersFlag: components.NewStringFlag(UserOwnersFlag, "Comma-separated list of user owners.", func(f *components.StringFlag) { f.Mandatory = false }), - GroupOwnersFlag: components.NewStringFlag(GroupOwnersFlag, "Comma-separated list of group owners.", func(f *components.StringFlag) { f.Mandatory = false }), - SigningKeyFlag: components.NewStringFlag(SigningKeyFlag, "The GPG/RSA key-pair name given in Artifactory.", func(f *components.StringFlag) { f.Mandatory = false }), - SyncFlag: components.NewBoolFlag(SyncFlag, "Whether to synchronize the operation.", components.WithBoolDefaultValueTrue()), - PromotionTypeFlag: components.NewStringFlag(PromotionTypeFlag, "The promotion type. The following values are supported: "+coreutils.ListToText(model.PromotionTypeValues), func(f *components.StringFlag) { f.Mandatory = false; f.DefaultValue = model.PromotionTypeCopy }), - DryRunFlag: components.NewBoolFlag(DryRunFlag, "Perform a simulation of the operation.", components.WithBoolDefaultValueFalse()), - ExcludeReposFlag: components.NewStringFlag(ExcludeReposFlag, "Semicolon-separated list of repositories to exclude.", func(f *components.StringFlag) { f.Mandatory = false }), - IncludeReposFlag: components.NewStringFlag(IncludeReposFlag, "Semicolon-separated list of repositories to include.", func(f *components.StringFlag) { f.Mandatory = false }), - 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 }), - TagFlag: components.NewStringFlag(TagFlag, "A tag to associate with the version. Must contain only alphanumeric characters, hyphens (-), underscores (_), and dots (.).", func(f *components.StringFlag) { f.Mandatory = false }), - BuildsFlag: components.NewStringFlag(BuildsFlag, "List of builds in format 'name1:number1[:timestamp1];name2:number2[:timestamp2]'", func(f *components.StringFlag) { f.Mandatory = false }), - ReleaseBundlesFlag: components.NewStringFlag(ReleaseBundlesFlag, "List of release bundles in format 'name1:version1;name2:version2'", func(f *components.StringFlag) { f.Mandatory = false }), - SourceVersionFlag: components.NewStringFlag(SourceVersionFlag, "Source versions in format 'app1:version1;app2:version2'", func(f *components.StringFlag) { f.Mandatory = false }), - PackagesFlag: components.NewStringFlag(PackagesFlag, "List of packages in format 'name1;name2'", func(f *components.StringFlag) { f.Mandatory = false }), - PropertiesFlag: components.NewStringFlag(PropertiesFlag, "Sets or updates custom properties for the application version in format 'key1=value1[,value2,...];key2=value3[,value4,...]'", func(f *components.StringFlag) { f.Mandatory = false }), - DeletePropertyFlag: components.NewStringFlag(DeletePropertyFlag, "Remove a property key and all its values", func(f *components.StringFlag) { f.Mandatory = false }), + ApplicationKeyFlag: components.NewStringFlag(ApplicationKeyFlag, "Application key.", func(f *components.StringFlag) { f.Mandatory = false }), + SpecFlag: components.NewStringFlag(SpecFlag, "A path to the specification file.", func(f *components.StringFlag) { f.Mandatory = false }), + SpecVarsFlag: components.NewStringFlag(SpecVarsFlag, "List of semicolon-separated (;) variables in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes) to be replaced in the File Spec. In the File Spec, the variables should be used as follows: ${key1}.", func(f *components.StringFlag) { f.Mandatory = false }), + StageVarsFlag: components.NewStringFlag(StageVarsFlag, "Promotion stage.", func(f *components.StringFlag) { f.Mandatory = true }), + ApplicationNameFlag: components.NewStringFlag(ApplicationNameFlag, "The display name of the application.", func(f *components.StringFlag) { f.Mandatory = false }), + DescriptionFlag: components.NewStringFlag(DescriptionFlag, "The description of the application.", func(f *components.StringFlag) { f.Mandatory = false }), + BusinessCriticalityFlag: components.NewStringFlag(BusinessCriticalityFlag, "The business criticality level. The following values are supported: "+coreutils.ListToText(model.BusinessCriticalityValues), func(f *components.StringFlag) { f.Mandatory = false }), + MaturityLevelFlag: components.NewStringFlag(MaturityLevelFlag, "The maturity level.", func(f *components.StringFlag) { f.Mandatory = false }), + LabelsFlag: components.NewStringFlag(LabelsFlag, "List of semicolon-separated (;) labels in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes).", func(f *components.StringFlag) { f.Mandatory = false }), + UserOwnersFlag: components.NewStringFlag(UserOwnersFlag, "Comma-separated list of user owners.", func(f *components.StringFlag) { f.Mandatory = false }), + GroupOwnersFlag: components.NewStringFlag(GroupOwnersFlag, "Comma-separated list of group owners.", func(f *components.StringFlag) { f.Mandatory = false }), + SyncFlag: components.NewBoolFlag(SyncFlag, "Whether to synchronize the operation.", components.WithBoolDefaultValueTrue()), + PromotionTypeFlag: components.NewStringFlag(PromotionTypeFlag, "The promotion type. The following values are supported: "+coreutils.ListToText(model.PromotionTypeValues), func(f *components.StringFlag) { f.Mandatory = false; f.DefaultValue = model.PromotionTypeCopy }), + DryRunFlag: components.NewBoolFlag(DryRunFlag, "Perform a simulation of the operation.", components.WithBoolDefaultValueFalse()), + ExcludeReposFlag: components.NewStringFlag(ExcludeReposFlag, "Semicolon-separated list of repositories to exclude.", func(f *components.StringFlag) { f.Mandatory = false }), + IncludeReposFlag: components.NewStringFlag(IncludeReposFlag, "Semicolon-separated list of repositories to include.", func(f *components.StringFlag) { f.Mandatory = false }), + 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 }), + TagFlag: components.NewStringFlag(TagFlag, "A tag to associate with the version. Must contain only alphanumeric characters, hyphens (-), underscores (_), and dots (.).", func(f *components.StringFlag) { f.Mandatory = false }), + SourceTypeBuildsFlag: components.NewStringFlag(SourceTypeBuildsFlag, "List of semicolon-separated (;) builds in the form of 'name=buildName1, id=runID1, [include-deps=true]; name=buildName2, id=runID2, [include-deps=true]' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), + SourceTypeReleaseBundlesFlag: components.NewStringFlag(SourceTypeReleaseBundlesFlag, "List of semicolon-separated (;) release bundles in the form of 'name=releaseBundleName1, version=version1; name=releaseBundleName2, version=version2' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), + SourceTypeApplicationVersionsFlag: components.NewStringFlag(SourceTypeApplicationVersionsFlag, "List of semicolon-separated (;) application versions in the form of 'application-key=app1, version=version1; application-key=app2, version=version2' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), + PropertiesFlag: components.NewStringFlag(PropertiesFlag, "Sets or updates custom properties for the application version in format 'key1=value1[,value2,...];key2=value3[,value4,...]'", func(f *components.StringFlag) { f.Mandatory = false }), + DeletePropertyFlag: components.NewStringFlag(DeletePropertyFlag, "Remove a property key and all its values", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -105,14 +93,10 @@ var commandFlags = map[string][]string{ user, accessToken, serverId, - ApplicationKeyFlag, TagFlag, - PackagesFlag, - PackageTypeFlag, - PackageRepositoryFlag, - BuildsFlag, - ReleaseBundlesFlag, - SourceVersionFlag, + SourceTypeBuildsFlag, + SourceTypeReleaseBundlesFlag, + SourceTypeApplicationVersionsFlag, SpecFlag, SpecVarsFlag, }, @@ -161,8 +145,6 @@ var commandFlags = map[string][]string{ accessToken, serverId, ApplicationKeyFlag, - PackagesFlag, - PackageTypeFlag, }, PackageUnbind: { url, @@ -170,8 +152,6 @@ var commandFlags = map[string][]string{ accessToken, serverId, ApplicationKeyFlag, - PackagesFlag, - PackageTypeFlag, }, Ping: { @@ -194,7 +174,6 @@ var commandFlags = map[string][]string{ LabelsFlag, UserOwnersFlag, GroupOwnersFlag, - SigningKeyFlag, SpecFlag, SpecVarsFlag, }, @@ -211,9 +190,6 @@ var commandFlags = map[string][]string{ LabelsFlag, UserOwnersFlag, GroupOwnersFlag, - SigningKeyFlag, - SpecFlag, - SpecVarsFlag, }, AppDelete: { diff --git a/apptrust/commands/utils/utils.go b/apptrust/commands/utils/utils.go index adf0fca..a894f70 100644 --- a/apptrust/commands/utils/utils.go +++ b/apptrust/commands/utils/utils.go @@ -56,11 +56,15 @@ func ParseSliceFlag(flagValue string) []string { // ParseMapFlag parses a semicolon-separated string of key=value pairs into a map[string]string. // Returns an error if any pair does not contain exactly one '='. func ParseMapFlag(flagValue string) (map[string]string, error) { - if flagValue == "" { + return ParseKeyValueString(flagValue, ";") +} + +func ParseKeyValueString(value, separator string) (map[string]string, error) { + if value == "" { return nil, nil } result := make(map[string]string) - pairs := strings.Split(flagValue, ";") + pairs := strings.Split(value, separator) for _, pair := range pairs { keyValue := strings.SplitN(pair, "=", 2) if len(keyValue) != 2 { @@ -87,25 +91,6 @@ func ValidateEnumFlag(flagName, value string, defaultValue string, allowedValues flagName, value, coreutils.ListToText(allowedValues)) } -// ParsePackagesFlag parses a comma-separated list of package name:version pairs into a slice of maps. -// Each map contains keys "name" and "version". Returns an error if any entry is not in the expected format. -// Example input: "pkg1:1.0.0,pkg2:2.0.0" => []map[string]string{{"name": "pkg1", "version": "1.0.0"}, {"name": "pkg2", "version": "2.0.0"}} -func ParsePackagesFlag(flagValue string) ([]map[string]string, error) { - if flagValue == "" { - return nil, nil - } - pairs := strings.Split(flagValue, ",") - var result []map[string]string - for _, pair := range pairs { - parts := strings.SplitN(strings.TrimSpace(pair), PartSeparator, 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid package format: %s (expected :)", pair) - } - result = append(result, map[string]string{"name": parts[0], "version": parts[1]}) - } - return result, nil -} - // ParseDelimitedSlice splits a delimited string into a slice of string slices. // Example: input "a:1;b:2" returns [][]string{{"a","1"},{"b","2"}} func ParseDelimitedSlice(input string) [][]string { diff --git a/apptrust/commands/utils/utils_test.go b/apptrust/commands/utils/utils_test.go index 298e259..db9e2b5 100644 --- a/apptrust/commands/utils/utils_test.go +++ b/apptrust/commands/utils/utils_test.go @@ -115,40 +115,6 @@ func TestValidateEnumFlag(t *testing.T) { } } -func TestParsePackagesFlag(t *testing.T) { - tests := []struct { - name string - input string - expected []map[string]string - expectErr bool - }{ - {"empty string", "", nil, false}, - {"single package", "foo:1.0.0", []map[string]string{{"name": "foo", "version": "1.0.0"}}, false}, - {"multiple packages", "foo:1.0.0,bar:2.0.0", []map[string]string{{"name": "foo", "version": "1.0.0"}, {"name": "bar", "version": "2.0.0"}}, false}, - {"spaces", " foo:1.0.0 , bar:2.0.0 ", []map[string]string{{"name": "foo", "version": "1.0.0"}, {"name": "bar", "version": "2.0.0"}}, false}, - {"invalid format", "foo", nil, true}, - {"missing version", "foo:", []map[string]string{{"name": "foo", "version": ""}}, false}, - {"missing name", ":1.0.0", []map[string]string{{"name": "", "version": "1.0.0"}}, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := ParsePackagesFlag(tt.input) - if tt.expectErr { - if err == nil { - t.Errorf("expected error for input %q, got nil", tt.input) - } - return - } - if err != nil { - t.Errorf("unexpected error for input %q: %v", tt.input, err) - } - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("ParsePackagesFlag(%q) = %v, want %v", tt.input, result, tt.expected) - } - }) - } -} - func TestParseDelimitedSlice(t *testing.T) { tests := []struct { name string diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index aef9a2e..440f723 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -2,6 +2,8 @@ package version import ( "encoding/json" + "strconv" + "strings" "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" @@ -92,35 +94,21 @@ func (cv *createAppVersionCommand) buildRequestPayload(ctx *components.Context) func (cv *createAppVersionCommand) buildSourcesFromFlags(ctx *components.Context) (*model.CreateVersionSources, error) { sources := &model.CreateVersionSources{} - if ctx.IsFlagSet(commands.PackagesFlag) { - parsedPkgs, err := utils.ParsePackagesFlag(ctx.GetStringFlagValue(commands.PackagesFlag)) - if err != nil { - return nil, err - } - for _, pkg := range parsedPkgs { - sources.Packages = append(sources.Packages, model.CreateVersionPackage{ - Type: ctx.GetStringFlagValue(commands.PackageTypeFlag), - Name: pkg["name"], - Version: pkg["version"], - Repository: ctx.GetStringFlagValue(commands.PackageRepositoryFlag), - }) - } - } - if buildsStr := ctx.GetStringFlagValue(commands.BuildsFlag); buildsStr != "" { + 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.ReleaseBundlesFlag); rbStr != "" { + 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.SourceVersionFlag); srcVersionsStr != "" { + if srcVersionsStr := ctx.GetStringFlagValue(commands.SourceTypeApplicationVersionsFlag); srcVersionsStr != "" { sourceVersions, err := cv.parseSourceVersions(srcVersionsStr) if err != nil { return nil, err @@ -164,17 +152,33 @@ func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model } func (cv *createAppVersionCommand) parseBuilds(buildsStr string) ([]model.CreateVersionBuild, error) { + const ( + nameField = "name" + idField = "id" + includeDepField = "include_deps" + ) + var builds []model.CreateVersionBuild - for _, parts := range utils.ParseDelimitedSlice(buildsStr) { - if len(parts) < 2 || len(parts) > 3 { - return nil, errorutils.CheckErrorf("invalid build format: %v", parts) + 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: parts[0], - Number: parts[1], + Name: buildEntryMap[nameField], + Number: buildEntryMap[idField], } - if len(parts) == 3 { - build.Started = parts[2] + 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) } @@ -182,30 +186,50 @@ func (cv *createAppVersionCommand) parseBuilds(buildsStr string) ([]model.Create } func (cv *createAppVersionCommand) parseReleaseBundles(rbStr string) ([]model.CreateVersionReleaseBundle, error) { - pairs, err := utils.ParseNameVersionPairs(rbStr) - if err != nil { - return nil, errorutils.CheckErrorf("invalid release bundle format: %v", err) - } + const ( + nameField = "name" + versionField = "version" + ) + var bundles []model.CreateVersionReleaseBundle - for _, pair := range pairs { + 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{ - Name: pair[0], - Version: pair[1], + Name: releaseBundleEntryMap[nameField], + Version: releaseBundleEntryMap[versionField], }) } return bundles, nil } -func (cv *createAppVersionCommand) parseSourceVersions(sourceVersionsStr string) ([]model.CreateVersionReference, error) { - pairs, err := utils.ParseNameVersionPairs(sourceVersionsStr) - if err != nil { - return nil, errorutils.CheckErrorf("invalid source version format: %v", err) - } +func (cv *createAppVersionCommand) parseSourceVersions(applicationVersionsStr string) ([]model.CreateVersionReference, error) { + const ( + applicationKeyField = "application-key" + versionField = "version" + ) + var refs []model.CreateVersionReference - for _, pair := range pairs { + 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: pair[0], - Version: pair[1], + ApplicationKey: applicationVersionEntryMap[applicationKeyField], + Version: applicationVersionEntryMap[versionField], }) } return refs, nil @@ -220,22 +244,14 @@ func validateCreateAppVersionContext(ctx *components.Context) error { } hasSource := ctx.IsFlagSet(commands.SpecFlag) || - ctx.IsFlagSet(commands.PackageNameFlag) || - ctx.IsFlagSet(commands.BuildsFlag) || - ctx.IsFlagSet(commands.ReleaseBundlesFlag) || - ctx.IsFlagSet(commands.SourceVersionFlag) + ctx.IsFlagSet(commands.SourceTypeBuildsFlag) || + ctx.IsFlagSet(commands.SourceTypeReleaseBundlesFlag) || + ctx.IsFlagSet(commands.SourceTypeApplicationVersionsFlag) if !hasSource { return errorutils.CheckErrorf( - "At least one source flag is required to create an application version. Please provide one of the following: --%s, --%s, --%s, --%s, or --%s.", - commands.SpecFlag, commands.PackageNameFlag, commands.BuildsFlag, commands.ReleaseBundlesFlag, commands.SourceVersionFlag) - } - - // Validate package details if used - if ctx.IsFlagSet(commands.PackageNameFlag) { - if !ctx.IsFlagSet(commands.PackageVersionFlag) || !ctx.IsFlagSet(commands.PackageRepositoryFlag) { - return handleMissingPackageDetailsError() - } + "At least one source flag is required to create an application version. Please provide one of the following: --%s, --%s, --%s, or --%s.", + commands.SpecFlag, commands.SourceTypeBuildsFlag, commands.SourceTypeReleaseBundlesFlag, commands.SourceTypeApplicationVersionsFlag) } return nil @@ -265,19 +281,13 @@ func GetCreateAppVersionCommand(appContext app.Context) components.Command { } } -func handleMissingPackageDetailsError() error { - return errorutils.CheckErrorf("Missing packages information. Please provide the following flags --%s or the set of: --%s, --%s, --%s, --%s", - commands.SpecFlag, commands.PackageTypeFlag, commands.PackageNameFlag, commands.PackageVersionFlag, commands.PackageRepositoryFlag) -} - // Returns error if both --spec and any other source flag are set func validateNoSpecAndFlagsTogether(ctx *components.Context) error { if ctx.IsFlagSet(commands.SpecFlag) { otherSourceFlags := []string{ - commands.PackageNameFlag, - commands.BuildsFlag, - commands.ReleaseBundlesFlag, - commands.SourceVersionFlag, + commands.SourceTypeBuildsFlag, + commands.SourceTypeReleaseBundlesFlag, + commands.SourceTypeApplicationVersionsFlag, } for _, flag := range otherSourceFlags { if ctx.IsFlagSet(flag) { @@ -287,3 +297,15 @@ func validateNoSpecAndFlagsTogether(ctx *components.Context) error { } 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 b77b09f..18a6395 100644 --- a/apptrust/commands/version/create_app_version_cmd_test.go +++ b/apptrust/commands/version/create_app_version_cmd_test.go @@ -89,7 +89,7 @@ func TestCreateAppVersionCommand_SpecAndFlags_Error(t *testing.T) { Arguments: []string{"app-key", "1.0.0"}, } ctx.AddStringFlag(commands.SpecFlag, testSpecPath) - ctx.AddStringFlag(commands.PackageNameFlag, "name") + ctx.AddStringFlag(commands.SourceTypeBuildsFlag, "name=build1,id=1.0.0") ctx.AddStringFlag("url", "https://example.com") mockVersionService := mockversions.NewMockVersionService(ctrl) @@ -116,23 +116,18 @@ func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { ctxSetup: func(ctx *components.Context) { ctx.Arguments = []string{"app-key", "1.0.0"} ctx.AddStringFlag(commands.TagFlag, "release-tag") - ctx.AddStringFlag(commands.PackagesFlag, "pkg1:1.2.3,pkg2:2.3.4") - ctx.AddStringFlag(commands.BuildsFlag, "build1:1.0.0:2024-01-01;build2:2.0.0:2024-02-01") - ctx.AddStringFlag(commands.ReleaseBundlesFlag, "rb1:1.0.0;rb2:2.0.0") - ctx.AddStringFlag(commands.SourceVersionFlag, "source-app:3.2.1") + ctx.AddStringFlag(commands.SourceTypeBuildsFlag, "name=build1,id=1.0.0,include_deps=true;name=build2,id=2.0.0,include_deps=false") + ctx.AddStringFlag(commands.SourceTypeReleaseBundlesFlag, "name=rb1,version=1.0.0;name=rb2,version=2.0.0") + ctx.AddStringFlag(commands.SourceTypeApplicationVersionsFlag, "application-key=source-app,version=3.2.1") }, expectsPayload: &model.CreateAppVersionRequest{ ApplicationKey: "app-key", Version: "1.0.0", Tag: "release-tag", Sources: &model.CreateVersionSources{ - Packages: []model.CreateVersionPackage{ - {Name: "pkg1", Version: "1.2.3"}, - {Name: "pkg2", Version: "2.3.4"}, - }, Builds: []model.CreateVersionBuild{ - {Name: "build1", Number: "1.0.0", Started: "2024-01-01"}, - {Name: "build2", Number: "2.0.0", Started: "2024-02-01"}, + {Name: "build1", Number: "1.0.0", IncludeDependencies: true}, + {Name: "build2", Number: "2.0.0", IncludeDependencies: false}, }, ReleaseBundles: []model.CreateVersionReleaseBundle{ {Name: "rb1", Version: "1.0.0"}, @@ -162,7 +157,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 one of the following: --spec, --package-name, --builds, --release-bundles, or --source-version.", + errorContains: "At least one source flag is required to create an application version. Please provide one of the following: --spec, --source-type-builds, --source-type-release-bundles, or --source-type-application-versions.", }, { name: "empty flags", @@ -171,7 +166,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 one of the following: --spec, --package-name, --builds, --release-bundles, or --source-version.", + errorContains: "At least one source flag is required to create an application version. Please provide one of the following: --spec, --source-type-builds, --source-type-release-bundles, or --source-type-application-versions.", }, } @@ -215,70 +210,191 @@ func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { func TestParseBuilds(t *testing.T) { cmd := &createAppVersionCommand{} - // Test basic build parsing - builds, err := cmd.parseBuilds("build1:1.0.0:2024-01-01;build2:2.0.0:2024-02-01") - assert.NoError(t, err) - assert.Len(t, builds, 2) - assert.Equal(t, "build1", builds[0].Name) - assert.Equal(t, "1.0.0", builds[0].Number) - assert.Equal(t, "build2", builds[1].Name) - assert.Equal(t, "2.0.0", builds[1].Number) - - // Test invalid format - _, err = cmd.parseBuilds("build1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid build format") + tests := []struct { + name string + input string + expectError bool + errorContains string + expectedBuilds []model.CreateVersionBuild + }{ + { + name: "multiple builds", + input: "name=build1,id=1.0.0,include_deps=true;name=build2,id=2.0.0,include_deps=false;name=build3,id=3.0.0", + expectError: false, + expectedBuilds: []model.CreateVersionBuild{ + {Name: "build1", Number: "1.0.0", IncludeDependencies: true}, + {Name: "build2", Number: "2.0.0", IncludeDependencies: false}, + {Name: "build3", Number: "3.0.0", IncludeDependencies: false}, + }, + }, + { + name: "empty string", + input: "", + expectError: false, + expectedBuilds: nil, + }, + { + name: "missing name field", + input: "id=1.0.0", + expectError: true, + errorContains: "missing required field: name", + }, + { + name: "missing id field", + input: "name=build1", + expectError: true, + errorContains: "missing required field: id", + }, + { + name: "invalid format", + input: "build1", + expectError: true, + errorContains: "invalid build format", + }, + { + name: "invalid include_deps value", + input: "name=build1,id=1.0.0,include_deps=invalid", + expectError: true, + errorContains: "invalid build format", + }, + } - // Test too many parts - _, err = cmd.parseBuilds("build1:1.0.0:timestamp:extra") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid build format") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builds, err := cmd.parseBuilds(tt.input) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedBuilds, builds) + } + }) + } } func TestParseReleaseBundles(t *testing.T) { cmd := &createAppVersionCommand{} - // Test basic release bundle parsing - rbs, err := cmd.parseReleaseBundles("rb1:1.0.0;rb2:2.0.0") - assert.NoError(t, err) - assert.Equal(t, 2, len(rbs)) - assert.Equal(t, "rb1", rbs[0].Name) - assert.Equal(t, "1.0.0", rbs[0].Version) - assert.Equal(t, "rb2", rbs[1].Name) - assert.Equal(t, "2.0.0", rbs[1].Version) - - // Test invalid format - _, err = cmd.parseReleaseBundles("rb1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid release bundle format") + tests := []struct { + name string + input string + expectError bool + errorContains string + expectedReleaseBundles []model.CreateVersionReleaseBundle + }{ + { + name: "multiple release bundles", + input: "name=rb1,version=1.0.0;name=rb2,version=2.0.0", + expectError: false, + expectedReleaseBundles: []model.CreateVersionReleaseBundle{ + {Name: "rb1", Version: "1.0.0"}, + {Name: "rb2", Version: "2.0.0"}, + }, + }, + { + name: "empty string", + input: "", + expectError: false, + expectedReleaseBundles: nil, + }, + { + name: "missing name field", + input: "version=1.0.0", + expectError: true, + errorContains: "missing required field: name", + }, + { + name: "missing version field", + input: "name=rb1", + expectError: true, + errorContains: "missing required field: version", + }, + { + name: "invalid format", + input: "rb1", + expectError: true, + errorContains: "invalid release bundle format", + }, + } - // Test invalid format with too many parts - _, err = cmd.parseReleaseBundles("rb1:1.0.0:extra") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid release bundle format") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rbs, err := cmd.parseReleaseBundles(tt.input) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedReleaseBundles, rbs) + } + }) + } } func TestParseSourceVersions(t *testing.T) { cmd := &createAppVersionCommand{} - // Test basic source versions parsing - svs, err := cmd.parseSourceVersions("app1:1.0.0;app2:2.0.0") - assert.NoError(t, err) - assert.Equal(t, 2, len(svs)) - assert.Equal(t, "app1", svs[0].ApplicationKey) - assert.Equal(t, "1.0.0", svs[0].Version) - assert.Equal(t, "app2", svs[1].ApplicationKey) - assert.Equal(t, "2.0.0", svs[1].Version) - - // Test invalid format - _, err = cmd.parseSourceVersions("app1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid source version format") + tests := []struct { + name string + input string + expectError bool + errorContains string + expectedSourceVersions []model.CreateVersionReference + }{ + { + name: "multiple source versions", + input: "application-key=app1,version=1.0.0;application-key=app2,version=2.0.0", + expectError: false, + expectedSourceVersions: []model.CreateVersionReference{ + {ApplicationKey: "app1", Version: "1.0.0"}, + {ApplicationKey: "app2", Version: "2.0.0"}, + }, + }, + { + name: "empty string", + input: "", + expectError: false, + expectedSourceVersions: nil, + }, + { + name: "missing application-key field", + input: "version=1.0.0", + expectError: true, + errorContains: "missing required field: application-key", + }, + { + name: "missing version field", + input: "application-key=app1", + expectError: true, + errorContains: "missing required field: version", + }, + { + name: "invalid format", + input: "app1", + expectError: true, + errorContains: "invalid application version format", + }, + } - // Test invalid format with too many parts - _, err = cmd.parseSourceVersions("app1:1.0.0:extra") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid source version format") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svs, err := cmd.parseSourceVersions(tt.input) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedSourceVersions, svs) + } + }) + } } func TestCreateAppVersionCommand_SpecFileSuite(t *testing.T) { @@ -379,3 +495,167 @@ func TestCreateAppVersionCommand_SpecFileSuite(t *testing.T) { }) } } + +func TestValidateCreateAppVersionContext(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectError bool + errorContains string + }{ + { + name: "no source flags", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + }, + expectError: true, + errorContains: "At least one source flag is required", + }, + { + name: "valid context with builds flag", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SourceTypeBuildsFlag, "name=build1,id=1.0.0") + }, + expectError: false, + }, + { + name: "valid context with spec flag", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + tt.ctxSetup(ctx) + + err := validateCreateAppVersionContext(ctx) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateNoSpecAndFlagsTogether(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectError bool + errorContains string + }{ + { + name: "spec flag with builds flag", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.SourceTypeBuildsFlag, "name=build1,id=1.0.0") + }, + expectError: true, + errorContains: "--spec provided", + }, + { + name: "spec flag with release bundles flag", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.SourceTypeReleaseBundlesFlag, "name=rb1,version=1.0.0") + }, + expectError: true, + errorContains: "--spec provided", + }, + { + name: "spec flag with application versions flag", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.SourceTypeApplicationVersionsFlag, "application-key=app1,version=1.0.0") + }, + expectError: true, + errorContains: "--spec provided", + }, + { + name: "spec flag only", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + }, + expectError: false, + }, + { + name: "other flags only", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SourceTypeBuildsFlag, "name=build1,id=1.0.0") + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + tt.ctxSetup(ctx) + + err := validateNoSpecAndFlagsTogether(ctx) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateRequiredFieldsInMap(t *testing.T) { + tests := []struct { + name string + inputMap map[string]string + requiredFields []string + expectError bool + errorContains string + }{ + { + name: "nil map", + inputMap: nil, + requiredFields: []string{"field1", "field2"}, + expectError: true, + errorContains: "missing required fields: field1, field2", + }, + { + name: "missing field", + inputMap: map[string]string{"field1": "value1"}, + requiredFields: []string{"field1", "field2"}, + expectError: true, + errorContains: "missing required field: field2", + }, + { + name: "all required fields present", + inputMap: map[string]string{"field1": "value1", "field2": "value2", "extra": "value3"}, + requiredFields: []string{"field1", "field2"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRequiredFieldsInMap(tt.inputMap, tt.requiredFields...) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index a4578ab..eebd0d0 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -28,7 +28,7 @@ func NewVersionService() VersionService { func (vs *versionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest) error { endpoint := fmt.Sprintf("/v1/applications/%s/versions/", request.ApplicationKey) - response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, map[string]string{"async": "false"}) if err != nil { return err } diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 8499f66..fb38874 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -57,7 +57,7 @@ func TestCreateAppVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Post("/v1/applications/test-app/versions/", tt.request, nil). + mockHttpClient.EXPECT().Post("/v1/applications/test-app/versions/", tt.request, map[string]string{"async": "false"}). Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) mockCtx := mockservice.NewMockContext(ctrl)