diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 031a3e1..c13898c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -29,6 +29,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} + allow-unsafe-pr-checkout: true - name: Setup Go uses: actions/setup-go@v6 diff --git a/apptrust/commands/application/application_utils.go b/apptrust/commands/application/application_utils.go index 2481a07..0e016a5 100644 --- a/apptrust/commands/application/application_utils.go +++ b/apptrust/commands/application/application_utils.go @@ -84,5 +84,13 @@ func populateApplicationFromFlags(ctx *components.Context, descriptor *model.App descriptor.GroupOwners = &groupOwners } + if ctx.IsFlagSet(commands.MonitorPolicyFlag) { + monitorPolicy, err := utils.ParseMonitorPolicyFlag(ctx.GetStringFlagValue(commands.MonitorPolicyFlag)) + if err != nil { + return fmt.Errorf("failed to parse --%s: %w", commands.MonitorPolicyFlag, err) + } + descriptor.MonitorPolicy = monitorPolicy + } + return nil } diff --git a/apptrust/commands/application/create_app_cmd.go b/apptrust/commands/application/create_app_cmd.go index ec7e404..d3489bb 100644 --- a/apptrust/commands/application/create_app_cmd.go +++ b/apptrust/commands/application/create_app_cmd.go @@ -164,6 +164,7 @@ func validateNoSpecAndFlagsTogether(ctx *components.Context) error { commands.LabelsFlag, commands.UserOwnersFlag, commands.GroupOwnersFlag, + commands.MonitorPolicyFlag, } for _, flag := range otherAppFlags { if ctx.IsFlagSet(flag) { @@ -197,12 +198,14 @@ Common patterns: $ jf apptrust app-create my-app --project=default --application-name="My App" --desc="Service X" $ jf apptrust app-create my-app --project=default --business-criticality=high --maturity-level=production $ jf apptrust app-create my-app --project=default --labels="team=core;area=platform" --user-owners="alice;bob" + $ jf apptrust app-create my-app --project=default --monitor-policy="type=version_count, value=5" $ jf apptrust app-create my-app --spec=app-spec.json --spec-vars="ENV=prod" Gotchas: -- --spec is mutually exclusive with --application-name, --project, --desc, --business-criticality, --maturity-level, --labels, --user-owners, --group-owners. +- --spec is mutually exclusive with --application-name, --project, --desc, --business-criticality, --maturity-level, --labels, --user-owners, --group-owners, --monitor-policy. - If --application-name is omitted, the application-key is used as the display name. - --labels uses semicolon separators (not commas) and key=value pairs. +- --monitor-policy takes 'type=[, value=]'. 'value' is required (positive integer) when type is "time_frame_in_months" or "version_count", and must be omitted when type is "none". Related: jf apptrust app-update, jf apptrust app-delete, jf apptrust version-create`, Category: common.CategoryApplication, diff --git a/apptrust/commands/application/create_app_cmd_test.go b/apptrust/commands/application/create_app_cmd_test.go index f8a1d74..ac1ca7c 100644 --- a/apptrust/commands/application/create_app_cmd_test.go +++ b/apptrust/commands/application/create_app_cmd_test.go @@ -22,6 +22,7 @@ func TestCreateAppCommand_Run_Flags(t *testing.T) { description := "Test application" businessCriticality := "high" maturityLevel := "production" + monitorPolicyValue := 5 ctx := &components.Context{ Arguments: []string{"app-key"}, @@ -34,6 +35,7 @@ func TestCreateAppCommand_Run_Flags(t *testing.T) { ctx.AddStringFlag("labels", "env=prod;region=us-east") ctx.AddStringFlag("user-owners", "john.doe;jane.smith") ctx.AddStringFlag("group-owners", "devops;security") + ctx.AddStringFlag("monitor-policy", "type=version_count, value=5") ctx.AddStringFlag("url", "https://example.com") requestPayload := &model.AppDescriptor{ @@ -47,8 +49,9 @@ func TestCreateAppCommand_Run_Flags(t *testing.T) { {Key: "env", Value: "prod"}, {Key: "region", Value: "us-east"}, }, - UserOwners: &[]string{"john.doe", "jane.smith"}, - GroupOwners: &[]string{"devops", "security"}, + UserOwners: &[]string{"john.doe", "jane.smith"}, + GroupOwners: &[]string{"devops", "security"}, + MonitorPolicy: &model.MonitorPolicy{Type: model.MonitorPolicyTypeVersionCount, Value: &monitorPolicyValue}, } mockAppService := mockapps.NewMockApplicationService(ctrl) diff --git a/apptrust/commands/application/update_app_cmd.go b/apptrust/commands/application/update_app_cmd.go index f5ac398..0f667da 100644 --- a/apptrust/commands/application/update_app_cmd.go +++ b/apptrust/commands/application/update_app_cmd.go @@ -109,10 +109,12 @@ Common patterns: $ jf apptrust app-update my-app --add-labels="env=prod;tier=critical" $ jf apptrust app-update my-app --remove-labels="env=staging" $ jf apptrust app-update my-app --user-owners="alice;bob" --group-owners="platform-team" + $ jf apptrust app-update my-app --monitor-policy="type=version_count, value=5" Gotchas: - --labels replaces the full label set; --add-labels and --remove-labels modify incrementally. - --user-owners / --group-owners take a semicolon-separated list and send exactly the owners you specify; there are no incremental add/remove-owner flags (unlike --add-labels / --remove-labels for labels). +- --monitor-policy takes 'type=[, value=]'. 'value' is required (positive integer) when type is "time_frame_in_months" or "version_count", and must be omitted when type is "none". When --monitor-policy is not provided, the current policy is left unchanged. - Application key cannot be changed; use app-delete and app-create if you need a different key. Related: jf apptrust app-create, jf apptrust app-delete`, diff --git a/apptrust/commands/application/update_app_cmd_test.go b/apptrust/commands/application/update_app_cmd_test.go index d12e26b..ccb7099 100644 --- a/apptrust/commands/application/update_app_cmd_test.go +++ b/apptrust/commands/application/update_app_cmd_test.go @@ -111,6 +111,8 @@ func TestUpdateAppCommand_WrongNumberOfArguments(t *testing.T) { } func TestUpdateAppCommand_FlagsSuite(t *testing.T) { + monitorPolicyValue := 5 + tests := []struct { name string ctxSetup func(*components.Context) @@ -255,6 +257,17 @@ func TestUpdateAppCommand_FlagsSuite(t *testing.T) { }, }, }, + { + name: "monitor-policy", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key"} + ctx.AddStringFlag(commands.MonitorPolicyFlag, "type=version_count, value=5") + }, + expectsPayload: &model.AppDescriptor{ + ApplicationKey: "app-key", + MonitorPolicy: &model.MonitorPolicy{Type: model.MonitorPolicyTypeVersionCount, Value: &monitorPolicyValue}, + }, + }, { name: "invalid add-labels format - missing equals", ctxSetup: func(ctx *components.Context) { diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index ada8dfb..d0dd291 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -44,6 +44,7 @@ const ( RemoveLabelsFlag = "remove-labels" UserOwnersFlag = "user-owners" GroupOwnersFlag = "group-owners" + MonitorPolicyFlag = "monitor-policy" SyncFlag = "sync" PromotionTypeFlag = "promotion-type" DryRunFlag = "dry-run" @@ -97,6 +98,7 @@ var flagsMap = map[string]components.Flag{ RemoveLabelsFlag: components.NewStringFlag(RemoveLabelsFlag, "List of semicolon-separated (;) labels to remove in the form of \"key1=value1;key2=value2;...\" (wrapped by quotes).", func(f *components.StringFlag) { f.Mandatory = false }), UserOwnersFlag: components.NewStringFlag(UserOwnersFlag, "semicolon-separated (;) list of user owners in the form of \"user1;user2;...\" (wrapped by quotes).", func(f *components.StringFlag) { f.Mandatory = false }), GroupOwnersFlag: components.NewStringFlag(GroupOwnersFlag, "semicolon-separated (;) list of group owners in the form of \"group1;group2;...\" (wrapped by quotes).", func(f *components.StringFlag) { f.Mandatory = false }), + MonitorPolicyFlag: components.NewStringFlag(MonitorPolicyFlag, "Defines how long application versions remain monitored, in the form of 'type=[, value=]'. Supported types: "+coreutils.ListToText(model.MonitorPolicyTypeValues)+". For '"+model.MonitorPolicyTypeTimeframe+"' or '"+model.MonitorPolicyTypeVersionCount+"', 'value' is the number of months or versions to keep monitoring (a positive integer). For '"+model.MonitorPolicyTypeNone+"', monitoring is disabled and 'value' must be omitted.", 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()), @@ -278,6 +280,7 @@ var commandFlags = map[string][]string{ LabelsFlag, UserOwnersFlag, GroupOwnersFlag, + MonitorPolicyFlag, SpecFlag, SpecVarsFlag, }, @@ -296,6 +299,7 @@ var commandFlags = map[string][]string{ RemoveLabelsFlag, UserOwnersFlag, GroupOwnersFlag, + MonitorPolicyFlag, }, AppDelete: { diff --git a/apptrust/commands/utils/utils.go b/apptrust/commands/utils/utils.go index 6173a62..f1b4169 100644 --- a/apptrust/commands/utils/utils.go +++ b/apptrust/commands/utils/utils.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "slices" + "strconv" "strings" "github.com/jfrog/jfrog-cli-application/apptrust/model" @@ -168,6 +169,50 @@ func ParseListPropertiesFlag(propertiesStr string) (map[string][]string, error) return result, nil } +func ParseMonitorPolicyFlag(flagValue string) (*model.MonitorPolicy, error) { + fields, err := ParseKeyValueString(flagValue, ",") + if err != nil { + return nil, err + } + + for key := range fields { + if key != "type" && key != "value" { + return nil, errorutils.CheckErrorf("invalid field '%s' (supported fields: type, value)", key) + } + } + + policyType, typeSet := fields["type"] + if !typeSet { + return nil, errorutils.CheckErrorf("missing required 'type' field") + } + if !slices.Contains(model.MonitorPolicyTypeValues, policyType) { + return nil, errorutils.CheckErrorf("invalid type '%s' (supported types: %s)", policyType, coreutils.ListToText(model.MonitorPolicyTypeValues)) + } + + valueStr, valueSet := fields["value"] + isNone := policyType == model.MonitorPolicyTypeNone + if isNone && valueSet { + return nil, errorutils.CheckErrorf("'value' must not be set when type is '%s'", model.MonitorPolicyTypeNone) + } + if !isNone && !valueSet { + return nil, errorutils.CheckErrorf("'value' is required when type is '%s'", policyType) + } + + policy := &model.MonitorPolicy{Type: policyType} + if valueSet { + value, err := strconv.Atoi(valueStr) + if err != nil { + return nil, errorutils.CheckErrorf("'value' must be an integer: %s", err.Error()) + } + if value <= 0 { + return nil, errorutils.CheckErrorf("'value' must be a positive integer") + } + policy.Value = &value + } + + return policy, nil +} + func ParseLabelKeyValuePairs(flagValue string) ([]model.LabelEntry, error) { if flagValue == "" { return []model.LabelEntry{}, nil diff --git a/apptrust/commands/utils/utils_test.go b/apptrust/commands/utils/utils_test.go index b5387ab..d817a63 100644 --- a/apptrust/commands/utils/utils_test.go +++ b/apptrust/commands/utils/utils_test.go @@ -165,6 +165,110 @@ func TestParseNameVersionPairs(t *testing.T) { } } +func TestParseMonitorPolicyFlag(t *testing.T) { + valueFive := 5 + valueThree := 3 + + tests := []struct { + name string + input string + expected *model.MonitorPolicy + expectErr bool + errorMsg string + }{ + { + name: "version count", + input: "type=version_count, value=5", + expected: &model.MonitorPolicy{Type: model.MonitorPolicyTypeVersionCount, Value: &valueFive}, + }, + { + name: "time frame in months", + input: "type=time_frame_in_months, value=3", + expected: &model.MonitorPolicy{Type: model.MonitorPolicyTypeTimeframe, Value: &valueThree}, + }, + { + name: "none without value", + input: "type=none", + expected: &model.MonitorPolicy{Type: model.MonitorPolicyTypeNone}, + }, + { + name: "missing type", + input: "value=5", + expectErr: true, + errorMsg: "missing required 'type' field", + }, + { + name: "invalid type", + input: "type=bogus, value=5", + expectErr: true, + errorMsg: "invalid type 'bogus'", + }, + { + name: "missing value for enabled type", + input: "type=version_count", + expectErr: true, + errorMsg: "'value' is required", + }, + { + name: "value set for none", + input: "type=none, value=5", + expectErr: true, + errorMsg: "'value' must not be set", + }, + { + name: "non-integer value", + input: "type=version_count, value=abc", + expectErr: true, + errorMsg: "must be an integer", + }, + { + name: "zero value", + input: "type=version_count, value=0", + expectErr: true, + errorMsg: "must be a positive integer", + }, + { + name: "negative value", + input: "type=version_count, value=-1", + expectErr: true, + errorMsg: "must be a positive integer", + }, + { + name: "unknown field", + input: "type=version_count, value=5, foo=bar", + expectErr: true, + errorMsg: "invalid field 'foo'", + }, + { + name: "empty string", + input: "", + expectErr: true, + errorMsg: "missing required 'type' field", + }, + { + name: "invalid key-value pair", + input: "type", + expectErr: true, + errorMsg: "invalid key-value pair", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseMonitorPolicyFlag(tt.input) + if tt.expectErr { + assert.Error(t, err, "ParseMonitorPolicyFlag(%q) expected error, got nil", tt.input) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg, "error message should contain %q", tt.errorMsg) + } + return + } + assert.NoError(t, err, "ParseMonitorPolicyFlag(%q) unexpected error: %v", tt.input, err) + assert.Equal(t, tt.expected, result, "ParseMonitorPolicyFlag(%q) = %v, want %v", tt.input, result, tt.expected) + }) + } +} + func TestParseLabelKeyValuePairs(t *testing.T) { tests := []struct { name string diff --git a/apptrust/model/app_descriptor.go b/apptrust/model/app_descriptor.go index 8948072..c7f18b3 100644 --- a/apptrust/model/app_descriptor.go +++ b/apptrust/model/app_descriptor.go @@ -11,6 +11,10 @@ const ( MaturityLevelExperimental = "experimental" MaturityLevelProduction = "production" MaturityLevelEndOfLife = "end_of_life" + + MonitorPolicyTypeNone = "none" + MonitorPolicyTypeTimeframe = "time_frame_in_months" + MonitorPolicyTypeVersionCount = "version_count" ) var ( @@ -28,6 +32,12 @@ var ( MaturityLevelProduction, MaturityLevelEndOfLife, } + + MonitorPolicyTypeValues = []string{ + MonitorPolicyTypeNone, + MonitorPolicyTypeTimeframe, + MonitorPolicyTypeVersionCount, + } ) type LabelEntry struct { @@ -35,20 +45,26 @@ type LabelEntry struct { Value string `json:"value"` } +type MonitorPolicy struct { + Type string `json:"type"` + Value *int `json:"value,omitempty"` +} + type LabelUpdates struct { Remove []LabelEntry `json:"remove,omitempty"` Add []LabelEntry `json:"add,omitempty"` } type AppDescriptor struct { - ApplicationKey string `json:"application_key"` - ApplicationName string `json:"application_name,omitempty"` - ProjectKey string `json:"project_key,omitempty"` - Description *string `json:"description,omitempty"` - MaturityLevel *string `json:"maturity_level,omitempty"` - BusinessCriticality *string `json:"criticality,omitempty"` - Labels *[]LabelEntry `json:"labels,omitempty"` - LabelUpdates *LabelUpdates `json:"label_updates,omitempty"` - UserOwners *[]string `json:"user_owners,omitempty"` - GroupOwners *[]string `json:"group_owners,omitempty"` + ApplicationKey string `json:"application_key"` + ApplicationName string `json:"application_name,omitempty"` + ProjectKey string `json:"project_key,omitempty"` + Description *string `json:"description,omitempty"` + MaturityLevel *string `json:"maturity_level,omitempty"` + BusinessCriticality *string `json:"criticality,omitempty"` + Labels *[]LabelEntry `json:"labels,omitempty"` + LabelUpdates *LabelUpdates `json:"label_updates,omitempty"` + UserOwners *[]string `json:"user_owners,omitempty"` + GroupOwners *[]string `json:"group_owners,omitempty"` + MonitorPolicy *MonitorPolicy `json:"monitor_policy,omitempty"` } diff --git a/e2e/application_test.go b/e2e/application_test.go index 40a1b54..7284b80 100644 --- a/e2e/application_test.go +++ b/e2e/application_test.go @@ -20,6 +20,7 @@ func TestCreateApp(t *testing.T) { maturityLevel := "production" userOwners := []string{"admin", "developer"} groupOwners := []string{"devops-team", "security-team"} + monitorPolicyValue := 5 err := utils.AppTrustCli.Exec("app-create", appKey, "--project="+projectKey, @@ -29,7 +30,8 @@ func TestCreateApp(t *testing.T) { "--maturity-level="+maturityLevel, "--labels=env=prod;team=devops", "--user-owners="+strings.Join(userOwners, ";"), - "--group-owners="+strings.Join(groupOwners, ";")) + "--group-owners="+strings.Join(groupOwners, ";"), + "--monitor-policy=type=version_count, value=5") assert.NoError(t, err) // Fetch and verify the application was created correctly @@ -44,6 +46,7 @@ func TestCreateApp(t *testing.T) { assert.ElementsMatch(t, []model.LabelEntry{{Key: "env", Value: "prod"}, {Key: "team", Value: "devops"}}, *app.Labels) assert.Equal(t, userOwners, *app.UserOwners) assert.Equal(t, groupOwners, *app.GroupOwners) + assert.Equal(t, &model.MonitorPolicy{Type: model.MonitorPolicyTypeVersionCount, Value: &monitorPolicyValue}, app.MonitorPolicy) utils.DeleteApplication(t, appKey) } @@ -60,6 +63,7 @@ func TestUpdateApp(t *testing.T) { updatedMaturityLevel := "production" updatedUserOwners := []string{"app-admin", "frog"} updatedGroupOwners := []string{"dev-team", "security-team"} + monitorPolicyValue := 6 err := utils.AppTrustCli.Exec("app-update", appKey, "--application-name="+updatedAppName, @@ -68,7 +72,8 @@ func TestUpdateApp(t *testing.T) { "--maturity-level="+updatedMaturityLevel, "--labels=env=qa;team=dev", "--user-owners="+strings.Join(updatedUserOwners, ";"), - "--group-owners="+strings.Join(updatedGroupOwners, ";")) + "--group-owners="+strings.Join(updatedGroupOwners, ";"), + "--monitor-policy=type=time_frame_in_months, value=6") assert.NoError(t, err) // Fetch and verify the application was updated correctly @@ -83,6 +88,7 @@ func TestUpdateApp(t *testing.T) { assert.ElementsMatch(t, []model.LabelEntry{{Key: "env", Value: "qa"}, {Key: "team", Value: "dev"}}, *app.Labels) assert.Equal(t, updatedUserOwners, *app.UserOwners) assert.Equal(t, updatedGroupOwners, *app.GroupOwners) + assert.Equal(t, &model.MonitorPolicy{Type: model.MonitorPolicyTypeTimeframe, Value: &monitorPolicyValue}, app.MonitorPolicy) utils.DeleteApplication(t, appKey) }