diff --git a/AGENTS.md b/AGENTS.md index b576d52..e95be35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,6 +183,34 @@ request_headers: An empty `body_params` still sends `{}` (required by gRPC-gateway for POST/PATCH/PUT). +### External (non-gateway) APIs + +Most specs call the Harness gateway (`c.resolved.APIUrl` + a relative `path`). A spec can +instead call a third-party API directly by giving `endpoint.path` (and `get_path`, if used) +a full `http://`/`https://` URL: + +```yaml +path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.parentId}}/ +``` + +When `path` is absolute, `buildRequest` skips the `accountIdentifier` query param and the +default Harness auth header (`x-api-key`/`Bearer`) — the spec must supply its own auth via +`request_headers`. Never hardcode the credential. Two expr-lang helpers provide it: + +- `authToken()` — the active profile's bearer credential (the same key from `harness login`). + Use this when the external API accepts the user's Harness key, so no second key is needed. +- `env("NAME")` — reads an OS environment variable, for a dedicated third-party key. + +Prefer reusing the profile token: + +```yaml +request_headers: + Authorization: '"Bearer " + authToken()' +``` + +See `pkg/spec/splitio.spec.yaml` for a full example (FME feature flags via the Split.io +Admin API, which lives on a different host with different auth than the Harness gateway). + ## Adding a new spec file 1. Create `pkg/spec/.spec.yaml`. @@ -222,6 +250,8 @@ The CLI reads auth from the active profile (typically `~/.harness/profiles.yaml` | `platform.spec.yaml` | Platform resources (projects, orgs, etc.) | | `pipeline.spec.yaml` | CI/CD pipelines | | `core.spec.yaml` | Core resources | +| `fme.spec.yaml` | Harness's own unreleased `/v3/feature-flags` API — `harness_internal: true`, not yet public | +| `splitio.spec.yaml` | FME feature flags (`fme_workspace`, `fme_environment`, `fme_traffic_type`, `fme_rollout_status`, `fme_flag`) via the real, public Split.io Admin API — see "External (non-gateway) APIs" above | ## Security — never put real credentials in code or comments diff --git a/cmd/harness/main-harness.go b/cmd/harness/main-harness.go index 69ce414..d64c26f 100644 --- a/cmd/harness/main-harness.go +++ b/cmd/harness/main-harness.go @@ -17,6 +17,7 @@ import ( "github.com/harness/cli/modules/gitops" "github.com/harness/cli/modules/iacm" "github.com/harness/cli/modules/pipeline" + "github.com/harness/cli/modules/splitio" "github.com/harness/cli/pkg/console" "github.com/harness/cli/pkg/hbase" "github.com/harness/cli/pkg/registry" @@ -49,6 +50,7 @@ func main() { pipeline.ModuleInit(reg.Module("pipeline")) // har is an external module (external_binary: harness-har) — ModuleInit is not loaded here. iacm.ModuleInit(reg.Module("iacm")) + splitio.ModuleInit(reg.Module("splitio")) rootcmd.MaybeCheckSpecs(reg) root := &cobra.Command{ diff --git a/modules/splitio/splitio.go b/modules/splitio/splitio.go new file mode 100644 index 0000000..baeb0b8 --- /dev/null +++ b/modules/splitio/splitio.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package splitio wires up the body_fn used by the splitio spec module (pkg/spec/splitio.spec.yaml) +// to manage Harness FME feature flags via the underlying Split.io Admin API. +package splitio + +import ( + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/registry" +) + +const updateFlagBodyFnID = "splitio_update_flag_body" + +func ModuleInit(reg registry.ModuleRegistrar) { + reg.RegisterBodyFn(updateFlagBodyFnID, updateFlagBodyFn) +} + +// updateFlagBodyFn builds the RFC 6902 JSON Patch array the Split Admin API requires for +// "update feature flag" (PATCH /splits/ws/{workspace}/{name}). Only flags the user actually +// passed are included, each as one patch operation. +func updateFlagBodyFn(ctx *cmdctx.Ctx) (any, error) { + var ops []map[string]any + + if v := cmdctx.GetString(ctx.FlagValues, "description"); v != "" { + ops = append(ops, map[string]any{"op": "replace", "path": "/description", "value": v}) + } + if tags := cmdctx.GetStringSlice(ctx.FlagValues, "tag"); len(tags) > 0 { + tagObjs := make([]map[string]string, len(tags)) + for i, t := range tags { + tagObjs[i] = map[string]string{"name": t} + } + ops = append(ops, map[string]any{"op": "replace", "path": "/tags", "value": tagObjs}) + } + if v := cmdctx.GetString(ctx.FlagValues, "rollout-status"); v != "" { + ops = append(ops, map[string]any{"op": "replace", "path": "/rolloutStatus/id", "value": v}) + } + + return ops, nil +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 878cff3..0f77574 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -233,17 +233,33 @@ func (c *Client) DoStream(r Request, timeout time.Duration) (*http.Response, err } // buildRequest prepares an authenticated *http.Request from r, including token refresh. +// +// When r.Path is an absolute URL (http:// or https://), it is used as-is instead of being +// resolved against the profile's Harness gateway APIUrl: no accountIdentifier query param +// is injected and no default Harness auth header (x-api-key/Bearer) is set. This lets specs +// call external APIs that live outside the Harness gateway (e.g. a third-party vendor API) +// — the spec must supply its own auth via request_headers. func (c *Client) buildRequest(r Request) (*http.Request, *url.URL, error) { if err := auth.CheckAndUpdateAccessToken(c.resolved, time.Now()); err != nil { return nil, nil, err } - u, err := url.Parse(c.resolved.APIUrl + r.Path) + external := strings.HasPrefix(r.Path, "http://") || strings.HasPrefix(r.Path, "https://") + + var u *url.URL + var err error + if external { + u, err = url.Parse(r.Path) + } else { + u, err = url.Parse(c.resolved.APIUrl + r.Path) + } if err != nil { return nil, nil, fmt.Errorf("building URL: %w", err) } q := u.Query() - q.Set("accountIdentifier", c.resolved.AccountID) + if !external { + q.Set("accountIdentifier", c.resolved.AccountID) + } for k, v := range r.QueryParams { if v != "" { q.Set(k, v) @@ -281,10 +297,12 @@ func (c *Client) buildRequest(r Request) (*http.Request, *url.URL, error) { if c.cliCommand != "" { req.Header.Set("X-CLI-Command", c.cliCommand) } - if c.resolved.AuthType == auth.AuthTypeSSO { - req.Header.Set("Authorization", "Bearer "+c.resolved.SSOToken) - } else { - req.Header.Set("x-api-key", c.resolved.PATToken) + if !external { + if c.resolved.AuthType == auth.AuthTypeSSO { + req.Header.Set("Authorization", "Bearer "+c.resolved.SSOToken) + } else { + req.Header.Set("x-api-key", c.resolved.PATToken) + } } if contentType != "" { req.Header.Set("Content-Type", contentType) diff --git a/pkg/exprenv/exprenv.go b/pkg/exprenv/exprenv.go index 302cdcd..5b426e6 100644 --- a/pkg/exprenv/exprenv.go +++ b/pkg/exprenv/exprenv.go @@ -7,6 +7,7 @@ package exprenv import ( "fmt" "maps" + "os" "strings" "github.com/expr-lang/expr" @@ -102,6 +103,23 @@ func Make(ctx *cmdctx.Ctx) map[string]any { "truncate": exprfuncs.Truncate, "substr": exprfuncs.Substr, "formatOrder": exprfuncs.FormatOrder, + // env reads an OS environment variable. Use it to pull secrets (e.g. a + // third-party API key) into request_headers/body_params instead of hardcoding + // them in spec YAML — see AGENTS.md's credential-handling rule. + "env": os.Getenv, + // authToken returns the active profile's bearer credential (the SSO access + // token, or the PAT/API key). Use it in request_headers so a spec calling an + // external API can reuse the same key the user logged in with, instead of + // requiring a second env var — see AGENTS.md's credential-handling rule. + "authToken": func() string { + if a == nil { + return "" + } + if a.SSOToken != "" { + return a.SSOToken + } + return a.PATToken + }, } if ctx.Resolver != nil { noun := ctx.Noun diff --git a/pkg/exprenv/exprenv_test.go b/pkg/exprenv/exprenv_test.go index e1bf87e..472d134 100644 --- a/pkg/exprenv/exprenv_test.go +++ b/pkg/exprenv/exprenv_test.go @@ -5,8 +5,29 @@ package exprenv import ( "testing" + + "github.com/harness/cli/pkg/auth" + "github.com/harness/cli/pkg/cmdctx" ) +// TestAuthTokenHeader verifies the splitio.spec.yaml auth pattern: +// the profile's bearer credential is reused via authToken(). +func TestAuthTokenHeader(t *testing.T) { + const hdr = `"Bearer " + authToken()` + + // PAT profile -> PAT token used. + env := Make(&cmdctx.Ctx{Auth: &auth.ResolvedAuth{PATToken: "pat123"}}) + if got := EvalExpr(env, hdr); got != "Bearer pat123" { + t.Fatalf("PAT token: got %q, want %q", got, "Bearer pat123") + } + + // SSO profile -> SSO token preferred over PAT. + env = Make(&cmdctx.Ctx{Auth: &auth.ResolvedAuth{SSOToken: "sso456", PATToken: "pat123"}}) + if got := EvalExpr(env, hdr); got != "Bearer sso456" { + t.Fatalf("SSO token: got %q, want %q", got, "Bearer sso456") + } +} + func baseEnv() map[string]any { return map[string]any{ "ctx": map[string]any{ diff --git a/pkg/spec/splitio.spec.yaml b/pkg/spec/splitio.spec.yaml new file mode 100644 index 0000000..222d37a --- /dev/null +++ b/pkg/spec/splitio.spec.yaml @@ -0,0 +1,386 @@ +spec_version: 1 +module_type: builtin +module_desc: Harness FME feature flags via the public Split.io Admin API +help_text: | + ## Feature Flags via Split.io (splitio) + + Manages Harness FME feature flags through the Split.io Admin API — the + real, currently-public API backing Feature Management & Experimentation. + This is a *different* backend than the `fme` module's `/v3/feature-flags` + endpoints (which are Harness's own unreleased first-party API). + + ### Auth + + These commands reuse your active Harness profile's credential (the same key + from `harness login`) as the Split.io Bearer token — no extra setup, no + second key. Requests still go directly to `api.split.io`, not through the + Harness gateway; the CLI just attaches your profile token itself. + + ### Domain Model + + A workspace is the top-level scope (roughly equivalent to a Harness + project). Traffic types and environments live inside a workspace. A + feature flag ("split") belongs to a traffic type and has independent + targeting state per environment (rollout status, kill switch). + + Most commands take a workspace id as either a positional parent arg + (`list`) or as the first segment of a slash-separated id (`get`/`update`/ + `delete`/`execute`), e.g. `harness get fme_flag /`. + + ### Nouns + + {{nouns}} + +nouns: + - noun: fme_workspace + short_desc: A Split.io / FME workspace — the top-level scope for traffic types, environments, and flags. + noun_aliases: [fme_workspaces] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + - id: type + expr: it.type ?? "" + - id: org + label: Org + expr: it.organizationIdentifier ?? "" + - id: project + label: Project + expr: it.projectIdentifier ?? "" + + - noun: fme_environment + short_desc: A deployment environment within an FME workspace (e.g. staging, production). + noun_aliases: [fme_environments] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + + - noun: fme_traffic_type + short_desc: A traffic type within an FME workspace (e.g. user, account) that feature flags target. + noun_aliases: [fme_traffic_types] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + - id: display_attribute_id + label: Display Attribute + expr: it.displayAttributeId ?? "" + + - noun: fme_rollout_status + short_desc: A rollout status a feature flag can be tagged with (e.g. "In Progress", "Live"). + noun_aliases: [fme_rollout_statuses] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + - id: description + expr: it.description ?? "" + width_max: 60 + + - noun: fme_flag + short_desc: A Harness FME feature flag (a Split.io "split"). + noun_aliases: [fme_flags, split, splits] + fields: + - id: id + expr: it.id + - id: name + expr: it.name + - id: description + expr: it.description ?? "" + width_max: 60 + - id: traffic_type + label: Traffic Type + expr: it.trafficType.name ?? "" + - id: tags + expr: 'join(map(it.tags ?? [], .name), ", ")' + - id: rollout_status + label: Rollout Status + expr: it.rolloutStatus.name ?? "" + - id: created + expr: epochMs(it.creationTime) + field_type: ts + +commands: + # ── fme_workspace ──────────────────────────────────────────────────────────── + + - command: list fme_workspace + verb: list + noun: fme_workspace + short: "List FME workspaces: harness list fme_workspace [--name ]" + handler_type: endpoint + flags: + - name: name + description: Filter by name (partial match) + endpoint: + path: https://api.split.io/internal/api/v2/workspaces + request_headers: + Authorization: '"Bearer " + authToken()' + query_params: + name: flags.name + items_expr: it.objects + get_id_expr: it.id + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 20 + page_size_max: 1000 + total_expr: it.totalCount + countable: true + columns: [id, name, type] + + # ── fme_environment ────────────────────────────────────────────────────────── + + - command: list fme_environment + verb: list + noun: fme_environment + short: "List environments in a workspace: harness list fme_environment " + handler_type: endpoint + requires_parentid: true + parentid_label: "" + endpoint: + path: https://api.split.io/internal/api/v2/environments/ws/{{ctx.parentId}} + request_headers: + Authorization: '"Bearer " + authToken()' + items_expr: it + get_id_expr: it.id + paging: + paging_strategy: flat_list + columns: [id, name] + + # ── fme_traffic_type ───────────────────────────────────────────────────────── + + - command: list fme_traffic_type + verb: list + noun: fme_traffic_type + short: "List traffic types in a workspace: harness list fme_traffic_type " + handler_type: endpoint + requires_parentid: true + parentid_label: "" + endpoint: + path: https://api.split.io/internal/api/v2/trafficTypes/ws/{{ctx.parentId}} + request_headers: + Authorization: '"Bearer " + authToken()' + items_expr: it + get_id_expr: it.id + paging: + paging_strategy: flat_list + columns: [id, name] + + # ── fme_rollout_status ─────────────────────────────────────────────────────── + + - command: list fme_rollout_status + verb: list + noun: fme_rollout_status + short: "List rollout statuses available in a workspace: harness list fme_rollout_status " + handler_type: endpoint + requires_parentid: true + parentid_label: "" + endpoint: + path: https://api.split.io/internal/api/v2/rolloutStatuses + request_headers: + Authorization: '"Bearer " + authToken()' + query_params: + wsId: ctx.parentId + items_expr: it + get_id_expr: it.id + paging: + paging_strategy: flat_list + columns: [id, name, description] + + # ── fme_flag ────────────────────────────────────────────────────────────────── + + - command: list fme_flag + verb: list + noun: fme_flag + short: "List feature flags in a workspace: harness list fme_flag [--tag ]" + handler_type: endpoint + requires_parentid: true + parentid_label: "" + flags: + - name: tag + description: Filter by tag + endpoint: + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.parentId}}/ + request_headers: + Authorization: '"Bearer " + authToken()' + query_params: + tag: flags.tag + items_expr: it.objects + get_id_expr: it.name + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 20 + page_size_max: 50 + total_expr: it.totalCount + countable: true + columns: [name, traffic_type, rollout_status, created] + + - command: get fme_flag + verb: get + noun: fme_flag + short: "Get feature flag details: harness get fme_flag /" + handler_type: endpoint + id_parts: 2 + endpoint: + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}} + request_headers: + Authorization: '"Bearer " + authToken()' + item_expr: it + fields_subset: [name, description, traffic_type, tags, rollout_status, created] + + - command: create fme_flag + verb: create + noun: fme_flag + short: "Create a feature flag: harness create fme_flag --traffic-type --set name= [description=]" + handler_type: endpoint + flags: + - name: traffic-type + description: Traffic type id or name for the new flag + required: true + flags_builtin: + set: true + endpoint: + method: POST + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.id}}/trafficTypes/{{flags.traffic-type}} + request_headers: + Authorization: '"Bearer " + authToken()' + create_strategy: set-fields + create_body_wrap: "" + item_expr: it + text_header: "\nCreated feature flag {{it.name}}\n" + + - command: update fme_flag + verb: update + noun: fme_flag + short: "Update a feature flag: harness update fme_flag / [--description ] [--tag ] [--rollout-status ]" + handler_type: endpoint + id_parts: 2 + flags: + - name: description + description: New description + - name: tag + description: Replace tags (repeatable) + is_multi: true + - name: rollout-status + description: "Rollout status id — see: harness list fme_rollout_status " + endpoint: + method: PATCH + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}} + request_headers: + Authorization: '"Bearer " + authToken()' + body_fn: splitio_update_flag_body + item_expr: it + text_header: "\nUpdated feature flag {{ctx.idParts[1]}}\n" + + - command: delete fme_flag + verb: delete + noun: fme_flag + confirm_mode: prompt + short: "Delete a feature flag: harness delete fme_flag /" + handler_type: endpoint + id_parts: 2 + endpoint: + method: DELETE + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}} + request_headers: + Authorization: '"Bearer " + authToken()' + + - command: execute fme_flag:archive + verb: execute + noun: fme_flag + noun_variant: archive + short: "Archive a feature flag: harness execute fme_flag:archive /" + handler_type: endpoint + id_parts: 2 + flags: + - name: title + description: Audit title + - name: comment + description: Audit comment + endpoint: + method: PUT + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}}/archive + request_headers: + Authorization: '"Bearer " + authToken()' + body_params: + title: flags.title + comment: flags.comment + item_expr: it + text_header: "\nArchived feature flag {{ctx.idParts[1]}}\n" + + - command: execute fme_flag:unarchive + verb: execute + noun: fme_flag + noun_variant: unarchive + short: "Unarchive a feature flag: harness execute fme_flag:unarchive /" + handler_type: endpoint + id_parts: 2 + flags: + - name: title + description: Audit title + - name: comment + description: Audit comment + endpoint: + method: PUT + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}}/unarchive + request_headers: + Authorization: '"Bearer " + authToken()' + body_params: + title: flags.title + comment: flags.comment + item_expr: it + text_header: "\nUnarchived feature flag {{ctx.idParts[1]}}\n" + + - command: execute fme_flag:kill + verb: execute + noun: fme_flag + noun_variant: kill + short: "Kill a flag in an environment (all traffic to defaultTreatment): harness execute fme_flag:kill / --env " + handler_type: endpoint + id_parts: 2 + flags: + - name: env + description: Environment id (required) + required: true + - name: comment + description: Audit comment + endpoint: + method: PUT + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}}/environments/{{flags.env}}/kill + request_headers: + Authorization: '"Bearer " + authToken()' + body_params: + comment: flags.comment + item_expr: it + text_header: "\nKilled feature flag {{ctx.idParts[1]}} in {{flags.env}}\n" + + - command: execute fme_flag:restore + verb: execute + noun: fme_flag + noun_variant: restore + short: "Restore a killed flag in an environment: harness execute fme_flag:restore / --env " + handler_type: endpoint + id_parts: 2 + flags: + - name: env + description: Environment id (required) + required: true + - name: comment + description: Audit comment + endpoint: + method: PUT + path: https://api.split.io/internal/api/v2/splits/ws/{{ctx.idParts[0]}}/{{ctx.idParts[1]}}/environments/{{flags.env}}/restore + request_headers: + Authorization: '"Bearer " + authToken()' + body_params: + comment: flags.comment + item_expr: it + text_header: "\nRestored feature flag {{ctx.idParts[1]}} in {{flags.env}}\n"