diff --git a/apptrust/commands/application/create_app_cmd.go b/apptrust/commands/application/create_app_cmd.go index c0bd0d7..20802d5 100644 --- a/apptrust/commands/application/create_app_cmd.go +++ b/apptrust/commands/application/create_app_cmd.go @@ -1,6 +1,8 @@ package application import ( + "encoding/json" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" @@ -9,7 +11,9 @@ import ( commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" "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" "github.com/jfrog/jfrog-cli-application/apptrust/app" "github.com/jfrog/jfrog-cli-application/apptrust/commands" @@ -42,12 +46,31 @@ func (cac *createAppCommand) CommandName() string { func (cac *createAppCommand) buildRequestPayload(ctx *components.Context) (*model.AppDescriptor, error) { applicationKey := ctx.Arguments[0] - applicationName := ctx.GetStringFlagValue(commands.ApplicationNameFlag) - if applicationName == "" { - // Default to the application key if application name is not provided - applicationName = applicationKey + + var appDescriptor *model.AppDescriptor + var err error + + if ctx.IsFlagSet(commands.SpecFlag) { + appDescriptor, err = cac.loadFromSpec(ctx) + } else { + appDescriptor, err = cac.buildFromFlags(ctx) + } + + if err != nil { + return nil, err + } + + appDescriptor.ApplicationKey = applicationKey + if appDescriptor.ApplicationName == "" { + appDescriptor.ApplicationName = applicationKey } + return appDescriptor, nil +} + +func (cac *createAppCommand) buildFromFlags(ctx *components.Context) (*model.AppDescriptor, error) { + applicationName := ctx.GetStringFlagValue(commands.ApplicationNameFlag) + project := ctx.GetStringFlagValue(commands.ProjectFlag) if project == "" { return nil, errorutils.CheckErrorf("--%s is mandatory", commands.ProjectFlag) @@ -83,7 +106,6 @@ func (cac *createAppCommand) buildRequestPayload(ctx *components.Context) (*mode return &model.AppDescriptor{ ApplicationName: applicationName, - ApplicationKey: applicationKey, Description: description, ProjectKey: project, MaturityLevel: maturityLevel, @@ -94,9 +116,34 @@ func (cac *createAppCommand) buildRequestPayload(ctx *components.Context) (*mode }, nil } +func (cac *createAppCommand) loadFromSpec(ctx *components.Context) (*model.AppDescriptor, error) { + specFilePath := ctx.GetStringFlagValue(commands.SpecFlag) + spec := new(model.AppDescriptor) + specVars := coreutils.SpecVarsStringToMap(ctx.GetStringFlagValue(commands.SpecVarsFlag)) + content, err := fileutils.ReadFile(specFilePath) + if errorutils.CheckError(err) != nil { + return nil, err + } + + if len(specVars) > 0 { + content = coreutils.ReplaceVars(content, specVars) + } + + err = json.Unmarshal(content, spec) + if errorutils.CheckError(err) != nil { + return nil, err + } + + if spec.ProjectKey == "" { + return nil, errorutils.CheckErrorf("project_key is mandatory in spec file") + } + + return spec, nil +} + func (cac *createAppCommand) prepareAndRunCommand(ctx *components.Context) error { - if len(ctx.Arguments) != 1 { - return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + if err := validateCreateAppContext(ctx); err != nil { + return err } var err error @@ -113,6 +160,37 @@ func (cac *createAppCommand) prepareAndRunCommand(ctx *components.Context) error return commonCLiCommands.Exec(cac) } +func validateCreateAppContext(ctx *components.Context) error { + if err := validateNoSpecAndFlagsTogether(ctx); err != nil { + return err + } + if len(ctx.Arguments) != 1 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + return nil +} + +func validateNoSpecAndFlagsTogether(ctx *components.Context) error { + if ctx.IsFlagSet(commands.SpecFlag) { + otherAppFlags := []string{ + commands.ApplicationNameFlag, + commands.ProjectFlag, + commands.DescriptionFlag, + commands.BusinessCriticalityFlag, + commands.MaturityLevelFlag, + commands.LabelsFlag, + commands.UserOwnersFlag, + commands.GroupOwnersFlag, + } + for _, flag := range otherAppFlags { + if ctx.IsFlagSet(flag) { + return errorutils.CheckErrorf("the flag --%s is not allowed when --spec is provided.", flag) + } + } + } + return nil +} + func GetCreateAppCommand(appContext app.Context) components.Command { cmd := &createAppCommand{ applicationService: appContext.GetApplicationService(), diff --git a/apptrust/commands/application/create_app_cmd_test.go b/apptrust/commands/application/create_app_cmd_test.go index cc1b454..bb47e73 100644 --- a/apptrust/commands/application/create_app_cmd_test.go +++ b/apptrust/commands/application/create_app_cmd_test.go @@ -15,15 +15,36 @@ import ( "go.uber.org/mock/gomock" ) -func TestCreateAppCommand_Run(t *testing.T) { +func TestCreateAppCommand_Run_Flags(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - serverDetails := &config.ServerDetails{Url: "https://example.com"} + ctx := &components.Context{ + Arguments: []string{"app-key"}, + } + ctx.AddStringFlag("application-name", "test-app") + ctx.AddStringFlag("project", "test-project") + ctx.AddStringFlag("desc", "Test application") + ctx.AddStringFlag("business-criticality", "high") + ctx.AddStringFlag("maturity-level", "production") + ctx.AddStringFlag("labels", "env=prod;region=us-east") + ctx.AddStringFlag("user-owners", "john.doe;jane.smith") + ctx.AddStringFlag("group-owners", "devops;security") + ctx.AddStringFlag("url", "https://example.com") + requestPayload := &model.AppDescriptor{ - ApplicationKey: "app-key", - ApplicationName: "app-name", - ProjectKey: "proj-key", + ApplicationKey: "app-key", + ApplicationName: "test-app", + ProjectKey: "test-project", + Description: "Test application", + BusinessCriticality: "high", + MaturityLevel: "production", + Labels: map[string]string{ + "env": "prod", + "region": "us-east", + }, + UserOwners: []string{"john.doe", "jane.smith"}, + GroupOwners: []string{"devops", "security"}, } mockAppService := mockapps.NewMockApplicationService(ctrl) @@ -31,11 +52,10 @@ func TestCreateAppCommand_Run(t *testing.T) { cmd := &createAppCommand{ applicationService: mockAppService, - serverDetails: serverDetails, requestBody: requestPayload, } - err := cmd.Run() + err := cmd.prepareAndRunCommand(ctx) assert.NoError(t, err) } @@ -84,3 +104,199 @@ func TestCreateAppCommand_WrongNumberOfArguments(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "Wrong number of arguments") } + +func TestCreateAppCommand_MissingProjectFlag(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + Arguments: []string{"app-key"}, + } + ctx.AddStringFlag("application-name", "test-app") + ctx.AddStringFlag("url", "https://example.com") + mockAppService := mockapps.NewMockApplicationService(ctrl) + + cmd := &createAppCommand{ + applicationService: mockAppService, + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--project is mandatory") +} + +func TestCreateAppCommand_Run_SpecFile(t *testing.T) { + tests := []struct { + name string + specPath string + args []string + expectsError bool + errorContains string + expectsPayload *model.AppDescriptor + }{ + { + name: "minimal spec file", + specPath: "./testfiles/minimal-spec.json", + args: []string{"app-min"}, + expectsPayload: &model.AppDescriptor{ + ApplicationKey: "app-min", + ApplicationName: "app-min", + ProjectKey: "test-project", + }, + }, + { + name: "full spec file", + specPath: "./testfiles/full-spec.json", + args: []string{"app-full"}, + expectsPayload: &model.AppDescriptor{ + ApplicationKey: "app-full", + ApplicationName: "test-app-full", + ProjectKey: "test-project", + Description: "A comprehensive test application", + MaturityLevel: "production", + BusinessCriticality: "high", + Labels: map[string]string{ + "environment": "production", + "region": "us-east-1", + "team": "devops", + }, + UserOwners: []string{"john.doe", "jane.smith"}, + GroupOwners: []string{"devops-team", "security-team"}, + }, + }, + { + name: "invalid spec file", + specPath: "./testfiles/invalid-spec.json", + args: []string{"app-invalid"}, + expectsError: true, + errorContains: "unexpected end of JSON input", + }, + { + name: "missing project key", + specPath: "./testfiles/missing-project-spec.json", + args: []string{"app-no-project"}, + expectsError: true, + errorContains: "project_key is mandatory in spec file", + }, + { + name: "non-existent spec file", + specPath: "./testfiles/non-existent.json", + args: []string{"app-nonexistent"}, + expectsError: true, + errorContains: "no such file or directory", + }, + { + name: "spec with application_key that should be ignored", + specPath: "./testfiles/spec-with-app-key.json", + args: []string{"command-line-app-key"}, + expectsPayload: &model.AppDescriptor{ + ApplicationKey: "command-line-app-key", + ApplicationName: "test-app", + ProjectKey: "test-project", + Description: "A test application with application_key that should be ignored", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + Arguments: tt.args, + } + ctx.AddStringFlag("url", "https://example.com") + ctx.AddStringFlag("spec", tt.specPath) + + var actualPayload *model.AppDescriptor + mockAppService := mockapps.NewMockApplicationService(ctrl) + if !tt.expectsError { + mockAppService.EXPECT().CreateApplication(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.AppDescriptor) error { + actualPayload = req + return nil + }).Times(1) + } + + cmd := &createAppCommand{ + applicationService: mockAppService, + } + + err := cmd.prepareAndRunCommand(ctx) + if tt.expectsError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectsPayload, actualPayload) + } + }) + } +} + +func TestCreateAppCommand_Run_SpecVars(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + expectedPayload := &model.AppDescriptor{ + ApplicationKey: "app-with-vars", + ApplicationName: "test-app", + ProjectKey: "test-project", + Description: "A test application for production", + MaturityLevel: "production", + BusinessCriticality: "high", + Labels: map[string]string{ + "environment": "production", + "region": "us-east-1", + }, + } + + ctx := &components.Context{ + Arguments: []string{"app-with-vars"}, + } + ctx.AddStringFlag("spec", "./testfiles/with-vars-spec.json") + ctx.AddStringFlag("spec-vars", "PROJECT_KEY=test-project;APP_NAME=test-app;ENVIRONMENT=production;MATURITY_LEVEL=production;CRITICALITY=high;REGION=us-east-1") + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.AppDescriptor + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().CreateApplication(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.AppDescriptor) error { + actualPayload = req + return nil + }).Times(1) + + cmd := &createAppCommand{ + applicationService: mockAppService, + } + + err := cmd.prepareAndRunCommand(ctx) + assert.NoError(t, err) + assert.Equal(t, expectedPayload, actualPayload) +} + +func TestCreateAppCommand_Error_SpecAndFlags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + testSpecPath := "./testfiles/minimal-spec.json" + ctx := &components.Context{ + Arguments: []string{"app-key"}, + } + ctx.AddStringFlag("spec", testSpecPath) + ctx.AddStringFlag("project", "test-project") + ctx.AddStringFlag("url", "https://example.com") + + mockAppService := mockapps.NewMockApplicationService(ctrl) + + cmd := &createAppCommand{ + applicationService: mockAppService, + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "the flag --project is not allowed when --spec is provided") +} diff --git a/apptrust/commands/application/testfiles/full-spec.json b/apptrust/commands/application/testfiles/full-spec.json new file mode 100644 index 0000000..b65aefc --- /dev/null +++ b/apptrust/commands/application/testfiles/full-spec.json @@ -0,0 +1,20 @@ +{ + "project_key": "test-project", + "application_name": "test-app-full", + "description": "A comprehensive test application", + "maturity_level": "production", + "criticality": "high", + "labels": { + "environment": "production", + "region": "us-east-1", + "team": "devops" + }, + "user_owners": [ + "john.doe", + "jane.smith" + ], + "group_owners": [ + "devops-team", + "security-team" + ] +} \ No newline at end of file diff --git a/apptrust/commands/application/testfiles/invalid-spec.json b/apptrust/commands/application/testfiles/invalid-spec.json new file mode 100644 index 0000000..d2de572 --- /dev/null +++ b/apptrust/commands/application/testfiles/invalid-spec.json @@ -0,0 +1,5 @@ +{ + "project_key": "test-project", + "application_name": "test-app", + "description": "A test application with invalid JSON", + "unclosed": "value" \ No newline at end of file diff --git a/apptrust/commands/application/testfiles/minimal-spec.json b/apptrust/commands/application/testfiles/minimal-spec.json new file mode 100644 index 0000000..3b6bcd9 --- /dev/null +++ b/apptrust/commands/application/testfiles/minimal-spec.json @@ -0,0 +1,3 @@ +{ + "project_key": "test-project" +} \ No newline at end of file diff --git a/apptrust/commands/application/testfiles/missing-project-spec.json b/apptrust/commands/application/testfiles/missing-project-spec.json new file mode 100644 index 0000000..e1904cd --- /dev/null +++ b/apptrust/commands/application/testfiles/missing-project-spec.json @@ -0,0 +1,4 @@ +{ + "application_name": "test-app", + "description": "A test application without project key" +} \ No newline at end of file diff --git a/apptrust/commands/application/testfiles/spec-with-app-key.json b/apptrust/commands/application/testfiles/spec-with-app-key.json new file mode 100644 index 0000000..995d0cb --- /dev/null +++ b/apptrust/commands/application/testfiles/spec-with-app-key.json @@ -0,0 +1,6 @@ +{ + "application_key": "ignored-app-key", + "project_key": "test-project", + "application_name": "test-app", + "description": "A test application with application_key that should be ignored" +} \ No newline at end of file diff --git a/apptrust/commands/application/testfiles/with-vars-spec.json b/apptrust/commands/application/testfiles/with-vars-spec.json new file mode 100644 index 0000000..ebbbce5 --- /dev/null +++ b/apptrust/commands/application/testfiles/with-vars-spec.json @@ -0,0 +1,11 @@ +{ + "project_key": "${PROJECT_KEY}", + "application_name": "${APP_NAME}", + "description": "A test application for ${ENVIRONMENT}", + "maturity_level": "${MATURITY_LEVEL}", + "criticality": "${CRITICALITY}", + "labels": { + "environment": "${ENVIRONMENT}", + "region": "${REGION}" + } +} \ No newline at end of file diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 083b259..ec39b79 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -66,7 +66,7 @@ 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 = true }), + ProjectFlag: components.NewStringFlag(ProjectFlag, "Project key associated with the application.", 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 }), @@ -78,8 +78,8 @@ var flagsMap = map[string]components.Flag{ 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.DefaultValue = model.BusinessCriticalityValues[0] }), - MaturityLevelFlag: components.NewStringFlag(MaturityLevelFlag, "The maturity level.", func(f *components.StringFlag) { f.DefaultValue = model.MaturityLevelValues[0] }), + 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 }),