diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6b384d..fff39a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -356,6 +356,22 @@ Once set, no Apply API caller — Control Center, curl, CI — needs to know or Three checks at `ork validate` time: required on a namespaced CRD with `idp.enabled: true`; rejected on a cluster-scoped one (`namespaced: false`) — nothing to resolve into; rejected when templated and the CRD's informer is pinned to one fixed namespace (`allowedNamespaces` with exactly one entry, or the legacy `namespace:` field) — a CR resolved outside that one namespace would exist but never be reconciled, silently. No equivalent checks exist for `idp.name` — there's no cluster-scoped/pinned-namespace-style conflict for a name to run into. +### `idp.fields.path` — nested spec paths + +`idp.fields` entries now support a `path:` field mapping a flat field name to a nested dot-notation path in the CRD `spec`. Callers submit flat fields; the gateway maps them to nested locations. + +```yaml +idp: + fields: + cpu: + path: app.resources.cpu + label: "CPU Request" +``` + +`ork validate` ensures paths are unique, formatted correctly, and warns on nested paths (schema existence validation coming later). + +→ [Nested fields with `path` reference](./documentation/reference/schema/02-katalog/21-idp-nested-spec.md) + ### `POST /api/v1/apply` response: `pollUrl` replaces `resourceVersion` A successful apply now returns `pollUrl` — the exact `GET /api/v1/resources/{kind}/{namespace}/{name}` path for the CR just applied — instead of `resourceVersion`, which nothing consumed. Callers can `jq -r '.pollUrl'` straight into a poll loop instead of hand-assembling the path from `kind`/`namespace`/`name`. Cluster-scoped CRDs get an empty namespace segment (`/api/v1/resources/AppRequest//payments-api`), matching the existing `GET`/`DELETE` path convention. diff --git a/documentation/concepts/idp/02-target-mode.md b/documentation/concepts/idp/02-target-mode.md new file mode 100644 index 00000000..f4e6f1e5 --- /dev/null +++ b/documentation/concepts/idp/02-target-mode.md @@ -0,0 +1,241 @@ +# Target Mode + +The Apply API accepts a simplified request format where callers submit a `target` and flat fields instead of a full Kubernetes CR. The gateway builds the CR from the IDP configuration. + +--- + +## Why target mode exists + +Every self-service caller — a browser form, a CI pipeline, a Slack bot — has the same problem: they want to describe what they need, not construct a Kubernetes object. A developer knows the repository and the image tag; they shouldn't need to know `apiVersion`, `kind`, `metadata`, or the difference between `spec` and `labels`. + +Target mode hides Kubernetes behind the IDP contract. The platform team defines the fields. The gateway handles the rest. + +--- + +## How it works + +The Katalog declares `idp.target` and `idp.fields`: + +```yaml +idp: + enabled: true + target: app + name: '{{ repoSlug .repository }}' + namespace: '{{ teamName }}-{{ environment }}' + fields: + repository: + label: "Repository" + type: string + required: true + image: + label: "Container Image" + type: string + required: true +``` + +Callers submit `target` + fields: + +```json +POST /api/v1/apply +{ + "target": "app", + "repository": "myorg/payments-api", + "image": "ghcr.io/myorg/app:v1.0.0", + "team": "team-payments", + "environment": "staging" +} +``` + +The gateway: + +1. Looks up the CRD by `target` +2. Routes fields to `spec`, `metadata.labels`, or `metadata.annotations` based on `idp.fields` and `idp.additionalFields` +3. Resolves `idp.name` and `idp.namespace` +4. Applies the full CR via SSA + +The caller never sees the CR. + +--- + +## Two modes, one API + +| Mode | Request format | When to use | +|------|----------------|-------------| +| **Target mode** | `{"target": "...", fields...}` | Self-service callers who don't know Kubernetes | +| **Full CR mode** | `{"apiVersion": "...", "kind": "...", ...}` | Advanced callers, existing clients, `kubectl` compatibility | + +```bash +# Target mode — submit fields +curl -X POST /api/v1/apply \ + -d '{"target":"app","repository":"myorg/app","image":"..."}' + +# Full CR mode — submit a complete CR +curl -X POST /api/v1/apply \ + -d '{"apiVersion":"platform.myorg.io/v1","kind":"App",...}' +``` + +Both modes produce the same result. The gateway detects which format you're using based on the presence of `target` or `apiVersion`+`kind`. + +--- + +## The schema contract + +Callers discover available targets and fields through the schema API: + +```bash +# List all available targets +curl -X GET /api/v1/schema \ + -H "Authorization: Bearer $TOKEN" + +# Get fields for a specific target +curl -X GET /api/v1/schema?target=app \ + -H "Authorization: Bearer $TOKEN" +``` + +The schema API returns a flat list of fields: + +```json +{ + "target": "app", + "title": "Application", + "fields": { + "repository": { + "label": "Repository", + "type": "string", + "required": true + }, + "image": { + "label": "Container Image", + "type": "string", + "required": true + } + } +} +``` + +Callers don't need to know about `spec`, `labels`, or `annotations` — they just see fields. + +--- + +## `idp.target` — the caller-facing identifier + +`idp.target` decouples the caller-facing identifier from the Kubernetes `kind`. + +```yaml +idp: + enabled: true + target: app # callers use this, not "App" or "apprequests" +``` + +If omitted, defaults to the lowercased `kind` (e.g., `kind: App` → `target: app`). + +`ork validate` ensures targets are unique across the Katalog. + +--- + +## `idp.name` and `idp.namespace` + +Target mode resolves `idp.name` and `idp.namespace` server-side, so callers don't need to know them: + +```yaml +idp: + enabled: true + name: '{{ repoSlug .repository }}' # → "payments-api" + namespace: '{{ teamName }}-{{ environment }}' # → "team-payments-staging" +``` + +Callers never supply `metadata.name` or `metadata.namespace` in target mode. + +When `idp.name` is not declared, the caller must supply a name. When `idp.namespace` is not declared on a namespaced CRD, the gateway rejects the request — self-service creation has no way to know where the CR belongs. + +--- + +## Nested fields with `path` + +Fields can map to nested locations in the CRD `spec` using `path`: + +```yaml +idp: + fields: + repository: + path: app.repository + label: "Repository" + cpu: + path: app.resources.cpu + label: "CPU Request" +``` + +Callers submit flat field names: + +```json +{ + "target": "app", + "repository": "myorg/app", + "cpu": "500m" +} +``` + +The gateway maps to: + +```yaml +spec: + app: + repository: myorg/app + resources: + cpu: 500m +``` + +→ [Nested fields with `path` reference](../../reference/schema/02-katalog/20-idp#idpfieldspath) + +--- + +## Response: `pollUrl` and `payload` + +A successful target-mode apply returns: + +```json +{ + "accepted": true, + "name": "payments-api", + "namespace": "team-payments-staging", + "kind": "AppRequest", + "apiVersion": "platform.myorg.io/v1", + "pollUrl": "/api/v1/resources/AppRequest/team-payments-staging/payments-api?field=status.phase", + "payload": { + "phase": "", + "serviceURL": "https://payments-api.staging.myorg.io", + "nextSteps": "Waiting for resources to be provisioned..." + } +} +``` + +- **`pollUrl`** — where to GET the resource (configurable via `idp.config.response.poll`) +- **`payload`** — the platform team's curated view (`idp.config.response.payload`) + +At apply time, `.status` is not yet available. Callers should poll `pollUrl` to see status updates. + +→ [`idp.config.response` reference](../../reference/schema/02-katalog/20-idp.md#idpconfigresponse) + +--- + +## Try it + +```bash +ork init --pack use-cases/idp +``` + +Follow the README — it walks through target mode from schema discovery to apply to polling. + +--- + +## See also + +→ [`idp.target` schema reference](../../reference/schema/02-katalog/20-idp#idptarget) + +→ [`idp.fields` schema reference](../../reference/schema/02-katalog/20-idp#idpfieldsname) + +→ [`idp.namespace` reference](../../reference/schema/02-katalog/20-idp#idpnamespace) + +→ [Apply API reference](../../reference/schema/02-katalog/17-katalog-applyapi.md) + +→ [Additional Fields](01-additional-fields.md) \ No newline at end of file diff --git a/documentation/concepts/idp/index.md b/documentation/concepts/idp/index.md index 0ecc3655..df215feb 100644 --- a/documentation/concepts/idp/index.md +++ b/documentation/concepts/idp/index.md @@ -103,7 +103,8 @@ The gateway Apply API is the uniform interface across all of those: | `GET /api/v1/resources/{kind}/{ns}/{name}` | Read CR state and status | | `GET /api/v1/resources/{kind}/{ns}` | List all CRs of a kind | | `DELETE /api/v1/resources/{kind}/{ns}/{name}` | Delete a CR | -| `GET /api/v1/schema/{kind}` | Discover the CRD's spec schema and field hints | +| `GET /api/v1/schema` | Discover the CRD's spec schema as fieldss | +| `GET /api/v1/raw-schema` | Discover the CRD's raw spec schema and field hints | Every enforcement rule — admission, namespace protection, deletion protection — is the same regardless of delivery path. There is nothing to reconfigure per caller. @@ -147,5 +148,6 @@ The pack runs three delivery paths against one `AppRequest` CRD — browser form ## Where to go next → [Additional Fields](01-additional-fields.md) +→ [Target Mode](02-target-mode.md) → [Apply API reference](../../reference/schema/02-katalog/17-katalog-applyapi.md) diff --git a/documentation/reference/schema/02-katalog/20-idp.md b/documentation/reference/schema/02-katalog/20-idp.md index 02294f70..0fb144d9 100644 --- a/documentation/reference/schema/02-katalog/20-idp.md +++ b/documentation/reference/schema/02-katalog/20-idp.md @@ -87,9 +87,51 @@ fields: | `anyOf` | At least one condition must pass for this field to be shown (OR). When both `when` and `anyOf` are declared, both blocks must pass. | | `required` | When `true`, marks the field as mandatory — enforced both client-side (the browser shows an asterisk and blocks submission while empty) and server-side: an implicit `exists` rule with `action: deny` is synthesized automatically at load time, so every caller of the Apply API is covered, not just the Control Center form. No matching `validation.rules` entry needs to be hand-written. Has no effect on fields currently hidden by a `when:` or `anyOf:` condition. | | `disabled` | Non-empty string — field is rendered greyed-out with this message. Useful for platform-managed fields that should be visible but not editable. | +| `path` | — | Dot-notation path mapping the field to a nested location in the CRD `spec`. When set, the field value is written to `spec.` instead of `spec.`. See [`path` — nested spec paths](#idpfieldspath) below. | +--- `order` isn't just cosmetic form layout. When multiple `required`/`type: enum` fields fail validation at once, only the first violation is reported as the headline denial reason — and synthesized rules are evaluated in the same order `order` puts the fields in, so the field a developer sees *first* on the form is also the one whose error they see first if several are wrong simultaneously. Two fields on the same CRD sharing a non-zero `order` value is a load-time error (`ork validate`) for exactly this reason — `0`/unset is the only value any number of fields may share, since it means "no preference," not a real position. +## `path` — nested spec paths + +By default, `idp.fields` maps field names directly to top-level `spec` paths: + +```yaml +fields: + repository: + label: "Repository" + # → spec.repository +``` + +Use `path` to map a field to a nested location: + +```yaml +fields: + repository: + path: app.repository + label: "Repository" + # → spec.app.repository + + cpu: + path: app.resources.cpu + label: "CPU Request" + # → spec.app.resources.cpu +``` + +Callers submit flat field names — they don't need to know the nesting structure. The gateway maps the field to the correct location in the CRD. + +```json +POST /api/v1/apply +{ + "target": "app", + "repository": "myorg/app", + "cpu": "500m" +} +``` + +→ [Full `path` reference](21-idp-nested-spec.md) + + ## `idp.name` `metadata.name` exists on every CR regardless of scope, so `idp.name` doesn't care whether the CRD is namespaced or cluster-scoped — it applies uniformly either way. It's optional, not required, though: most CRDs still want the caller to choose a name, since multiple concurrent instances of the same underlying app are normal (PR previews, ephemeral environments), and a name is the only thing distinguishing them. Set `idp.name` only when instances are 1:1 with some other identity the caller already supplies, and a redeploy is meant to update that same CR in place rather than create a new one — a stable environment where only the image tag changes between deploys: diff --git a/documentation/reference/schema/02-katalog/21-idp-nested-spec.md b/documentation/reference/schema/02-katalog/21-idp-nested-spec.md new file mode 100644 index 00000000..b3a8535b --- /dev/null +++ b/documentation/reference/schema/02-katalog/21-idp-nested-spec.md @@ -0,0 +1,131 @@ +## Nested Spec Paths + +### `idp.fields.path` + +By default, `idp.fields` maps field names directly to top-level `spec` paths. Use `path` to map a field to a nested location in the CRD `spec`. + +```yaml +idp: + fields: + # Flat field — maps to spec.repository + repository: + label: "Repository" + + # Nested field — maps to spec.app.repository + repository: + path: app.repository + label: "Repository" + + # Deeply nested — maps to spec.app.resources.cpu + cpu: + path: app.resources.cpu + label: "CPU Request" +``` + +Callers submit flat field names — they don't need to know about nesting. The gateway maps the field to the correct location in the CRD. + +```bash +curl -X POST /api/v1/apply \ + -d '{ + "target": "smartapp", + "repository": "myorg/payments-api", # → spec.app.repository + "cpu": "500m" # → spec.app.resources.cpu + }' +``` + +### Why Use `path` + +| Without `path` | With `path` | +|----------------|-------------| +| Fields must match CRD structure | Fields are flat and caller-friendly | +| Callers must know nested paths | Callers submit simple field names | +| CRD evolution breaks callers | Gateway maps to new paths | +| UI fields show dot-paths | UI fields show clean names | + +### Validation + +`ork validate` enforces: + +- **Unique paths** — no two fields can map to the same `spec` location +- **Valid format** — path segments must be valid Kubernetes names (alphanumeric, `_`, `-`, `.`) +- **No empty segments** — `app..repository` is rejected +- **No leading/trailing dots** — `.app.repository` is rejected + +### Schema Validation (Not Yet Implemented) + +Path existence in the CRD schema is not yet validated by `ork validate`. The platform team must verify that nested paths exist in the CRD spec. This will be added in a future release when OpenAPI schemas are loaded into the Katalog. + +### Example + +**CRD:** + +```yaml +spec: + app: + repository: string + image: string + resources: + cpu: string + memory: string + scaling: + replicas: integer + minReplicas: integer + maxReplicas: integer +``` + +**IDP Config:** + +```yaml +idp: + fields: + repository: + path: app.repository + label: "Repository" + image: + path: app.image + label: "Container Image" + cpu: + path: app.resources.cpu + label: "CPU Request" + memory: + path: app.resources.memory + label: "Memory Request" + replicas: + path: scaling.replicas + label: "Replicas" +``` + +**Caller Request:** + +```json +{ + "target": "smartapp", + "repository": "myorg/payments-api", + "image": "ghcr.io/myorg/app:v1", + "cpu": "500m", + "memory": "512Mi", + "replicas": 3 +} +``` + +**Generated CR:** + +```yaml +spec: + app: + repository: myorg/payments-api + image: ghcr.io/myorg/app:v1 + resources: + cpu: 500m + memory: 512Mi + scaling: + replicas: 3 +``` + +### Related + +→ [`idp.fields`](20-idp.md#idpfieldsname) — field configuration reference + +→ [`idp.additionalFields`](20-idp.md#idpadditionalfields) — labels and annotations as fields + +→ [Target Mode API](../../../concepts/idp/02-target-mode.md) — submitting fields instead of CRs \ No newline at end of file diff --git a/pkg/gateway/applyapi/target.go b/pkg/gateway/applyapi/target.go index 7863bc01..a1735587 100644 --- a/pkg/gateway/applyapi/target.go +++ b/pkg/gateway/applyapi/target.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/orkspace/orkestra/pkg/logger" orktmpl "github.com/orkspace/orkestra/pkg/resources/template" orktypes "github.com/orkspace/orkestra/pkg/types" "github.com/orkspace/orkestra/pkg/utils" @@ -76,7 +77,7 @@ func newCRSkeleton(crd *orktypes.CRDEntry) *unstructured.Unstructured { } // routeFields routes each submitted field to its declared destination: -// - idp.fields → spec +// - idp.fields → spec (supports nested dot-paths via 'path' field) // - idp.additionalFields.labels → metadata.labels // - idp.additionalFields.annotations → metadata.annotations // - unknown fields are silently ignored @@ -90,10 +91,13 @@ func routeFields( annotations := meta["annotations"].(map[string]interface{}) spec := obj.Object["spec"].(map[string]interface{}) - // Build O(1) destination sets from the IDP field declarations. - specFields := make(map[string]struct{}, len(crd.IDP.Fields)) - for name := range crd.IDP.Fields { - specFields[name] = struct{}{} + // Build lookup maps + // - specPathLookup: field name → spec path (flat or nested) + // - labelFields: field name → config (for labels) + // - annotationFields: field name → config (for annotations) + specPathLookup := make(map[string]string, len(crd.IDP.Fields)) + for name, config := range crd.IDP.Fields { + specPathLookup[name] = config.SpecPath(name) } labelFields := crd.AdditionalLabelFields() annotationFields := crd.AdditionalAnnotationFields() @@ -104,19 +108,37 @@ func routeFields( continue } - switch { - case utils.SetContains(specFields, key): - spec[key] = value + // ─── Spec fields (supports nested via path) ────────────────────── + if specPath, ok := specPathLookup[key]; ok { + if utils.IsNestedPath(specPath) { + // Nested path — set at the dot-notation path + if err := utils.SetNestedPath(spec, specPath, value); err != nil { + // Log error but continue — don't fail the request + logger.Error().Err(err). + Str("path", specPath). + Msg("apply api: failed to set spec path") + continue + } + } else { + // Flat path — direct assignment + spec[specPath] = value + } + continue + } - case utils.MapContains(labelFields, key): + // ─── Labels ────────────────────────────────────────────────────── + if utils.MapContains(labelFields, key) { labels[key] = fmt.Sprintf("%v", value) + continue + } - case utils.MapContains(annotationFields, key): + // ─── Annotations ────────────────────────────────────────────────── + if utils.MapContains(annotationFields, key) { annotations[key] = fmt.Sprintf("%v", value) - - default: - // Unknown field — silently ignored. + continue } + + // Unknown field — silently ignored. } } diff --git a/pkg/katalog/helper.go b/pkg/katalog/helper.go index 45235e44..3d007129 100644 --- a/pkg/katalog/helper.go +++ b/pkg/katalog/helper.go @@ -20,5 +20,6 @@ var ( parseTimeDuration = utils.ParseTimeDuration // helpers - toStringSet = utils.ToStringSet + toStringSet = utils.ToStringSet + isNestedPath = utils.IsNestedPath ) diff --git a/pkg/katalog/validate.go b/pkg/katalog/validate.go index 60ac513d..2b21074f 100644 --- a/pkg/katalog/validate.go +++ b/pkg/katalog/validate.go @@ -265,9 +265,9 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { } // ------------------------------------------------------------------------- - // 36. Validate idp.additionalFields (key syntax, enum, uniqueness) + // 36. Validate IDP configuration // ------------------------------------------------------------------------- - if err := k.validateIDPAdditionalFields(); err != nil { + if err := k.validateIDP(); err != nil { return nil, err } @@ -285,30 +285,6 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } - // ------------------------------------------------------------------------- - // 39. Validate idp field order: values don't collide - // ------------------------------------------------------------------------- - if err := k.validateIDPFieldOrder(); err != nil { - return nil, err - } - - // ------------------------------------------------------------------------- - // 40. Validate idp.namespace — required on namespaced+idp-enabled CRDs, - // rejected on cluster-scoped ones, incompatible with a pinned watch - // scope when templated - // ------------------------------------------------------------------------- - if err := k.validateIDPNamespace(); err != nil { - return nil, err - } - - // ------------------------------------------------------------------------- - // 41. Validate IDP response config — payload template compilation and - // payload/exclude path conflicts (warnings, not errors). - // ------------------------------------------------------------------------- - if err := k.validateIDPResponseConfig(); err != nil { - return nil, err - } - // ------------------------------------------------------------------------- // 41. Validate gateway tokens (no duplicates) // ------------------------------------------------------------------------- @@ -316,19 +292,5 @@ func (k *Katalog) ValidateConfig(kfg *konfig.Konfig) (*Katalog, error) { return nil, err } - // ------------------------------------------------------------------------- - // 42. Validate IDP tokens and namespace restrictions per CRD - // ------------------------------------------------------------------------- - if err := k.validateIDPTokenRestrictions(); err != nil { - return nil, err - } - - // ------------------------------------------------------------------------- - // 42. Validate IDP targets per CRD; uniquness across the katalog - // ------------------------------------------------------------------------- - if err := k.validateIDPTarget(); err != nil { - return nil, err - } - return k, nil } diff --git a/pkg/katalog/validate_idp.go b/pkg/katalog/validate_idp.go index 1187499f..d7e26e89 100644 --- a/pkg/katalog/validate_idp.go +++ b/pkg/katalog/validate_idp.go @@ -11,6 +11,56 @@ import ( orktypes "github.com/orkspace/orkestra/pkg/types" ) +// validateIDP runs all IDP-related validations. +// This is the single entry point for IDP validation, keeping the main +// pipeline clean and grouping all IDP checks together. +func (k *Katalog) validateIDP() error { + // 1. Validate idp.additionalFields (key syntax, enum, uniqueness) + if err := k.validateIDPAdditionalFields(); err != nil { + return err + } + + // 2. Validate idp.fields path configurations (uniqueness, format, nested) + if err := k.validateIDPFieldPaths(); err != nil { + return err + } + + // 3. Validate idp field order: values don't collide + if err := k.validateIDPFieldOrder(); err != nil { + return err + } + + // 4. Validate idp.namespace — required on namespaced+idp-enabled CRDs, + // rejected on cluster-scoped ones, incompatible with a pinned watch + // scope when templated + if err := k.validateIDPNamespace(); err != nil { + return err + } + + // 5. Validate IDP response config — payload template compilation and + // payload/exclude path conflicts (warnings, not errors) + if err := k.validateIDPResponseConfig(); err != nil { + return err + } + + // 6. Validate IDP tokens and namespace restrictions per CRD + if err := k.validateIDPTokenRestrictions(); err != nil { + return err + } + + // 7. Validate IDP targets per CRD; uniqueness across the katalog + if err := k.validateIDPTarget(); err != nil { + return err + } + + // 8. Validate IDP response config (depends on CRD) + if err := k.validateIDPResponseConfig(); err != nil { + return err + } + + return nil +} + // validateIDPAdditionalFields checks idp.additionalFields.labels/annotations // keys are syntactically valid Kubernetes label/annotation keys, that // type: enum fields declare a non-empty enum, and that no key collides with diff --git a/pkg/katalog/validate_idp_field_paths.go b/pkg/katalog/validate_idp_field_paths.go new file mode 100644 index 00000000..1a46e89f --- /dev/null +++ b/pkg/katalog/validate_idp_field_paths.go @@ -0,0 +1,102 @@ +package katalog + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" +) + +// validateIDPFieldPaths validates idp.fields path configurations: +// - Paths (if set) must be unique across all fields +// - Path must be a valid dot-notation path (no empty segments, no leading/trailing dots) +// - Warn if path is nested (contains a dot) — schema existence validation is not yet implemented +func (k *Katalog) validateIDPFieldPaths() error { + for crdName, crd := range k.enabledCRDs { + if !crd.IDPEnabled() { + continue + } + + if !crd.HasIDPFields() { + continue + } + + seenPaths := make(map[string]bool) + + for name, config := range crd.IDP.Fields { + // 1. Skip if no path is set (flat field) + if !config.HasSpecPath() { + continue + } + + specPath := config.SpecPath(name) + + // 2. Validate the path format (no empty segments) + if err := validatePathFormat(specPath); err != nil { + return fmt.Errorf( + "CRD %q: idp.fields %q has invalid path %q: %w", + crdName, name, specPath, err, + ) + } + + // 3. Check for duplicate spec paths + if seenPaths[specPath] { + return errDuplicateIDPFieldPath(crdName, specPath, name) + } + seenPaths[specPath] = true + + // 4. If path is nested, warn that validation against the CRD + // schema is not yet implemented (to be added later) + if isNestedPath(specPath) { + crd.Warnings.AddWarning(fmt.Sprintf( + "CRD %q: idp.fields %q has nested path %q — "+ + "schema validation is not yet available. Verify the path exists "+ + "in the CRD spec (e.g., 'spec.%s') and that it is a leaf field.", + crdName, name, specPath, specPath, + )) + } + } + } + return nil +} + +// validatePathFormat ensures the path is a valid dot-notation path: +// - No empty segments (e.g., "app..repository") +// - No leading or trailing dots +// - Each segment is a valid Kubernetes qualified name +func validatePathFormat(path string) error { + if path == "" { + return fmt.Errorf("path cannot be empty") + } + + if strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") { + return fmt.Errorf("path cannot start or end with a dot") + } + + parts := strings.Split(path, ".") + for _, part := range parts { + if part == "" { + return fmt.Errorf("path contains empty segment (double dot)") + } + if errs := validation.IsQualifiedName(part); len(errs) > 0 { + return fmt.Errorf("path segment %q is not a valid Kubernetes name: %s", part, strings.Join(errs, "; ")) + } + } + + return nil +} + +func errDuplicateIDPFieldPath(crd, path, field string) error { + return fmt.Errorf(` +────────────────────────────────────────────── +%s Duplicate idp.fields path: %q + CRD: %s + Used by field: %q + +Each idp.fields entry must have a unique spec path — two fields cannot map +to the same location in the CRD spec. + +If you need two fields to represent the same spec path, consider using a +single field with an enum or conditional logic. +──────────────────────────────────────────────`, failureMark(), path, crd, field) +} diff --git a/pkg/types/types_crd_entry.go b/pkg/types/types_crd_entry.go index 7b17d145..737b3421 100644 --- a/pkg/types/types_crd_entry.go +++ b/pkg/types/types_crd_entry.go @@ -307,6 +307,11 @@ func (c *CRDEntry) HasIDPName() bool { return c.IDP != nil && c.IDP.Name != "" } +// HasIDPFields reports whether this CRD declares any idp.fields. +func (c *CRDEntry) HasIDPFields() bool { + return c.IDPEnabled() && c.IDP.Fields != nil && len(c.IDP.Fields) > 0 +} + // RequireIDPName reports whether an Apply API caller (and the Control Center // form) must supply metadata.name themselves — true unless idp.name is // declared, in which case the name is resolved server-side instead. diff --git a/pkg/types/types_idp.go b/pkg/types/types_idp.go index 5475b59c..652c6495 100644 --- a/pkg/types/types_idp.go +++ b/pkg/types/types_idp.go @@ -3,6 +3,7 @@ package types import ( "fmt" "slices" + "strings" ) // IDPDenyReason is returned by TokenAllowed to let the caller compose a @@ -226,6 +227,12 @@ type IDPFieldConfig struct { // Enum lists valid values when Type == "enum". Required in that case. Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"` + + // Path is the dot-notation path in the CRD spec where this field belongs. + // Example: "app.repository", "scaling.minReplicas" + // When set, the field is mapped to this nested path. + // When empty, the field name is used as the path (flat). + Path string `yaml:"path,omitempty" json:"path,omitempty"` } // IDPConfig_Config is the container for gateway-level CRD configuration. @@ -248,6 +255,25 @@ func IsValidIDPFieldType(t string) bool { } } +// SpecPath returns the dot-notation path to use in the CRD spec. +// If Path is set, use Path. Otherwise, use the field name. +func (f IDPFieldConfig) SpecPath(name string) string { + if f.Path != "" { + return f.Path + } + return name +} + +// IsNested returns true if the spec path contains a dot. +func (f IDPFieldConfig) IsNested(name string) bool { + return strings.Contains(f.SpecPath(name), ".") +} + +// HasSpecPath returns true if the spec path is set. +func (f IDPFieldConfig) HasSpecPath() bool { + return f.Path != "" +} + // HasTokenRestrictions reports whether any per-token access rules are declared. // When false, any valid gateway token may access this CRD — backward-compatible // with the previous model where tokens were only checked for existence. diff --git a/pkg/utils/helper.go b/pkg/utils/helper.go index ff97680d..86060b0d 100644 --- a/pkg/utils/helper.go +++ b/pkg/utils/helper.go @@ -1,7 +1,5 @@ package utils -import "strings" - // ToStringSet converts a slice of strings to a map[string]bool for O(1) lookups. // // Useful for: @@ -18,82 +16,6 @@ func ToStringSet(ops []string) map[string]bool { return s } -// NestedSlice navigates a map[string]interface{} using dot-notation keys -// and returns the final slice value. -// Returns nil, false if any key in the path is missing or not a map. -func NestedSlice(obj map[string]interface{}, keys ...string) ([]interface{}, bool) { - cur := obj - for i, k := range keys { - if i == len(keys)-1 { - v, ok := cur[k].([]interface{}) - return v, ok - } - next, ok := cur[k].(map[string]interface{}) - if !ok { - return nil, false - } - cur = next - } - return nil, false -} - -// NestedMap navigates a map[string]interface{} using dot-notation keys -// and returns the final map value. -// Returns nil, false if any key in the path is missing or not a map. -func NestedMap(obj map[string]interface{}, keys ...string) (map[string]interface{}, bool) { - cur := obj - for _, k := range keys { - next, ok := cur[k].(map[string]interface{}) - if !ok { - return nil, false - } - cur = next - } - return cur, true -} - -// DeleteNestedPath removes a dot-notation path from a nested map in place. -// Silently does nothing when the path does not exist — partial paths are not -// errors. Supports arbitrary depth: "metadata.managedFields", -// "status.observedGeneration", "metadata.annotations.internal-key". -func DeleteNestedPath(obj map[string]interface{}, path string) { - parts := strings.SplitN(path, ".", 2) - if len(parts) == 0 || obj == nil { - return - } - - key := parts[0] - if len(parts) == 1 { - // Leaf — delete this key. - delete(obj, key) - return - } - - // Intermediate — recurse into the nested map if it exists. - if nested, ok := obj[key].(map[string]interface{}); ok { - DeleteNestedPath(nested, parts[1]) - } -} - -// DeepCopyMap returns a shallow-to-one-level deep copy of a map[string]interface{}. -// Nested maps are also copied; slices and scalar values share the same pointer. -// Sufficient for our use case: we only modify top-level keys and nested map keys -// via deleteNestedPath — we never mutate slice elements or scalar values. -func DeepCopyMap(src map[string]interface{}) map[string]interface{} { - if src == nil { - return nil - } - dst := make(map[string]interface{}, len(src)) - for k, v := range src { - if nested, ok := v.(map[string]interface{}); ok { - dst[k] = DeepCopyMap(nested) - } else { - dst[k] = v - } - } - return dst -} - // SetContains is a nil-safe struct{} map membership check. func SetContains(s map[string]struct{}, key string) bool { _, ok := s[key] diff --git a/pkg/utils/helper_test.go b/pkg/utils/helper_test.go index 8e516875..10d784bc 100644 --- a/pkg/utils/helper_test.go +++ b/pkg/utils/helper_test.go @@ -1,24 +1,347 @@ package utils -import "testing" +import ( + "fmt" + "reflect" + "testing" +) -func TestNestedMap(t *testing.T) { - obj := map[string]interface{}{ - "a": map[string]interface{}{ - "b": map[string]interface{}{ - "c": map[string]interface{}{"found": true}, +func TestToStringSet(t *testing.T) { + tests := []struct { + name string + ops []string + want map[string]bool + }{ + { + name: "valid slice", + ops: []string{"read", "write", "execute"}, + want: map[string]bool{ + "read": true, + "write": true, + "execute": true, }, }, + { + name: "slice with duplicates", + ops: []string{"read", "read", "write", "write"}, + want: map[string]bool{ + "read": true, + "write": true, + }, + }, + { + name: "empty slice", + ops: []string{}, + want: map[string]bool{}, + }, + { + name: "nil slice", + ops: nil, + want: map[string]bool{}, + }, + { + name: "single element", + ops: []string{"admin"}, + want: map[string]bool{ + "admin": true, + }, + }, + { + name: "with special characters", + ops: []string{"key.with.dots", "key-with-dashes", "key_with_underscores"}, + want: map[string]bool{ + "key.with.dots": true, + "key-with-dashes": true, + "key_with_underscores": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToStringSet(tt.ops) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ToStringSet() = %v, want %v", got, tt.want) + } + // Verify all values are true + for key, val := range got { + if !val { + t.Errorf("ToStringSet() key %q has value false, expected true", key) + } + } + }) + } +} + +func TestSetContains(t *testing.T) { + tests := []struct { + name string + s map[string]struct{} + key string + want bool + }{ + { + name: "key exists", + s: map[string]struct{}{ + "read": {}, + "write": {}, + }, + key: "read", + want: true, + }, + { + name: "key does not exist", + s: map[string]struct{}{ + "read": {}, + "write": {}, + }, + key: "execute", + want: false, + }, + { + name: "nil map", + s: nil, + key: "read", + want: false, + }, + { + name: "empty map", + s: map[string]struct{}{}, + key: "read", + want: false, + }, + { + name: "empty string key", + s: map[string]struct{}{ + "": {}, + }, + key: "", + want: true, + }, + { + name: "key with special characters", + s: map[string]struct{}{ + "key.with.dots": {}, + "key-with-dash": {}, + }, + key: "key.with.dots", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SetContains(tt.s, tt.key) + if got != tt.want { + t.Errorf("SetContains() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMapContains(t *testing.T) { + tests := []struct { + name string + m map[string]interface{} + key string + want bool + }{ + { + name: "key exists with string value", + m: map[string]interface{}{ + "name": "myapp", + "port": 8080, + }, + key: "name", + want: true, + }, + { + name: "key exists with int value", + m: map[string]interface{}{ + "name": "myapp", + "port": 8080, + }, + key: "port", + want: true, + }, + { + name: "key exists with nil value", + m: map[string]interface{}{ + "name": "myapp", + "nil": nil, + }, + key: "nil", + want: true, + }, + { + name: "key does not exist", + m: map[string]interface{}{ + "name": "myapp", + "port": 8080, + }, + key: "missing", + want: false, + }, + { + name: "nil map", + m: nil, + key: "name", + want: false, + }, + { + name: "empty map", + m: map[string]interface{}{}, + key: "name", + want: false, + }, + { + name: "key with special characters", + m: map[string]interface{}{ + "key.with.dots": "value1", + "key-with-dash": "value2", + }, + key: "key.with.dots", + want: true, + }, } - got, ok := NestedMap(obj, "a", "b", "c") - if !ok { - t.Fatal("nestedMap: not found") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MapContains(tt.m, tt.key) + if got != tt.want { + t.Errorf("MapContains() = %v, want %v", got, tt.want) + } + }) } - if got["found"] != true { - t.Errorf("nestedMap result = %v", got) +} + +// Additional tests for generic MapContains with different value types +func TestMapContainsGeneric(t *testing.T) { + // Test with int values + t.Run("int values", func(t *testing.T) { + m := map[string]int{ + "one": 1, + "two": 2, + } + if !MapContains(m, "one") { + t.Errorf("MapContains() with int map should return true for existing key") + } + if MapContains(m, "three") { + t.Errorf("MapContains() with int map should return false for missing key") + } + }) + + // Test with bool values + t.Run("bool values", func(t *testing.T) { + m := map[string]bool{ + "enabled": true, + "disabled": false, + } + if !MapContains(m, "enabled") { + t.Errorf("MapContains() with bool map should return true for existing key") + } + if MapContains(m, "missing") { + t.Errorf("MapContains() with bool map should return false for missing key") + } + }) + + // Test with struct values + t.Run("struct values", func(t *testing.T) { + type Person struct { + Name string + Age int + } + m := map[string]Person{ + "alice": {Name: "Alice", Age: 30}, + "bob": {Name: "Bob", Age: 25}, + } + if !MapContains(m, "alice") { + t.Errorf("MapContains() with struct map should return true for existing key") + } + if MapContains(m, "charlie") { + t.Errorf("MapContains() with struct map should return false for missing key") + } + }) + + // Test with slice values + t.Run("slice values", func(t *testing.T) { + m := map[string][]string{ + "fruits": {"apple", "banana"}, + "colors": {"red", "blue"}, + } + if !MapContains(m, "fruits") { + t.Errorf("MapContains() with slice map should return true for existing key") + } + if MapContains(m, "vegetables") { + t.Errorf("MapContains() with slice map should return false for missing key") + } + }) +} + +// Benchmark tests +func BenchmarkToStringSet(b *testing.B) { + ops := []string{"read", "write", "execute", "delete", "create", "update"} + for i := 0; i < b.N; i++ { + ToStringSet(ops) + } +} + +func BenchmarkSetContains(b *testing.B) { + s := map[string]struct{}{ + "read": {}, + "write": {}, + "execute": {}, + "delete": {}, + "create": {}, + "update": {}, + } + for i := 0; i < b.N; i++ { + SetContains(s, "read") + } +} + +func BenchmarkMapContains(b *testing.B) { + m := map[string]interface{}{ + "read": true, + "write": true, + "execute": true, + "delete": true, + "create": true, + "update": true, + } + for i := 0; i < b.N; i++ { + MapContains(m, "read") + } +} + +// Example usage tests +func ExampleToStringSet() { + ops := []string{"read", "write", "execute"} + set := ToStringSet(ops) + if set["read"] { + fmt.Println("read permission exists") + } + // Output: read permission exists +} + +func ExampleSetContains() { + permissions := map[string]struct{}{ + "read": {}, + "write": {}, + } + if SetContains(permissions, "read") { + fmt.Println("has read permission") + } + // Output: has read permission +} + +func ExampleMapContains() { + config := map[string]interface{}{ + "port": 8080, + "debug": true, } - _, ok = NestedMap(obj, "a", "missing") - if ok { - t.Error("nestedMap should return false for missing key") + if MapContains(config, "port") { + fmt.Println("port is configured") } + // Output: port is configured } diff --git a/pkg/utils/nested.go b/pkg/utils/nested.go new file mode 100644 index 00000000..0430c43f --- /dev/null +++ b/pkg/utils/nested.go @@ -0,0 +1,152 @@ +package utils + +import ( + "fmt" + "strings" +) + +// NestedSlice navigates a map[string]interface{} using dot-notation keys +// and returns the final slice value. +// Returns nil, false if any key in the path is missing or not a map. +func NestedSlice(obj map[string]interface{}, keys ...string) ([]interface{}, bool) { + cur := obj + for i, k := range keys { + if i == len(keys)-1 { + v, ok := cur[k].([]interface{}) + return v, ok + } + next, ok := cur[k].(map[string]interface{}) + if !ok { + return nil, false + } + cur = next + } + return nil, false +} + +// NestedMap navigates a map[string]interface{} using dot-notation keys +// and returns the final map value. +// Returns nil, false if any key in the path is missing or not a map. +func NestedMap(obj map[string]interface{}, keys ...string) (map[string]interface{}, bool) { + if len(keys) == 0 { + return nil, false + } + cur := obj + for _, k := range keys { + next, ok := cur[k].(map[string]interface{}) + if !ok { + return nil, false + } + cur = next + } + return cur, true +} + +// DeepCopyMap returns a shallow-to-one-level deep copy of a map[string]interface{}. +// Nested maps are also copied; slices and scalar values share the same pointer. +// Sufficient for our use case: we only modify top-level keys and nested map keys +// via deleteNestedPath — we never mutate slice elements or scalar values. +func DeepCopyMap(src map[string]interface{}) map[string]interface{} { + if src == nil { + return nil + } + dst := make(map[string]interface{}, len(src)) + for k, v := range src { + if nested, ok := v.(map[string]interface{}); ok { + dst[k] = DeepCopyMap(nested) + } else { + dst[k] = v + } + } + return dst +} + +// SetNestedPath sets a value at a dot-notation path in a map. +// Creates intermediate maps as needed. +// +// Example: +// +// SetNestedPath(spec, "app.repository", "myorg/payments-api") +// → spec["app"]["repository"] = "myorg/payments-api" +func SetNestedPath(m map[string]interface{}, path string, value interface{}) error { + if path == "" { + return fmt.Errorf("empty path") + } + + parts := strings.Split(path, ".") + if len(parts) == 0 { + return fmt.Errorf("empty path") + } + + // Navigate to the parent + current := m + for i := 0; i < len(parts)-1; i++ { + key := parts[i] + if _, ok := current[key]; !ok { + current[key] = make(map[string]interface{}) + } + next, ok := current[key].(map[string]interface{}) + if !ok { + return fmt.Errorf("path segment %q is not a map (cannot set nested value)", key) + } + current = next + } + + // Set the value at the final key + current[parts[len(parts)-1]] = value + return nil +} + +// GetNestedPath retrieves a value from a dot-notation path in a map. +// Returns nil, false if the path doesn't exist. +func GetNestedPath(m map[string]interface{}, path string) (interface{}, bool) { + if path == "" { + return nil, false + } + + parts := strings.Split(path, ".") + current := m + for i, key := range parts { + val, ok := current[key] + if !ok { + return nil, false + } + if i == len(parts)-1 { + return val, true + } + next, ok := val.(map[string]interface{}) + if !ok { + return nil, false + } + current = next + } + return nil, false +} + +// DeleteNestedPath removes a dot-notation path from a nested map in place. +// Silently does nothing when the path does not exist — partial paths are not +// errors. Supports arbitrary depth: "metadata.managedFields", +// "status.observedGeneration", "metadata.annotations.internal-key". +func DeleteNestedPath(obj map[string]interface{}, path string) { + parts := strings.SplitN(path, ".", 2) + if len(parts) == 0 || obj == nil { + return + } + + key := parts[0] + if len(parts) == 1 { + // Leaf — delete this key. + delete(obj, key) + return + } + + // Intermediate — recurse into the nested map if it exists. + if nested, ok := obj[key].(map[string]interface{}); ok { + DeleteNestedPath(nested, parts[1]) + } +} + +// IsNestedPath returns true if the path contains a dot. +func IsNestedPath(path string) bool { + return strings.Contains(path, ".") +} diff --git a/pkg/utils/nested_test.go b/pkg/utils/nested_test.go new file mode 100644 index 00000000..1c780c6b --- /dev/null +++ b/pkg/utils/nested_test.go @@ -0,0 +1,610 @@ +package utils + +import ( + "reflect" + "testing" +) + +func TestNestedSlice(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + keys []string + want []interface{} + wantOk bool + }{ + { + name: "valid nested slice", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "items": []interface{}{"a", "b", "c"}, + }, + }, + keys: []string{"app", "items"}, + want: []interface{}{"a", "b", "c"}, + wantOk: true, + }, + { + name: "slice at top level", + obj: map[string]interface{}{ + "items": []interface{}{1, 2, 3}, + }, + keys: []string{"items"}, + want: []interface{}{1, 2, 3}, + wantOk: true, + }, + { + name: "non-slice value at final key", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + }, + }, + keys: []string{"app", "name"}, + want: nil, + wantOk: false, + }, + { + name: "missing intermediate key", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "items": []interface{}{"a", "b"}, + }, + }, + keys: []string{"app", "missing", "items"}, + want: nil, + wantOk: false, + }, + { + name: "missing final key", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "items": []interface{}{"a", "b"}, + }, + }, + keys: []string{"app", "missing"}, + want: nil, + wantOk: false, + }, + { + name: "empty keys", + obj: map[string]interface{}{ + "items": []interface{}{"a", "b"}, + }, + keys: []string{}, + want: nil, + wantOk: false, + }, + { + name: "nil object", + obj: nil, + keys: []string{"app", "items"}, + want: nil, + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := NestedSlice(tt.obj, tt.keys...) + if ok != tt.wantOk { + t.Errorf("NestedSlice() ok = %v, want %v", ok, tt.wantOk) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NestedSlice() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNestedMap(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + keys []string + want map[string]interface{} + wantOk bool + }{ + { + name: "valid nested map", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "config": map[string]interface{}{ + "key": "value", + }, + }, + }, + keys: []string{"app", "config"}, + want: map[string]interface{}{"key": "value"}, + wantOk: true, + }, + { + name: "top level map", + obj: map[string]interface{}{ + "config": map[string]interface{}{ + "key": "value", + }, + }, + keys: []string{"config"}, + want: map[string]interface{}{"key": "value"}, + wantOk: true, + }, + { + name: "non-map value at final key", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + }, + }, + keys: []string{"app", "name"}, + want: nil, + wantOk: false, + }, + { + name: "missing intermediate key", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "config": map[string]interface{}{"key": "value"}, + }, + }, + keys: []string{"app", "missing", "config"}, + want: nil, + wantOk: false, + }, + { + name: "empty keys", + obj: map[string]interface{}{ + "config": map[string]interface{}{"key": "value"}, + }, + keys: []string{}, + want: nil, + wantOk: false, + }, + { + name: "nil object", + obj: nil, + keys: []string{"app", "config"}, + want: nil, + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := NestedMap(tt.obj, tt.keys...) + if ok != tt.wantOk { + t.Errorf("NestedMap() ok = %v, want %v", ok, tt.wantOk) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NestedMap() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDeepCopyMap(t *testing.T) { + tests := []struct { + name string + src map[string]interface{} + want map[string]interface{} + }{ + { + name: "nil input", + src: nil, + want: nil, + }, + { + name: "empty map", + src: map[string]interface{}{}, + want: map[string]interface{}{}, + }, + { + name: "flat map with scalar values", + src: map[string]interface{}{ + "name": "myapp", + "port": 8080, + "debug": true, + }, + want: map[string]interface{}{ + "name": "myapp", + "port": 8080, + "debug": true, + }, + }, + { + name: "nested maps", + src: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + "config": map[string]interface{}{ + "key": "value", + }, + }, + "items": []interface{}{"a", "b"}, + }, + want: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + "config": map[string]interface{}{ + "key": "value", + }, + }, + "items": []interface{}{"a", "b"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DeepCopyMap(tt.src) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DeepCopyMap() got = %v, want %v", got, tt.want) + } + // Verify that nested maps are actually copies (not same pointer) + if len(tt.src) > 0 { + if reflect.ValueOf(got).Pointer() == reflect.ValueOf(tt.src).Pointer() { + t.Errorf("DeepCopyMap() returned same map pointer, should be a copy") + } + // Check nested maps separately + for k, v := range tt.src { + if nested, ok := v.(map[string]interface{}); ok { + if gotNested, ok := got[k].(map[string]interface{}); ok { + if reflect.ValueOf(gotNested).Pointer() == reflect.ValueOf(nested).Pointer() { + t.Errorf("DeepCopyMap() nested map for key %q is same pointer, should be copied", k) + } + if !reflect.DeepEqual(gotNested, nested) { + t.Errorf("DeepCopyMap() nested map for key %q content mismatch", k) + } + } + } + } + } + }) + } +} + +func TestSetNestedPath(t *testing.T) { + tests := []struct { + name string + initial map[string]interface{} + path string + value interface{} + want map[string]interface{} + wantErr bool + }{ + { + name: "set simple key", + initial: map[string]interface{}{}, + path: "name", + value: "myapp", + want: map[string]interface{}{"name": "myapp"}, + wantErr: false, + }, + { + name: "set nested path", + initial: map[string]interface{}{ + "app": map[string]interface{}{}, + }, + path: "app.repository", + value: "myorg/payments-api", + want: map[string]interface{}{ + "app": map[string]interface{}{ + "repository": "myorg/payments-api", + }, + }, + wantErr: false, + }, + { + name: "create intermediate maps", + initial: map[string]interface{}{}, + path: "app.config.timeout", + value: 30, + want: map[string]interface{}{ + "app": map[string]interface{}{ + "config": map[string]interface{}{ + "timeout": 30, + }, + }, + }, + wantErr: false, + }, + { + name: "path segment not a map - overwrite existing value", + initial: map[string]interface{}{ + "app": "string-value", + }, + path: "app.repository", + value: "myorg/payments-api", + want: nil, + wantErr: true, + }, + { + name: "empty path", + initial: map[string]interface{}{}, + path: "", + value: "test", + want: map[string]interface{}{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SetNestedPath(tt.initial, tt.path, tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("SetNestedPath() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && !reflect.DeepEqual(tt.initial, tt.want) { + t.Errorf("SetNestedPath() got = %v, want %v", tt.initial, tt.want) + } + }) + } +} + +func TestGetNestedPath(t *testing.T) { + tests := []struct { + name string + m map[string]interface{} + path string + want interface{} + wantOk bool + }{ + { + name: "get simple key", + m: map[string]interface{}{ + "name": "myapp", + }, + path: "name", + want: "myapp", + wantOk: true, + }, + { + name: "get nested path", + m: map[string]interface{}{ + "app": map[string]interface{}{ + "repository": "myorg/payments-api", + }, + }, + path: "app.repository", + want: "myorg/payments-api", + wantOk: true, + }, + { + name: "get deeply nested path", + m: map[string]interface{}{ + "app": map[string]interface{}{ + "config": map[string]interface{}{ + "timeout": 30, + }, + }, + }, + path: "app.config.timeout", + want: 30, + wantOk: true, + }, + { + name: "path not found", + m: map[string]interface{}{ + "name": "myapp", + }, + path: "missing", + want: nil, + wantOk: false, + }, + { + name: "nested path not found", + m: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + }, + }, + path: "app.missing", + want: nil, + wantOk: false, + }, + { + name: "path segment not a map", + m: map[string]interface{}{ + "app": "string-value", + }, + path: "app.repository", + want: nil, + wantOk: false, + }, + { + name: "empty path", + m: map[string]interface{}{}, + path: "", + want: nil, + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := GetNestedPath(tt.m, tt.path) + if ok != tt.wantOk { + t.Errorf("GetNestedPath() ok = %v, want %v", ok, tt.wantOk) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetNestedPath() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDeleteNestedPath(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + path string + want map[string]interface{} + }{ + { + name: "delete simple key", + obj: map[string]interface{}{ + "name": "myapp", + "port": 8080, + }, + path: "name", + want: map[string]interface{}{ + "port": 8080, + }, + }, + { + name: "delete nested key", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "repository": "myorg/payments-api", + "version": "v1", + }, + }, + path: "app.repository", + want: map[string]interface{}{ + "app": map[string]interface{}{ + "version": "v1", + }, + }, + }, + { + name: "delete deeply nested key", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + }, + path: "metadata.annotations.key1", + want: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "key2": "value2", + }, + }, + }, + }, + { + name: "delete non-existent key (no-op)", + obj: map[string]interface{}{ + "name": "myapp", + }, + path: "missing", + want: map[string]interface{}{ + "name": "myapp", + }, + }, + { + name: "delete non-existent nested key (no-op)", + obj: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + }, + }, + path: "app.missing", + want: map[string]interface{}{ + "app": map[string]interface{}{ + "name": "myapp", + }, + }, + }, + { + name: "path segment not a map (no-op)", + obj: map[string]interface{}{ + "app": "string-value", + }, + path: "app.repository", + want: map[string]interface{}{ + "app": "string-value", + }, + }, + { + name: "empty path (no-op)", + obj: map[string]interface{}{ + "name": "myapp", + }, + path: "", + want: map[string]interface{}{ + "name": "myapp", + }, + }, + { + name: "delete nested map completely", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "key": "value", + }, + "labels": map[string]interface{}{ + "env": "prod", + }, + }, + }, + path: "metadata.annotations", + want: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "env": "prod", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + DeleteNestedPath(tt.obj, tt.path) + if !reflect.DeepEqual(tt.obj, tt.want) { + t.Errorf("DeleteNestedPath() got = %v, want %v", tt.obj, tt.want) + } + }) + } +} + +func TestIsNestedPath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + { + name: "simple key", + path: "name", + want: false, + }, + { + name: "one dot", + path: "app.repository", + want: true, + }, + { + name: "multiple dots", + path: "metadata.annotations.internal-key", + want: true, + }, + { + name: "empty path", + path: "", + want: false, + }, + { + name: "dot at start", + path: ".start", + want: true, + }, + { + name: "dot at end", + path: "end.", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNestedPath(tt.path); got != tt.want { + t.Errorf("IsNestedPath() = %v, want %v", got, tt.want) + } + }) + } +}