diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index df4cd4b..19fe515 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -277,7 +277,17 @@ func buildCtx(cmd *cobra.Command, cs *spec.CommandSpec, args []string, r *Regist return nil, err } for _, f := range cs.Flags { - if f.Required && cmdctx.GetString(ctx.FlagValues, f.Name) == "" { + if !f.Required { + continue + } + var missing bool + switch { + case f.IsArray || f.IsMulti: + missing = len(cmdctx.GetStringSlice(ctx.FlagValues, f.Name)) == 0 + default: + missing = cmdctx.GetString(ctx.FlagValues, f.Name) == "" + } + if missing { if len(f.CompletionValues) > 0 { return nil, fmt.Errorf("flag --%s is required (%s)", f.Name, strings.Join(f.CompletionValues, ", ")) } diff --git a/pkg/registry/buildctx_workflow_test.go b/pkg/registry/buildctx_workflow_test.go index 69650d7..5ca7a61 100644 --- a/pkg/registry/buildctx_workflow_test.go +++ b/pkg/registry/buildctx_workflow_test.go @@ -159,6 +159,37 @@ func TestBuildCtx_WorkflowRequiredFlag(t *testing.T) { } } +func TestBuildCtx_WorkflowRequiredArrayFlag(t *testing.T) { + r := New() + registerWorkflowExecute(t, r, "reqarrayflag", &spec.CommandSpec{ + Flags: []spec.Flag{ + {Name: "keys", Required: true, IsArray: true, Description: "keys to remove"}, + }, + }) + cs := r.GetSpec(VerbExecute, "reqarrayflag") + + t.Run("missing", func(t *testing.T) { + cmd := buildWorkflowTestCmd(t, r, cs) + _, err := buildCtx(cmd, cs, []string{"my-id"}, r) + if err == nil { + t.Fatal("buildCtx() = nil, want error") + } + if !strings.Contains(err.Error(), "flag --keys is required") { + t.Fatalf("buildCtx() error %q missing expected substring", err) + } + }) + + t.Run("provided", func(t *testing.T) { + cmd := buildWorkflowTestCmd(t, r, cs) + if err := cmd.ParseFlags([]string{"--keys", "key-one,key-two"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if _, err := buildCtx(cmd, cs, []string{"my-id"}, r); err != nil { + t.Fatalf("buildCtx() = %v, want no error", err) + } + }) +} + func TestBuildCtx_WorkflowIdPartsTooMany(t *testing.T) { r := New() registerWorkflowExecute(t, r, "cluster", &spec.CommandSpec{ diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index 9bf4a0a..c60dd0f 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -1,7 +1,6 @@ spec_version: 1 module_type: builtin module_desc: Harness FME — Feature flags and targeting definitions -harness_internal: true help_text: | ## Feature Management & Experimentation (fme) @@ -15,20 +14,96 @@ nouns: noun_aliases: [feature_flags, ff] fields: - id: name - expr: it.entity.name + expr: it.name - id: description - expr: it.entity.description ?? "" + expr: it.description ?? "" + mutable_path: description width_max: 60 - id: traffic_type label: Traffic Type - expr: it.entity.trafficType.name + expr: it.trafficType.name - id: status - expr: it.entity.status + expr: it.status - id: rollout_status label: Rollout Status - expr: it.entity.rolloutStatus.name ?? "" + expr: it.rolloutStatus.name ?? "" - id: created - expr: it.entity.createdAt + expr: epochMs(it.createdAt * 1000) + field_type: ts + + - noun: fme_environment + short_desc: A Harness FME environment (distinct from the CD "environment" noun). + noun_aliases: [fme_environments, fme_env] + fields: + - id: name + expr: it.name + mutable_path: name + - id: production + label: Production + expr: string(it.isProduction) + mutable_path: isProduction + - id: status + expr: it.status + + - noun: segment + short_desc: A Harness FME segment (a named list of targeting keys). + noun_aliases: [segments, seg] + fields: + - id: name + expr: it.name + - id: description + expr: it.description ?? "" + mutable_path: description + width_max: 60 + - id: traffic_type + label: Traffic Type + expr: it.trafficType.name + - id: status + expr: it.status + - id: created + expr: epochMs(it.createdAt * 1000) + field_type: ts + + - noun: segment_definition_keys + short_desc: A targeting key within a segment definition (used for field lookup only). + fields: + - id: key + expr: it + + - noun: feature_flag_definition + short_desc: A feature flag's environment-specific targeting definition (used for field lookup only). + fields: + - id: environment + expr: it.environment.name + - id: default_treatment + label: Default Treatment + expr: it.defaultTreatment + - id: traffic_allocation + label: Traffic Allocation + expr: string(it.trafficAllocation) + mutable_path: trafficAllocation + - id: is_killed + label: Killed + expr: string(it.isKilled) + - id: created + expr: epochMs(it.createdAt * 1000) + field_type: ts + + - noun: segment_definition + short_desc: A segment's environment-specific targeting definition (used for field lookup only). + fields: + - id: segment + expr: it.segment.name + - id: environment + expr: it.environment.name + - id: description + expr: it.description ?? "" + mutable_path: description + width_max: 60 + - id: status + expr: it.status + - id: created + expr: epochMs(it.createdAt * 1000) field_type: ts commands: @@ -46,10 +121,10 @@ commands: description: "Filter by status: ACTIVE, ARCHIVED" completion_values: [ACTIVE, ARCHIVED] endpoint: - path: /v3/feature-flags + path: /fme/api/v4/feature-flags items_expr: it.data item_item_expr: it - get_id_expr: it.entity.name + get_id_expr: it.name query_params: account_id: auth.account organization_identifier: auth.org @@ -72,13 +147,13 @@ commands: short: Get feature flag details handler_type: endpoint endpoint: - path: /v3/feature-flags/{{ctx.id}} + path: /fme/api/v4/feature-flags/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project item_expr: it - yaml_pick_expr: it.entity + yaml_pick_expr: it - command: create feature_flag verb: create @@ -93,7 +168,7 @@ commands: set: true endpoint: method: POST - path: /v3/feature-flags + path: /fme/api/v4/feature-flags query_params: account_id: auth.account organization_identifier: auth.org @@ -101,10 +176,10 @@ commands: create_strategy: set-fields create_body_init: name: ctx.id - trafficType: flags.traffic-type + trafficType: flags["traffic-type"] create_body_wrap: "" - item_expr: it - text_header: "\nCreated feature flag {{it.entity.name}}\n" + item_expr: it.entity + text_header: "\nCreated feature flag {{it.name}}\n" - command: update feature_flag verb: update @@ -116,16 +191,16 @@ commands: del: true endpoint: method: PATCH - path: /v3/feature-flags/{{ctx.id}} - get_path: /v3/feature-flags/{{ctx.id}} + path: /fme/api/v4/feature-flags/{{ctx.id}} + get_path: /fme/api/v4/feature-flags/{{ctx.id}} update_strategy: get-then-patch - update_body_pick: it.entity + update_body_pick: it update_body_wrap: "" query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project - item_expr: it + item_expr: it.entity text_header: "\nUpdated feature flag {{ctx.id}}\n" - command: delete feature_flag @@ -136,7 +211,7 @@ commands: handler_type: endpoint endpoint: method: DELETE - path: /v3/feature-flags/{{ctx.id}} + path: /fme/api/v4/feature-flags/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -153,14 +228,14 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flags/{{ctx.id}}/archive + path: /fme/api/v4/feature-flags/{{ctx.id}}/archive query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nArchived feature flag {{ctx.id}}\n" - command: execute feature_flag:unarchive @@ -174,14 +249,14 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flags/{{ctx.id}}/unarchive + path: /fme/api/v4/feature-flags/{{ctx.id}}/unarchive query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nUnarchived feature flag {{ctx.id}}\n" # ── feature_flag:definition ─────────────────────────────────────────────────── @@ -194,11 +269,12 @@ commands: handler_type: endpoint requires_parentid: true parentid_label: "" + fields_noun: feature_flag_definition endpoint: - path: /v3/feature-flag-definitions + path: /fme/api/v4/feature-flag-definitions items_expr: it.data item_item_expr: it - get_id_expr: it.entity.environment.id + get_id_expr: it.environment.id query_params: account_id: auth.account organization_identifier: auth.org @@ -212,22 +288,7 @@ commands: page_size_max: 100 total_expr: it.totalCount countable: true - fields_extra: - - id: environment - expr: it.entity.environment.name - - id: default_treatment - label: Default Treatment - expr: it.entity.defaultTreatment - - id: traffic_allocation - label: Traffic Allocation - expr: string(it.entity.trafficAllocation) - - id: is_killed - label: Killed - expr: string(it.entity.killed) - - id: modified - expr: it.entity.modifiedAt - field_type: ts - columns: [environment, default_treatment, traffic_allocation, is_killed, modified] + columns: [environment, default_treatment, traffic_allocation, is_killed, created] - command: get feature_flag:definition verb: get @@ -235,19 +296,20 @@ commands: noun_variant: definition short: "Get a flag definition for a specific environment: harness get ff:definition --env " handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) required: true endpoint: - path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project environment_id: flags.env item_expr: it - yaml_pick_expr: it.entity + yaml_pick_expr: it - command: create feature_flag:definition verb: create @@ -255,6 +317,7 @@ commands: noun_variant: definition short: "Create a flag definition in an environment: harness create ff:definition --env -f def.json" handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -262,13 +325,13 @@ commands: endpoint: method: POST file_body: required - path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project environment_id: flags.env - item_expr: it + item_expr: it.entity text_header: "\nCreated definition for {{ctx.id}} in {{flags.env}}\n" - command: update feature_flag:definition @@ -277,6 +340,7 @@ commands: noun_variant: definition short: "Update a flag definition: harness update ff:definition --env --set trafficAllocation=80" handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -286,17 +350,17 @@ commands: del: true endpoint: method: PATCH - path: /v3/feature-flag-definitions/{{ctx.id}} - get_path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} + get_path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} update_strategy: get-then-patch - update_body_pick: it.entity + update_body_pick: it update_body_wrap: "" query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project environment_id: flags.env - item_expr: it + item_expr: it.entity text_header: "\nUpdated definition for {{ctx.id}} in {{flags.env}}\n" - command: delete feature_flag:definition @@ -312,7 +376,7 @@ commands: required: true endpoint: method: DELETE - path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -325,6 +389,7 @@ commands: noun_variant: kill short: "Kill a flag in an environment (all traffic → defaultTreatment): harness execute ff:kill --env " handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -333,7 +398,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flag-definitions/{{ctx.id}}/kill + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}}/kill query_params: account_id: auth.account organization_identifier: auth.org @@ -341,7 +406,7 @@ commands: environment_id: flags.env body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nKilled feature flag {{ctx.id}} in {{flags.env}}\n" - command: execute feature_flag:restore @@ -350,6 +415,7 @@ commands: noun_variant: restore short: Restore a killed flag definition handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -358,7 +424,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flag-definitions/{{ctx.id}}/restore + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}}/restore query_params: account_id: auth.account organization_identifier: auth.org @@ -366,7 +432,7 @@ commands: environment_id: flags.env body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nRestored feature flag {{ctx.id}} in {{flags.env}}\n" - command: execute feature_flag:reallocate @@ -375,6 +441,7 @@ commands: noun_variant: reallocate short: Re-hash traffic distribution for a flag definition handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -383,7 +450,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flag-definitions/{{ctx.id}}/reallocate + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}}/reallocate query_params: account_id: auth.account organization_identifier: auth.org @@ -391,5 +458,449 @@ commands: environment_id: flags.env body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nReallocated traffic for {{ctx.id}} in {{flags.env}}\n" + + # ── fme_environment ──────────────────────────────────────────────────────────── + # Named fme_environment (not "environment") because the "environment" noun is + # already owned by the cd module (a different resource). + + - command: list fme_environment + verb: list + noun: fme_environment + short: List FME environments + handler_type: endpoint + endpoint: + path: /fme/api/v4/environments + items_expr: it.data + item_item_expr: it + get_id_expr: it.id + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [name, production, status] + + - command: get fme_environment + verb: get + noun: fme_environment + short: "Get FME environment details: harness get fme_environment " + handler_type: endpoint + endpoint: + path: /fme/api/v4/environments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it + yaml_pick_expr: it + + - command: create fme_environment + verb: create + noun: fme_environment + short: "Create an FME environment: harness create fme_environment [--production]" + handler_type: endpoint + flags: + - name: production + is_bool: true + description: Mark the environment as a production environment + flags_builtin: + set: true + endpoint: + method: POST + path: /fme/api/v4/environments + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + create_strategy: set-fields + create_body_init: + name: ctx.id + isProduction: flags.production + create_body_wrap: "" + item_expr: it.entity + text_header: "\nCreated environment {{it.name}}\n" + + - command: update fme_environment + verb: update + noun: fme_environment + short: "Update an FME environment: harness update fme_environment --set name=foo" + handler_type: endpoint + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /fme/api/v4/environments/{{ctx.id}} + get_path: /fme/api/v4/environments/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: it + update_body_wrap: "" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it.entity + text_header: "\nUpdated environment {{ctx.id}}\n" + + - command: delete fme_environment + verb: delete + noun: fme_environment + confirm_mode: prompt + short: Delete (archive) an FME environment + handler_type: endpoint + endpoint: + method: DELETE + path: /fme/api/v4/environments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + + # ── segment ────────────────────────────────────────────────────────────────── + + - command: list segment + verb: list + noun: segment + short: List segments + handler_type: endpoint + endpoint: + path: /fme/api/v4/segments + items_expr: it.data + item_item_expr: it + get_id_expr: it.name + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [name, traffic_type, status, created] + + - command: get segment + verb: get + noun: segment + short: "Get segment details: harness get segment " + handler_type: endpoint + endpoint: + path: /fme/api/v4/segments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it + yaml_pick_expr: it + + - command: create segment + verb: create + noun: segment + short: "Create a segment: harness create segment --traffic-type user" + handler_type: endpoint + flags: + - name: traffic-type + description: Traffic type for the segment (e.g. user, account) + required: true + - name: description + description: Segment description + flags_builtin: + set: true + endpoint: + method: POST + path: /fme/api/v4/segments + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + create_strategy: set-fields + create_body_init: + name: ctx.id + trafficType: flags["traffic-type"] + description: 'flags.description != "" ? flags.description : nil' + create_body_wrap: "" + item_expr: it.entity + text_header: "\nCreated segment {{it.name}}\n" + + - command: update segment + verb: update + noun: segment + short: "Update a segment: harness update segment --set description=foo" + handler_type: endpoint + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /fme/api/v4/segments/{{ctx.id}} + get_path: /fme/api/v4/segments/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: it + update_body_wrap: "" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it.entity + text_header: "\nUpdated segment {{ctx.id}}\n" + + - command: delete segment + verb: delete + noun: segment + confirm_mode: prompt + short: Delete (archive) a segment + handler_type: endpoint + endpoint: + method: DELETE + path: /fme/api/v4/segments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + + # ── segment:definition ───────────────────────────────────────────────────────── + + - command: list segment:definition + verb: list + noun: segment + noun_variant: definition + short: "List segment definitions in an environment: harness list segment:definition --env " + handler_type: endpoint + fields_noun: segment_definition + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + path: /fme/api/v4/segment-definitions + items_expr: it.data + item_item_expr: it + get_id_expr: it.segment.name + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [segment, environment, description, status, created] + + - command: get segment:definition + verb: get + noun: segment + noun_variant: definition + short: "Get a segment definition: harness get segment:definition --env " + handler_type: endpoint + fields_noun: segment_definition + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + path: /fme/api/v4/segment-definitions/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + item_expr: it + yaml_pick_expr: it + + - command: create segment:definition + verb: create + noun: segment + noun_variant: definition + short: "Create a segment definition in an environment: harness create segment:definition --env [--description desc]" + handler_type: endpoint + fields_noun: segment_definition + flags: + - name: env + description: Environment identifier (required) + required: true + - name: description + description: Definition description + endpoint: + method: POST + path: /fme/api/v4/segment-definitions/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + body_params: + description: 'flags.description != "" ? flags.description : nil' + item_expr: it.entity + text_header: "\nCreated segment definition for {{ctx.id}} in {{flags.env}}\n" + + - command: update segment:definition + verb: update + noun: segment + noun_variant: definition + short: "Update a segment definition: harness update segment:definition --env --set description=foo" + handler_type: endpoint + fields_noun: segment_definition + flags: + - name: env + description: Environment identifier (required) + required: true + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /fme/api/v4/segment-definitions/{{ctx.id}} + get_path: /fme/api/v4/segment-definitions/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: "{description: it.description}" + update_body_wrap: "" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + item_expr: it.entity + text_header: "\nUpdated segment definition for {{ctx.id}} in {{flags.env}}\n" + + - command: delete segment:definition + verb: delete + noun: segment + noun_variant: definition + confirm_mode: prompt + short: Delete (archive) a segment definition from an environment + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + method: DELETE + path: /fme/api/v4/segment-definitions/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + + # ── segment:definition_keys ──────────────────────────────────────────────────── + + - command: list segment:definition_keys + verb: list + noun: segment + noun_variant: definition_keys + short: "List targeting keys in a segment definition: harness list segment:definition_keys --env " + handler_type: endpoint + requires_parentid: true + parentid_label: "" + fields_noun: segment_definition_keys + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + path: /fme/api/v4/segment-definitions/{{ctx.parentId}}/keys + items_expr: it.data + item_item_expr: it + get_id_expr: "-" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [key] + + - command: execute segment:definition_keys_add + verb: execute + noun: segment + noun_variant: definition_keys_add + short: "Add (or replace) targeting keys in a segment definition: harness execute segment:definition_keys_add --env --keys k1,k2 [--replace]" + handler_type: endpoint + fields_noun: segment_definition_keys + flags: + - name: env + description: Environment identifier (required) + required: true + - name: keys + description: Targeting keys to add (comma-separated list or JSON array) + is_array: true + - name: replace + is_bool: true + description: Replace the entire key set instead of merging (empty --keys with --replace clears all keys) + - name: comment + description: Audit comment + - name: title + description: Audit change title + endpoint: + method: POST + path: /fme/api/v4/segment-definitions/{{ctx.id}}/keys + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + replace: 'flags.replace ? "true" : nil' + body_params: + keys: flags.keys + comment: flags.comment + title: flags.title + item_expr: it.keys + text_header: "\nUpdated keys for segment definition {{ctx.id}} in {{flags.env}}\n" + + - command: execute segment:definition_keys_remove + verb: execute + noun: segment + noun_variant: definition_keys_remove + short: "Remove targeting keys from a segment definition: harness execute segment:definition_keys_remove --env --keys k1,k2" + handler_type: endpoint + fields_noun: segment_definition_keys + flags: + - name: env + description: Environment identifier (required) + required: true + - name: keys + description: Targeting keys to remove (comma-separated list or JSON array) + required: true + is_array: true + - name: comment + description: Audit comment + - name: title + description: Audit change title + endpoint: + method: POST + path: /fme/api/v4/segment-definitions/{{ctx.id}}/keys/remove + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + body_params: + keys: flags.keys + comment: flags.comment + title: flags.title + item_expr: it.keys + text_header: "\nRemoved keys from segment definition {{ctx.id}} in {{flags.env}}\n" diff --git a/pkg/specloader/fme_spec_test.go b/pkg/specloader/fme_spec_test.go new file mode 100644 index 0000000..b17f3b0 --- /dev/null +++ b/pkg/specloader/fme_spec_test.go @@ -0,0 +1,339 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package specloader + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/harness/cli/pkg/auth" + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/registry" +) + +// fmeCaptureServer returns a mock server that records the inbound request path +// and always replies with resp, plus the *cmdctx.Ctx wired to call it. +func fmeCaptureServer(t *testing.T, resp string) (*httptest.Server, *string) { + t.Helper() + path := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, resp) + })) + t.Cleanup(srv.Close) + return srv, &path +} + +// fmeCaptureServerWithQuery is like fmeCaptureServer but also records the raw +// query string, for asserting flag-to-query-param wiring (e.g. --env → environment_id). +func fmeCaptureServerWithQuery(t *testing.T, resp string) (*httptest.Server, *string, *string) { + t.Helper() + path, query := "", "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + query = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, resp) + })) + t.Cleanup(srv.Close) + return srv, &path, &query +} + +func fmeTestCtx(t *testing.T, apiURL string) *cmdctx.Ctx { + t.Helper() + return &cmdctx.Ctx{ + Context: context.Background(), + Auth: &auth.ResolvedAuth{ + APIUrl: apiURL, + AccountID: "acct", + OrgID: "org", + ProjectID: "proj", + PATToken: "pat.test", + AuthType: auth.AuthTypePAT, + }, + FormatFlags: cmdctx.FormatFlags{OutFile: filepath.Join(t.TempDir(), "out")}, + } +} + +func fmeReadOut(t *testing.T, ctx *cmdctx.Ctx) string { + t.Helper() + b, err := os.ReadFile(ctx.FormatFlags.OutFile) + if err != nil { + t.Fatalf("read output: %v", err) + } + return string(b) +} + +// TestFMESpec_ListFeatureFlag drives the real embedded fme.spec.yaml "list feature_flag" +// command against a mock server returning the flat (no "entity" wrapper) shape that the +// live FME v4 API returns, and asserts the request hits /fme/api/v4/feature-flags +// and that fields resolve directly off the item (it.name, it.trafficType.name, ...). +func TestFMESpec_ListFeatureFlag(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "feature_flag") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list feature_flag: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"name":"my-flag","description":"desc","trafficType":{"name":"user"},"status":"ACTIVE","rolloutStatus":{"name":"Ramp"},"createdAt":"2026-01-01T00:00:00Z"}],"limit":20,"offset":0,"totalCount":1}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "feature_flag" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/api/v4/feature-flags") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/feature-flags", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"my-flag", "user", "ACTIVE", "Ramp"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q (flat field did not resolve): %s", want, body) + } + } +} + +// TestFMESpec_GetFeatureFlag drives the real embedded fme.spec.yaml "get feature_flag" +// command against a mock server returning a flat object, and asserts the request path +// and that yaml_pick_expr/item_expr resolve the item directly (it, not it.entity). +func TestFMESpec_GetFeatureFlag(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("get", "feature_flag") + if cs == nil || cs.Endpoint == nil { + t.Fatal("get feature_flag: command not found or missing endpoint spec") + } + + fixture := `{"name":"my-flag","description":"desc","trafficType":{"name":"user"},"status":"ACTIVE","rolloutStatus":{"name":"Ramp"},"createdAt":"2026-01-01T00:00:00Z"}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Id = "my-flag" + ctx.Noun = "feature_flag" + ctx.Resolver = reg + ctx.FormatFlags.Format = "yaml" + + if _, err := registry.RunEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunEndpoint: %v", err) + } + + if *path != "/fme/api/v4/feature-flags/my-flag" { + t.Fatalf("request path = %q, want /fme/api/v4/feature-flags/my-flag", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"name: my-flag", "status: ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q (yaml_pick_expr did not resolve flat item): %s", want, body) + } + } + if strings.Contains(body, "entity") { + t.Fatalf("output still references entity wrapper: %s", body) + } +} + +// TestFMESpec_ListFMEEnvironment drives "list fme_environment" and asserts it +// hits /fme/api/v4/environments and that get_id_expr resolves off +// it.id (environments are addressed by UUID, not name, unlike segment/feature_flag). +func TestFMESpec_ListFMEEnvironment(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "fme_environment") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list fme_environment: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"id":"env-uuid-1","name":"Prod","isProduction":true,"status":"ACTIVE"}],"limit":100,"offset":0,"totalCount":1}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "fme_environment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/api/v4/environments") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/environments", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"Prod", "true", "ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q: %s", want, body) + } + } +} + +// TestFMESpec_GetFMEEnvironment drives "get fme_environment" with a UUID id +// and asserts the path embeds it (environments are looked up by id, not name). +func TestFMESpec_GetFMEEnvironment(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("get", "fme_environment") + if cs == nil || cs.Endpoint == nil { + t.Fatal("get fme_environment: command not found or missing endpoint spec") + } + + fixture := `{"id":"env-uuid-1","name":"Prod","isProduction":true,"status":"ACTIVE"}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Id = "env-uuid-1" + ctx.Noun = "fme_environment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "yaml" + + if _, err := registry.RunEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunEndpoint: %v", err) + } + + if *path != "/fme/api/v4/environments/env-uuid-1" { + t.Fatalf("request path = %q, want /fme/api/v4/environments/env-uuid-1", *path) + } +} + +// TestFMESpec_ListSegment drives "list segment" and asserts get_id_expr +// resolves off it.name (segments, unlike fme_environment, are addressed by name). +func TestFMESpec_ListSegment(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "segment") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list segment: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"name":"my-segment","description":"desc","trafficType":{"name":"user"},"status":"ACTIVE","createdAt":1778049995.725}],"limit":100,"offset":0,"totalCount":1}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "segment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/api/v4/segments") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/segments", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"my-segment", "user", "ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q: %s", want, body) + } + } +} + +// TestFMESpec_ListSegmentDefinition drives "list segment:definition" and asserts +// the --env flag maps to the environment_id query param and fields_extra resolves +// (segment/environment names, description, status) off the flat item. +func TestFMESpec_ListSegmentDefinition(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "segment:definition") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list segment:definition: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"segment":{"name":"my-segment"},"environment":{"name":"Prod"},"description":"desc","status":"ACTIVE","createdAt":1778049995.725}],"limit":100,"offset":0,"totalCount":1}` + srv, path, query := fmeCaptureServerWithQuery(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "segment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + ctx.FlagValues = map[string]any{"env": "env-uuid-1"} + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/api/v4/segment-definitions") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/segment-definitions", *path) + } + if !strings.Contains(*query, "environment_id=env-uuid-1") { + t.Fatalf("query = %q, want environment_id=env-uuid-1 (from --env flag)", *query) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"my-segment", "Prod", "desc", "ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q: %s", want, body) + } + } +} + +// TestFMESpec_ListSegmentDefinitionKeys drives "list segment:definition_keys" and +// asserts the fields_noun override (segment_definition_keys) renders raw string +// items directly (it.key = it), not object fields, and the parent id is embedded +// in the path. +func TestFMESpec_ListSegmentDefinitionKeys(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "segment:definition_keys") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list segment:definition_keys: command not found or missing endpoint spec") + } + + fixture := `{"data":["key-one","key-two"],"limit":100,"offset":0,"totalCount":2}` + srv, path, query := fmeCaptureServerWithQuery(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "segment" + ctx.ParentId = "my-segment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + ctx.FlagValues = map[string]any{"env": "env-uuid-1"} + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if *path != "/fme/api/v4/segment-definitions/my-segment/keys" { + t.Fatalf("request path = %q, want /fme/api/v4/segment-definitions/my-segment/keys", *path) + } + if !strings.Contains(*query, "environment_id=env-uuid-1") { + t.Fatalf("query = %q, want environment_id=env-uuid-1 (from --env flag)", *query) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"key-one", "key-two"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q (fields_noun override did not resolve raw string item): %s", want, body) + } + } +}