diff --git a/CHANGELOG.md b/CHANGELOG.md index a629b31e..75aea93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,6 +227,81 @@ Both gates use the full resolver chain (`.spec`, `.metadata`, serve intent, prof `crdFiles` / `crFiles` added to `E2ESpec`. `tests/simulate-envtest/04-conditional-reconciliation/` covers gate-pass and gate-discard via envtest simulate. `examples/intermediate/05-when-conditions/conditional-reconciliation/` — App (reconcileGate) + Route (unconditional) pack. +### Serve modes, apply-time controls, and field selectors + +Three new blocks under `serve` and per target give platform teams granular control over the Gateway API surface, override behaviour, and full CR routing. + +**`serve.modes`** — controls which apply modes are available for a CRD. Both default to `true`. + +```yaml +serve: + enabled: true + modes: + target: true # target mode — submit fields with a target identifier + cr: false # full CR mode — submit a complete Kubernetes CR + + targets: + staging: + primary: true + modes: + target: false # disable target mode in staging +``` + +At least one mode must be enabled. `ork validate` enforces this. Can be set at the CRD level and per target. + +**`serve.apply.overrides`** — controls whether request-level overrides (`?overwrite=true` and `?override=true`) are honoured. Both default to `true` (allow overrides). This is a second line of defence — even if the caller passes the override parameter, the gateway can reject it based on the configuration. + +```yaml +serve: + enabled: true + apply: + overrides: + resourceConflict: true # allow ?overwrite=true (SSA field ownership) + targetConflict: false # disallow ?override=true (routing surface changes) + + targets: + staging: + primary: true + apply: + overrides: + targetConflict: true # only staging allows routing changes + + production: + primary: false + # inherits CRD-level +``` + +**`resourceConflict`** (previously `forceConflict`) — when `true`, callers can pass `?overwrite=true` to force field ownership on server-side apply. When `false`, the override is rejected regardless of the request. + +**`targetConflict`** (previously `targetOverride`) — when `true`, callers can pass `?override=true` to change the routing surface (target/alias) of an existing CR. When `false`, the override is rejected and routing surface changes are always disallowed. + +Both settings can be set at the CRD level (fallback) and per target. Target-level wins when set. + +**`serve.targets[].fieldSelector`** — links full CRs to a target based on field values. When a CR matches ALL key-value pairs, it is automatically routed to that target — enabling per-target response config, tokens, permissions, and mode enforcement for full CR mode. + +```yaml +serve: + enabled: true + targets: + internal: + fieldSelector: + spec.workloadType: app + modes: + cr: false # internal disallows full CRs + apply: + targetOverride: false +``` + +**`fieldSelector`** — a map of dot-notation field paths to values (max 3 per target). This is a true selector — like Service → Pod selection. Each target must have a unique selector. `ork validate` enforces uniqueness and warns if a target has `cr: false` but no field selector. + +Validation rules: +- Max 3 field selectors per target +- Unique across targets — no two targets can share the same `path:value` pair +- Must be valid dot-notation paths (e.g., `spec.mealPlan: dinner`) +- Values must be non-empty + +The target becomes the owner of the matched CR — controlling its mode, response config, tokens, and provenance. + --- ## v0.7.14 — Aliases and Intent Provenance diff --git a/cmd/cli/play_chain.go b/cmd/cli/play_chain.go index 5410e3e7..568875a2 100644 --- a/cmd/cli/play_chain.go +++ b/cmd/cli/play_chain.go @@ -77,7 +77,7 @@ func runCreateUpdateChain(k *katalog.Katalog, raw map[string]interface{}, tokenN // Stage 4: Provenance — stamp first, then print the CR so annotations are visible printStage(4, "Provenance annotations") api.InjectProvenanceAnnotations(obj, crd.ServeTarget(), alias, source) - api.InjectIntentAnnotation(obj, raw) + api.InjectServeIntentAnnotation(obj, raw) ann := obj.GetAnnotations() keys := make([]string, 0, len(ann)) for k := range ann { diff --git a/documentation/blog/07-what-if-the-next-ops-is-intentops.md b/documentation/blog/07-what-if-the-next-ops-is-intentops.md new file mode 100644 index 00000000..f357aa0a --- /dev/null +++ b/documentation/blog/07-what-if-the-next-ops-is-intentops.md @@ -0,0 +1,155 @@ +# What If the Next Ops Is IntentOps? + +*Every generation of ops shifted what the primary artifact was. We might be due for another shift.* + +--- + +## A short history of shifting artifacts + +Every major ops movement of the last twenty years can be described by what it made the primary artifact — the thing that everything else was derived from, the thing you versioned, the thing you trusted. + +**DevOps** made the pipeline the artifact. Automate the process. If the pipeline is right, the deployment is right. The artifact was the `.yaml` file describing the CI/CD steps, the shell scripts, the Makefiles. You shipped the automation, not just the code. + +**GitOps** made the Git repository the artifact. The desired state of the system lived in Git. The cluster converged toward it. Flux and ArgoCD watched for drift and corrected it. The repository was the source of truth — not the cluster, not the pipeline, the repository. + +**PlatformOps** made the platform itself the artifact. Internal developer platforms, service catalogs, self-service portals. The platform team built the thing that other teams used to deploy things. The artifact was the platform — Backstage plugins, Port integrations, Terraform modules, custom webhooks — and its job was to give developers a simpler interface than raw Kubernetes. + +Each movement answered a real problem. Each one created a new problem by solving the previous one. + +GitOps solved "the cluster is the source of truth" but created "the repository is full of Kubernetes YAML that developers don't understand." PlatformOps solved the developer experience problem but created "someone has to build and maintain the portal, the plugin, the form, and keep it in sync with the actual infrastructure." Every generation of ops made things better for one audience and harder for another. + +--- + +## The problem none of them solved + +Every ops movement has treated the Kubernetes resource as the fundamental unit. The manifest. The CR. The object with `apiVersion`, `kind`, `metadata`, `spec`. + +GitOps delivers manifests from Git. PlatformOps builds forms that construct manifests. CI pipelines apply manifests. The manifest is where everything converges — the translation target that every tool, every workflow, every developer ultimately has to produce. + +The manifest is a Kubernetes concept. It reflects how Kubernetes stores state, not how humans think about intent. + +A developer thinking about deploying an application thinks: *repository, environment, team, replicas.* They do not think: *apiVersion: platform.myorg.io/v1, kind: App, metadata.labels.team: payments, spec.deployment.replicas: 2.* The manifest is the Kubernetes representation of their intent. It is not the intent itself. + +Every ops movement has solved the problem of getting the manifest to the cluster. None of them solved the problem of the manifest being the wrong artifact in the first place. + +--- + +## What IntentOps would mean + +IntentOps is a different premise. The primary artifact is not the manifest. It is the intent. + +Intent is what the person or system actually means to express — in their own vocabulary, at their own level of abstraction, without reference to how Kubernetes stores it. + +```yaml +target: app +repository: myorg/payments-api +environment: staging +team: payments +replicas: 2 +``` + +This is an intent file. It has no `apiVersion`. No `kind`. No `spec`. No `metadata`. It expresses what the developer wants. It does not express how Kubernetes represents what the developer wants. + +In an IntentOps model: + +- **The intent is versioned** — in a file, in a Git repository, reviewed in pull requests, recoverable by commit SHA +- **The intent is the stable contract** — field names that reflect the developer's vocabulary, not the CRD's structure +- **The delivery surface translates** — reads the intent, maps it to the current Kubernetes representation, applies it +- **The cluster is an implementation detail** — the intent is the thing you care about; how the cluster stores it is the translation layer's concern + +When the CRD schema changes — when a field moves, when a structure deepens — the intent file does not change. The translation declaration changes. The callers notice nothing. + +This is not a small shift. It inverts the relationship between intent and infrastructure that every ops movement has taken for granted. Instead of asking "how do we get this manifest to the cluster efficiently," it asks "how do we keep the manifest from being the thing people have to think about at all." + +--- + +## The delivery surface + +IntentOps requires a delivery surface — the layer between intent and infrastructure that does the translation, validates the intent, stamps the provenance, and delivers to the cluster. + +The delivery surface has to answer several questions that current tools leave to callers: + +**Where does this intent go?** The surface resolves the target — which CRD, which cluster, which operator — from the intent's vocabulary without requiring the caller to know. + +**What does it become?** The surface translates the intent's flat vocabulary into the CRD's nested structure. `"cpu": "500m"` becomes `spec.app.resources.cpu`. A cron string `"0 2 * * 1-5"` fans out into five structured schedule fields. The caller's vocabulary and the CRD's structure never have to match. + +**Is it valid?** The surface validates against admission rules that can reason about context — the time of day, external metrics, the caller's identity, cross-field dependencies — not just static field predicates. + +**Who sent it?** The surface stamps provenance — which target, which alias, which source, cryptographically verified — so every downstream system knows how the intent arrived without a separate audit log. + +**Where does it go next?** In a multi-cluster world, the delivery surface routes intent to the right cluster based on whatever the platform team declares — environment, region, alias, token identity. + +None of this is possible when the manifest is the artifact. It becomes possible when intent is the artifact and the surface does the translation. + +--- + +## The sources + +IntentOps also changes the relationship between delivery and source. When intent is the artifact, the source becomes irrelevant to the runtime. + +A developer opens a browser form. A CI pipeline runs a curl command. A GitHub push event fires a webhook. A Slack command is typed. A PagerDuty alert triggers a webhook. A cron job runs at 2am. + +In every case, the delivery surface receives intent — in whatever form the source produces it — translates it, and delivers it. The runtime reconciling behind the delivery surface doesn't know or care where the intent came from. The operator sees a CR. The CR carries provenance annotations. The notes can reason about the source if behavior should vary. But the fundamental reconciliation loop is source-agnostic. + +This is qualitatively different from GitOps, where the source — the Git repository — is load-bearing. The GitOps tool watches the repository. The repository is the source of truth. Remove the repository and the model breaks. In IntentOps, the source is just a delivery mechanism. The intent is the source of truth. The delivery surface can receive it from anywhere. + +--- + +## The provenance dimension + +IntentOps adds something GitOps never had: provenance at the intent level. + +In GitOps, you know what commit caused a deployment. You know the author, the timestamp, the commit message. But you don't know the intent — you know the manifest that was applied. If the CRD schema has changed since the manifest was written, the manifest might not reflect what the author intended anymore. + +In IntentOps, the intent is preserved. It is stamped on the CR as an annotation. The delivery surface records which target was used, which alias, which source. If the source was GitHub Actions, the verified OIDC sub claim is stamped — cryptographic proof of which workflow, which repository, which branch delivered this intent. + +The operator can read this at reconcile time. The admission layer can gate on it. The status layer can surface it. The compliance layer can assert on it. Every CR carries not just what was intended but the full context in which the intent was expressed — verified, immutable, readable by the operator without any additional infrastructure. + +--- + +## Why now + +The reason IntentOps hasn't existed before is not that nobody wanted it. It's that it requires owning enough of the stack to make it work. + +GitOps tools own the delivery layer. They don't own the admission layer or the reconciliation layer. Admission webhook frameworks own the validation layer. They don't own the delivery layer or the reconciliation layer. Operator frameworks own the reconciliation layer. They don't own the delivery layer or the admission layer. + +IntentOps requires owning all three — delivery, admission, and reconciliation — under a unified model so that intent can flow from source to cluster without losing context at each boundary. When delivery, admission, and reconciliation are separate tools, provenance gets lost at every handoff. When they share a model, provenance travels end to end inside the CR itself. + +This is why IntentOps has been hard to build even though the need has been obvious. It's not a new feature for an existing tool. It's a new layer that sits across all of them. + +--- + +## The shift + +DevOps shifted the artifact from code to pipeline. +GitOps shifted the artifact from pipeline to repository. +PlatformOps shifted the artifact from repository to platform. +IntentOps shifts the artifact from platform to intent. + +Each shift absorbed complexity from the caller and placed it in the infrastructure. DevOps absorbed manual deployment. GitOps absorbed manual kubectl. PlatformOps absorbed Kubernetes knowledge. IntentOps absorbs the manifest itself — the last Kubernetes concept that callers have had to understand. + +When intent is the artifact, the caller knows their vocabulary and their target. Everything else is the delivery surface's problem. The CRD schema can change. The cluster can change. The operator can change. The intent stays the same. + +--- + +## I've been building this without knowing that's what I was building + +I started Orkestra to solve a specific problem: writing Kubernetes operators required writing Go, and most of what you wrote was boilerplate. I built a declarative runtime. That was the plan. + +The plan didn't survive contact with the next question. And the question after that. And the question after that. + +The runtime became a gateway. The gateway became a serve layer. The serve layer got a translation layer. The translation layer enabled schema evolution. Schema evolution combined with provenance enabled context-aware reconciliation. Context-aware reconciliation made the cluster an implementation detail. + +I kept following the same principle — separate intent from infrastructure — and it kept leading somewhere new. Three years later I'm looking at what exists and trying to name it. + +IntentOps might be the name. + +The intent file is the artifact. The delivery surface translates it. The cluster is an implementation detail. + +If that's right, we're not at the end of this shift. We're at the beginning of it. + +--- + +*Orkestra is an open source implementation of the IntentOps model for Kubernetes. Early access is open.* + +*[orkestra.sh](https://orkestra.sh)* \ No newline at end of file diff --git a/documentation/blog/08-i-have-been-trying-to-describe-orkestra-for-two-years.md b/documentation/blog/08-i-have-been-trying-to-describe-orkestra-for-two-years.md new file mode 100644 index 00000000..aabd5a7b --- /dev/null +++ b/documentation/blog/08-i-have-been-trying-to-describe-orkestra-for-two-years.md @@ -0,0 +1,184 @@ +# I've Been Trying to Describe Orkestra in One Sentence for Two Years + +*And every time I thought I had it, the project outgrew the words.* + +--- + +I shipped the first version of Orkestra with a GitHub description that said: **"Declarative runtime for Kubernetes operators."** + +I was confident about it. It was accurate. It described exactly what existed — a runtime that let you declare operator behavior in YAML instead of writing Go. You wrote a Katalog. Orkestra reconciled. No controllers. No informers. No workqueues. Just behavior, declared. + +That description lasted about four months. + +--- + +## The first problem + +The first time someone asked me what Orkestra was and I said "a declarative runtime for Kubernetes operators," I watched their face. They nodded in the way people nod when they don't want to ask a follow-up question. + +The word "operators" was the problem. To most engineers, operators mean something specific — the Go programs that extend Kubernetes. Saying Orkestra was a runtime for operators made it sound like something that ran those Go programs. Which is not what it does. It replaces them. + +So I changed it to **"Declarative runtime for Kubernetes behaviors."** + +Behaviors instead of operators. More honest. You're not writing an operator — you're declaring behavior, and Orkestra implements it. The word "behaviors" at least gestured at the right thing. + +That description lasted about two months. + +--- + +## The gateway + +Then I built the gateway. + +The gateway was a new component alongside the runtime — an HTTP server that sat in front of the cluster and accepted intent from any caller. CI pipelines. Browser forms. Slack commands. curl. Any system that could make an HTTP POST could now deliver intent to the platform without touching kubectl, without knowing what a CR was, without understanding Kubernetes at all. + +The runtime reconciled. The gateway delivered. These were different concerns. "Declarative runtime" described the first one. It said nothing about the second. + +I changed it to **"Declarative control plane for Kubernetes operators."** + +Control plane felt right because it covered both components. A control plane coordinates. Orkestra coordinated. + +But I wasn't sure about it. "Control plane" is a Kubernetes-internal term. It sounds like something that manages the cluster infrastructure — the API server, etcd, the scheduler. That's not what Orkestra is. Orkestra runs on the cluster. It uses the cluster. It is not the cluster's control plane. + +I left it in the docs and kept building. + +--- + +## The serve layer + +Then I built the serve layer. + +This is the part that broke every description I had. + +The serve layer started as a small experiment. The platform team had built these beautiful operators using Orkestra. But developers who needed to use the platform still had to write YAML and run kubectl. The gap between "the platform works" and "developers can use the platform" was still a Kubernetes-shaped gap. + +So I added `idp:` to the CRD entry and a Create button appeared in the Control Center. The form was generated from the CRD schema. A developer filled it in. A CR appeared. The operator reconciled. + +Clean. But I noticed something wrong. The Control Center was doing too much. It fetched the full CRD schema. It knew which fields went into `spec` versus `metadata.labels`. It built the complete Kubernetes CR before sending it. Every caller — the Control Center, a CI pipeline, a curl command — was constructing Kubernetes objects. + +I spent days thinking about why this felt wrong. + +Then it clicked: the caller shouldn't know any of that. The Katalog already declared all of it. If the gateway read the Katalog and the CRD entry had a target identifier, the gateway could look up the CRD, read the field declarations, and build the CR itself. The caller would just submit: + +```json +{"target": "app", "repository": "myorg/payments-api", "environment": "staging"} +``` + +No apiVersion. No kind. No spec. No Kubernetes knowledge. + +And it worked. + +--- + +## The path addition — and the schema evolution accident + +Then I added `path` to field declarations. A dot-notation address within the spec so nested CRD structures could be supported. A caller submitting `"cpu": "500m"` could have that value routed to `spec.app.resources.cpu` without knowing it existed. + +And then I noticed something I hadn't set out to build. + +When a CRD field moved — when `spec.app.repository` restructured into `spec.source.repository` — the platform team updated one line in the serve declaration. One line. Every caller submitted the same intent they always had. The CRD had evolved. They had noticed nothing. + +I had accidentally solved Kubernetes schema evolution. + +--- + +## The translation layer + +But path routing only got you so far. It answered the question of *where* a value goes in the spec. It didn't answer the question of *what form* it should take when it gets there. + +Real platforms have this problem constantly. A developer thinks in cron strings — `"0 2 * * 1-5"`. The CRD expects five structured fields: minute, hour, day of month, month, day of week. These are the same thing expressed in two completely different shapes. Before the translation layer, the caller had to know the CRD's shape. They had to submit the five fields. The abstraction leaked. + +So I added `value` and `values` to field declarations. + +`value` transforms a single submitted value before it reaches the spec — strip a prefix, normalise a format, apply a note function. `values` fans one submitted value out into multiple spec fields: + +```yaml +serve: + fields: + schedule: + label: "Schedule (cron)" + values: + schedule.minute: '{{ cronMinute .value }}' + schedule.hour: '{{ cronHour .value }}' + schedule.dayOfMonth: '{{ cronDom .value }}' + schedule.month: '{{ cronMonth .value }}' + schedule.dayOfWeek: '{{ cronDow .value }}' +``` + +The caller submits `"0 2 * * 1-5"`. The CR receives five structured fields. Neither side sees the other's format. The serve layer is the contract between them. + +This is when I understood what the gateway actually was. Not a delivery mechanism. A translation layer. It stood between the world's vocabulary — whatever form intent naturally takes for the people expressing it — and Kubernetes's vocabulary — whatever structure the CRD required. Those two vocabularies never had to match. The gateway translated between them. + +The serve layer was not an IDP. It was not a portal. It was a stable interface — a surface through which intent could arrive in any form, from any source, and be translated into exactly what the cluster expected, without either side knowing what the other looked like. + +--- + +## The names stopped fitting + +At this point I had to rename everything. + +The gateway was called the Apply API. But it wasn't just applying anymore. It was resolving targets, transforming values, fanning out fields, building CRs, validating intent, stamping provenance, and returning structured responses. + +The configuration block was called `idp:`. But what I'd built wasn't an Internal Developer Platform. It was a way to serve a CRD to the world — to any caller, from any source, in any vocabulary — without that caller needing to know Kubernetes existed. + +I renamed everything. `pkg/gateway/applyapi` became `pkg/gateway/api`. The YAML key `idp:` became `serve:`. + +And then I tried to update the GitHub description. + +I stared at it for a long time. + +--- + +## The pattern I hadn't noticed + +Somewhere around the sixth rewrite I noticed the pattern. + +Every description I'd written was correct. Each one accurately described Orkestra at the moment I wrote it. And each one became wrong — not because Orkestra changed direction, but because Orkestra kept growing in the same direction. The implementation kept revealing new implications of the same core idea. + +The runtime was a consequence of "separate intent from infrastructure." The gateway was a consequence of the same principle applied to delivery. The serve layer was a consequence of the same principle applied to the caller interface. Schema evolution fell out of following the principle to its logical conclusion for field routing. The translation layer fell out of asking what happens when the caller's vocabulary and the CRD's vocabulary don't match. + +One principle. Many consequences. Each consequence needed a different description. No single sentence covered them all. + +I wasn't struggling to describe Orkestra because I couldn't write. I was struggling to describe Orkestra because I was still discovering what it was. + +--- + +## Where I landed + +After two years and six descriptions, here is the one I keep returning to: + +**"The missing layer between Kubernetes and the teams who use it."** + +It doesn't describe features. It describes the gap. The gap between what Kubernetes gives you and what the teams using Kubernetes actually need — a stable interface they can build on, deliver through, and evolve without coordination overhead. + +Every team running Kubernetes has felt that gap. Platform engineers feel it when they realise developers still need to write YAML to use the platform they built. Developers feel it when they need to learn kubectl to deploy an application. SREs feel it when a CRD schema change requires migrating every CI pipeline. + +Orkestra fills that gap. + +--- + +## What comes next + +I am currently experimenting with something that would have been unthinkable when I wrote that first description. + +The gateway now has multiple receiving points — direct API calls, GitHub webhooks, GitLab webhooks, Slack commands, generic HTTP integrations. Intent arrives from anywhere. The gateway translates and delivers. + +The question I'm asking now: what if the gateway could deliver to multiple clusters? + +I've built internal platforms before that connected multiple clusters with ArgoCD using Terraform to manage the connections — a setup that is genuinely difficult to build and almost impossible to find clear guidance on. I understand the shape of the problem. The gateway's architecture — a single translation layer with multiple intake points — suggests a natural extension to multiple delivery points. One intent, multiple clusters, the gateway routing based on whatever logic the platform team declares. + +I'm close. But that's a story for another post. + +--- + +I've rewritten the README six times. Each time was correct. Each time was temporary. + +If you're building something that keeps outgrowing its description, that's not a communication problem. That's what it looks like to build something the ecosystem didn't have a word for yet. + +Keep building until you find the word. Or until the word finds you. + +--- + +*Orkestra is in early access. The missing layer is open source.* + +*[orkestra.sh](https://orkestra.sh)* \ No newline at end of file diff --git a/documentation/reference/schema/02-katalog/20-serve-apply.md b/documentation/reference/schema/02-katalog/20-serve-apply.md new file mode 100644 index 00000000..ad2a04e9 --- /dev/null +++ b/documentation/reference/schema/02-katalog/20-serve-apply.md @@ -0,0 +1,154 @@ +# Apply Time Controls + +## `serve.apply.overrides` + +Controls apply-time behaviour for server-side apply and routing surface changes. These settings act as a **second line of defense** — they determine whether request-level overrides (`?overwrite=true` and `?override=true`) are honored. + +Both settings default to `true` (allow overrides). Can be set at the CRD level (fallback for all targets) and per target. + +```yaml +serve: + enabled: true + + # CRD-level fallback for all targets + apply: + overrides: + resourceConflict: true # allow ?overwrite=true + targetConflict: true # allow ?override=true + + targets: + staging: + primary: true + apply: + overrides: + targetConflict: false # staging does NOT allow routing surface changes, even with ?override=true + + production: + primary: false + apply: + overrides: + resourceConflict: false # production does NOT allow force field changes, even with ?overwrite=true +``` + +**`resourceConflict`** — when `true` (default), callers can use `?overwrite=true` to force field ownership on server-side apply, equivalent to `--force-conflict`. When `false`, `?overwrite=true` is rejected and field conflicts are always surfaced as errors. Default: `true`. + +**`targetConflict`** — when `true` (default), callers can use `?override=true` to change the routing surface (target/alias) of an existing CR. When `false`, `?override=true` is rejected and routing surface changes are always rejected with a conflict error. Default: `true`. + +Resolution order per target: +1. Target-level (`serve.targets[].apply.overrides`) +2. CRD-level (`serve.apply.overrides`) +3. Default (`true` — allow overrides) + + +## `serve.targets[].fieldSelector` + +Links full CRs to a target based on field values. When a CR matches ALL key-value pairs, it is automatically routed to that target — enabling per-target response config, tokens, permissions, and mode enforcement for full CR mode. + +```yaml +serve: + enabled: true + targets: + internal: + fieldSelector: + spec.workloadType: app + modes: + cr: false # internal disallows full CRs + apply: + targetOverride: false +``` + +**`fieldSelector`** — a map of dot-notation field paths to values (max 3 per target). This is a true selector — like Service → Pod selection. Each target must have a unique selector. `ork validate` enforces uniqueness. + +**Validation rules:** + +| Rule | Description | +|------|-------------| +| **Maximum 3 selectors** | Each target can have at most 3 field selectors — keep it simple, avoid overlapping. | +| **Unique across targets** | No two targets can share the same `path:value` pair — routing must be deterministic. | +| **Dot-notation format** | Paths must be valid dot-notation paths (e.g., `spec.mealPlan`, not `.mealPlan`). | +| **Valid Kubernetes names** | Each path segment must be a valid Kubernetes qualified name. | +| **Non-empty values** | Values cannot be empty strings. | + +**Warnings:** + +| Condition | Warning | +|-----------|---------| +| CR mode disabled (`modes.cr: false`) and no `fieldSelector` | Target is unreachable via full CR mode — add field selectors or enable CR mode. | +| CR mode disabled globally (`serve.modes.cr: false`) and `fieldSelector` set | field selectors will have no effect — CR mode is disabled globally. | + +```yaml +targets: + internal: + modes: + cr: false # full CR mode disabled + # no fieldSelector → warning: target unreachable via full CR mode +``` + +```yaml +targets: + internal: + fieldSelector: + spec.workloadType: app + modes: + cr: false # full CR mode disabled, but fieldSelector provides a way in + # ✅ no warning — fieldSelector routes CRs to this target +``` + +**The warnings are advisory.** `ork validate` does not block — it informs the platform team of potential misconfigurations. + +**Examples** + +Route CRs with `spec.workloadType: app` to `internal`, and enforce `cr: false`: + +```yaml +serve: + enabled: true + targets: + internal: + modes: + cr: false + fieldSelector: + spec.workloadType: app + apply: + targetOverride: false +``` + +Full CRs with `spec.workloadType: app` are routed to `internal`, and `cr: false` is enforced. The mode check uses the effective target, not the primary target. + +**Validation errors:** + +```bash +ork validate +✗ CRD "platRsc": target "internal" has 4 field selectors — maximum is 3 +✗ CRD "platRsc": field selector "spec.workloadType=app" is used by both targets "internal" and "kitchen" — field selectors must be unique across targets +✗ CRD "platRsc": target "internal" has invalid field selector path ".workloadType": field selector path cannot start or end with a dot. Usage example: 'spec.mealPlan' +``` + +**Warnings:** + +```bash +⚠ CRD "platRsc": target "internal" has fieldSelector but CR mode is disabled — fieldSelector will have no effect +``` + +## Quick Scan + +### The Two-Layer Defense System + +**Layer 1: Request-level (`?overwrite=true` or `?override=true`)** +- Callers can request to bypass conflicts per-request +- This is the **first line of defense** + +**Layer 2: Configuration (`apply.overrides.resourceConflict` / `apply.overrides.targetConflict`)** +- CRD owners can **opt-out** of allowing these bypasses +- When set to `false`, even if the caller passes `?overwrite=true` or `?override=true`, the request is rejected +- This is the **second line of defense** + +### Default Behavior + +**`resourceConflict`**: +- Default: `true` — allows `?overwrite=true` to force field conflicts +- When `false`: even `?overwrite=true` is rejected + +**`targetConflict`**: +- Default: `true` — allows `?override=true` to change routing surface +- When `false`: even `?override=true` is rejected diff --git a/documentation/reference/schema/02-katalog/20-serve.md b/documentation/reference/schema/02-katalog/20-serve.md index 41951e3d..00183c04 100644 --- a/documentation/reference/schema/02-katalog/20-serve.md +++ b/documentation/reference/schema/02-katalog/20-serve.md @@ -57,8 +57,6 @@ spec: | `labels` | — | Label keys exposed as self-service form fields, written to `metadata.labels` on apply. Each entry needs an explicit `type`. | | `annotations` | — | Annotation keys exposed as self-service form fields, written to `metadata.annotations` on apply. Each entry needs an explicit `type`. | | `include` | — | Path (relative to the katalog file) to a YAML file containing `fields:`, `labels:`, and/or `annotations:` keys. Inline entries take precedence. Expanded at load time. | -| `forceConflict` | `false` | When `true`, every Gateway API request for this CRD uses `Force: true` on server-side apply — the gateway takes ownership of any conflicting fields rather than surfacing a conflict error. Equivalent to `helm --force-conflict`. Callers can still override per-request with `?overwrite=true`. | -| `name` | — | Template expression resolving the CR's `metadata.name`. Optional, unlike `namespace` — when unset (the common case), the caller must supply a name. See [`serve.name`](#servename) below. | | `namespace` | — | Template expression resolving the namespace a new CR is created in. Required on a namespaced CRD with `serve.enabled: true`; rejected on a cluster-scoped one. See [`serve.namespace`](#servenamespace) below. | | `clusters` | — | List of registered cluster names (static or template expressions). Declares which clusters this CRD's intents may be applied to, and is the default fan-out when no target override is set. Absent means local cluster only. See [`serve.clusters`](#serveclusters) below. | @@ -179,6 +177,61 @@ This only affects the Gateway API. A raw `kubectl apply` is unaffected either wa **The cluster-scoped alternative.** A CRD can sidestep this entirely by being cluster-scoped (`namespaced: false`) and having `onCreate` provision a namespace as a *child resource* of the CR instead — the CR itself has no namespace, so there's nothing for `serve.namespace` to resolve. Two different answers to the same "a developer shouldn't have to pick a namespace" problem, matched to two different scope choices: cluster-scoped + `onCreate`-provisions-a-child-namespace, or namespaced + `serve.namespace`-routes-into-a-platform-provisioned-one. +## `serve.modes` + +Controls which apply modes are available for this CRD. Both modes default to `true` for backward compatibility. + +```yaml +serve: + enabled: true + modes: + target: true # target mode — submit fields with a target identifier + cr: false # full CR mode — submit a complete Kubernetes CR +``` + +**`target`** — when `true`, callers can use the target mode format: `{"target": "app", "fields": ...}`. This is the intent-first delivery model where callers submit flat fields and the gateway builds the CR. + +**`cr`** — when `true`, callers can use the full CR format: `{"apiVersion": "...", "kind": "...", "spec": {...}}`. This is the traditional Kubernetes CR submission model. + +Both modes default to `true` when omitted. At least one mode must be enabled. `ork validate` enforces this. + +**Examples** + +Only target mode — enforce intent-first delivery: + +```yaml +serve: + enabled: true + target: app + modes: + target: true + cr: false +``` + +Only full CR mode — disable intent-first delivery: + +```yaml +serve: + enabled: true + modes: + target: false + cr: true +``` + +Both modes enabled (default): + +```yaml +serve: + enabled: true + # modes omitted — both true by default +``` + +**Validation rules** — `ork validate` checks that: + +1. At least one mode is enabled. +2. If `target` is `false`, `serve.target` must not be set (a target is only meaningful when target mode is enabled). + +--- ## `serve.clusters` Declares which registered clusters this CRD's intents are allowed to be applied to, @@ -308,9 +361,11 @@ When omitted, defaults to the lowercased kind (`AppRequest` → `apprequest`). ```yaml serve: enabled: true + targetOverride: true target: smartapp: primary: true + targetOverride: false # overrides the global setting preview: enabled: true # default; omit to keep it simple include: ./serve/aliases/preview.yaml diff --git a/go.mod b/go.mod index ae422663..3d6fcdaa 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/orkspace/orkestra -go 1.26.5 +go 1.26.6 require ( cloud.google.com/go/pubsub v1.50.2 diff --git a/pkg/gateway/api/apply.go b/pkg/gateway/api/apply.go index 70ee6b19..ae529171 100644 --- a/pkg/gateway/api/apply.go +++ b/pkg/gateway/api/apply.go @@ -213,6 +213,13 @@ func applyHandler( crd = resolution.CRD alias = resolution.Alias + if !crd.TargetModeEnabledFor(alias) { + writeJSONError(w, http.StatusBadRequest, "target mode not enabled", + fmt.Sprintf("Target mode is not enabled for '%q'", target), + ) + return + } + built, err := BuildCRFromTarget(raw, crd, notes) if err != nil { writeJSON(w, http.StatusBadRequest, ApplyResponse{ @@ -222,7 +229,7 @@ func applyHandler( } obj = built InjectProvenanceAnnotations(obj, crd.ServeTarget(), alias, OIDCSubFromContext(r.Context())) - InjectIntentAnnotation(obj, raw) + InjectServeIntentAnnotation(obj, raw) gvr = crd.GVR() // ─── Marshal built CR for the patch body ────────────────────── @@ -261,10 +268,40 @@ func applyHandler( return } + // If the CRD has matchFields, try to find a matching target + matchedTarget := "" + if target := crd.EffectiveServeTargetForMap(full.Object); target != "" { + matchedTarget = target + } + if matchedTarget != "" { + logger.FromContext(r.Context()).Debug(). + Str("kind", full.GetKind()). + Str("name", full.GetName()). + Str("namespace", full.GetNamespace()). + Str("target", matchedTarget). + Msg("matched target") + } + + // Determine the effective target for mode checking and provenance + effectiveTarget := matchedTarget + if effectiveTarget == "" { + effectiveTarget = crd.ServeTarget() + } + + if !crd.FullCRModeEnabledFor(effectiveTarget) { + writeJSONError(w, http.StatusBadRequest, "CR mode not enabled", + fmt.Sprintf("Full CR mode is not enabled for %q", effectiveTarget), + ) + return + } + obj = &full - InjectProvenanceAnnotations(obj, crd.ServeTarget(), "", OIDCSubFromContext(r.Context())) - gvr = crd.GVR() - patchBody = body // raw body is already a valid CR + + // ─── Inject Provenance Annotations ──────────────────────────────────────── + InjectProvenanceAnnotations(obj, crd.ServeTarget(), matchedTarget, OIDCSubFromContext(r.Context())) + if matchedTarget != "" { + InjectFieldSelectorAnnotations(obj, matchedTarget, crd.FieldSelectorForTarget(matchedTarget)) + } // Resolve serve.name and serve.namespace when declared on the CRD. if err := resolveServeMeta(obj, crd, notes); err != nil { @@ -275,6 +312,21 @@ func applyHandler( } } + // ─── Marshal the modified CR ─────────────────────────────────────────────── + patchBody, err = json.Marshal(obj.Object) + if err != nil { + writeJSON(w, http.StatusInternalServerError, ApplyResponse{ + Message: fmt.Sprintf("failed to marshal CR with annotations: %v", err), + }) + return + } + gvr = crd.GVR() + + // ─── Resolve final effective target ────────────────────────────────────────── + if effectiveTarget := crd.EffectiveServeTargetForMap(obj.Object); effectiveTarget != "" { + alias = effectiveTarget + } + // ── Cluster routing ─────────────────────────────────────────────────────── // Resolve apply targets for this CRD+alias. Cascade: target.clusters → // serve.clusters → local. Templates resolved against raw. @@ -284,10 +336,11 @@ func applyHandler( return } - // CRD-level forceConflict is a katalog declaration; ?overwrite=true is a - // per-request override. Either one sets Force=true. + // CRD-level/target-level forceConflict is a katalog declaration; + // ?overwrite=true is a per-request override. + // Either one sets Force=true. if !overwrite && crd != nil && crd.Serve != nil { - overwrite = crd.Serve.ForceConflict + overwrite = crd.ServeForceConflictEnabledFor(alias) } if obj.GetName() == "" { @@ -364,6 +417,17 @@ func applyHandler( }) return } + + // ── Resolve Targe Override ─────────────────────────────────────────────────────── + if override { + if !crd.ServeTargetOverrideEnabledFor(storedSurface) { + writeJSONError(w, http.StatusBadRequest, "target override not enabled", + fmt.Sprintf("Target override is not enabled for %q", storedSurface), + ) + return + } + } + logger.FromContext(r.Context()).Warn(). Str("from", storedSurface). Str("to", incomingSurface). @@ -527,6 +591,9 @@ func applyHandler( } } +// resolveServeMeta resolves serve.name and serve.namespace on a full CR in-place. +// Called in full CR mode when the platform team has set these expressions on +// the CRD — the submitted spec fields are the resolver data source. // resolveServeMeta resolves serve.name and serve.namespace on a full CR in-place. // Called in full CR mode when the platform team has set these expressions on // the CRD — the submitted spec fields are the resolver data source. @@ -541,10 +608,18 @@ func resolveServeMeta( // Build resolver from the submitted spec so expressions like // `{{ .repository }}` resolve against spec fields. - data := map[string]interface{}{} - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - for k, v := range spec { - data[k] = v + data := map[string]interface{}{ + "metadata": obj.Object["metadata"], + "spec": obj.Object["spec"], + } + + // Add labels and annotations as top-level fields for convenience + if meta, ok := obj.Object["metadata"].(map[string]interface{}); ok { + if labels, ok := meta["labels"].(map[string]interface{}); ok { + data["labels"] = labels + } + if annotations, ok := meta["annotations"].(map[string]interface{}); ok { + data["annotations"] = annotations } } resolver := orktmpl.NewResolverFromMap(data).WithUserNotes(notes) @@ -552,23 +627,53 @@ func resolveServeMeta( if crd.HasServeName() { name, err := resolver.Resolve(crd.Serve.Name) if err != nil || strings.TrimSpace(name) == "" { + logger.Error(). + Str("serve.name", crd.Serve.Name). + Err(err). + Msg("serve.name could not be resolved") return fmt.Errorf( "serve.name %q could not be resolved: %w", crd.Serve.Name, err, ) } - obj.SetName(strings.TrimSpace(name)) + name = strings.TrimSpace(name) + if err := validateK8sName(name); err != nil { + logger.Error(). + Str("serve.name", name). + Err(err). + Msg("serve.name is not a valid kubernetes name") + return fmt.Errorf( + "serve.name %q is not a valid kubernetes name: %w", + name, err, + ) + } + obj.SetName(name) } if crd.HasServeNamespace() { ns, err := resolver.Resolve(crd.Serve.Namespace) if err != nil || strings.TrimSpace(ns) == "" { + logger.Error(). + Str("serve.namespace", crd.Serve.Namespace). + Err(err). + Msg("serve.namespace could not be resolved") return fmt.Errorf( "serve.namespace %q could not be resolved: %w", crd.Serve.Namespace, err, ) } - obj.SetNamespace(strings.TrimSpace(ns)) + ns = strings.TrimSpace(ns) + if err := validateK8sName(ns); err != nil { + logger.Error(). + Str("serve.namespace", ns). + Err(err). + Msg("serve.namespace is not a valid kubernetes namespace") + return fmt.Errorf( + "serve.namespace %q is not a valid kubernetes namespace: %w", + ns, err, + ) + } + obj.SetNamespace(ns) } return nil @@ -596,10 +701,10 @@ func InjectProvenanceAnnotations(obj *unstructured.Unstructured, target, alias, obj.SetAnnotations(ann) } -// InjectIntentAnnotation stores the raw intent payload as a JSON-encoded +// InjectServeIntentAnnotation stores the raw intent payload as a JSON-encoded // annotation so the admission webhook can bind it as .request in validation // rules, enabling intent-level gates before field translation. -func InjectIntentAnnotation(obj *unstructured.Unstructured, raw map[string]interface{}) { +func InjectServeIntentAnnotation(obj *unstructured.Unstructured, raw map[string]interface{}) { b, err := json.Marshal(raw) if err != nil { return @@ -612,6 +717,24 @@ func InjectIntentAnnotation(obj *unstructured.Unstructured, raw map[string]inter obj.SetAnnotations(ann) } +// InjectFieldSelectorAnnotations adds the field selector target and selectors to the CR. +func InjectFieldSelectorAnnotations(obj *unstructured.Unstructured, target string, selector map[string]string) { + if target == "" || len(selector) == 0 { + return + } + ann := obj.GetAnnotations() + if ann == nil { + ann = make(map[string]string) + } + ann[labels.AnnotationServeSelectorTarget] = target + + b, err := json.Marshal(selector) + if err == nil { + ann[labels.AnnotationServeSelector] = string(b) + } + obj.SetAnnotations(ann) +} + // extractViolations pulls field-level causes out of a Kubernetes Status error. func extractViolations(err error) []ApplyViolation { var statusErr *k8serrors.StatusError diff --git a/pkg/gateway/api/apply_target.go b/pkg/gateway/api/apply_target.go index 49d7d3f7..0ccbca3c 100644 --- a/pkg/gateway/api/apply_target.go +++ b/pkg/gateway/api/apply_target.go @@ -71,7 +71,7 @@ func ApplyTargetFields( return &ApplyResponse{Message: err.Error()}, http.StatusBadRequest } InjectProvenanceAnnotations(obj, crd.ServeTarget(), alias, tokenName) - InjectIntentAnnotation(obj, fields) + InjectServeIntentAnnotation(obj, fields) gvr := crd.GVR() patchBody, err := json.Marshal(obj.Object) @@ -102,7 +102,7 @@ func ApplyTargetFields( }, http.StatusUnprocessableEntity } - overwrite := crd.Serve != nil && crd.Serve.ForceConflict + overwrite := crd.ServeForceConflictEnabledFor(alias) patchOpts := metav1.PatchOptions{ FieldManager: konfig.FieldManagerGateway, Force: boolPtr(overwrite), diff --git a/pkg/gateway/api/fixture/intent/README.md b/pkg/gateway/api/fixture/intent/README.md index 3d8cc8a7..b467b306 100644 --- a/pkg/gateway/api/fixture/intent/README.md +++ b/pkg/gateway/api/fixture/intent/README.md @@ -26,8 +26,8 @@ To simulate what `ci-pipeline` can do: ```bash # Allowed on primary (ci-pipeline has get/list at CRD level) -ork serve play -f $KATALOG --token ci-pipeline --operation list -i pkg/gateway/api/fixture/intent/primary.yaml +ork serve play -f $KATALOG --token ci-pipeline --operation list -i pkg/gateway/api/fixture/intent/primary.yaml --target apifixture # Denied — ci-pipeline is not listed in the preview or internal alias token maps -ork serve play -f $KATALOG --token ci-pipeline -i pkg/gateway/api/fixture/intent/preview.json +ork serve play -f $KATALOG --token ci-pipeline -i pkg/gateway/api/fixture/intent/preview.json --target preview ``` diff --git a/pkg/gateway/api/fixture/katalog.yaml b/pkg/gateway/api/fixture/katalog.yaml index 5870d2a9..88c61fa0 100644 --- a/pkg/gateway/api/fixture/katalog.yaml +++ b/pkg/gateway/api/fixture/katalog.yaml @@ -23,6 +23,7 @@ # getServeAlias / getServeTarget — provenance notes consumed by the operatorBox # ?dryRun=true — POST /api/v1/apply preview with field violations # GET /api/v1/schema/ — service catalog (category + description) +# serve.modes — per-target mode control (target/CR mode) # # Tokens: # control-center — secretRef (fixture-cc-token); set controlCenter.gatewayToken.secretRef.name in Helm @@ -94,6 +95,14 @@ spec: include: ./serve/aliases/preview.yaml internal: include: ./serve/aliases/internal.yaml + fieldSelector: + spec.workloadType: app + metadata.name: internal-memo + modes: + cr: false + apply: + targetOverride: false + namespace: '{{ teamName }}' category: "Compute" description: "Self-service application and infrastructure provisioning" diff --git a/pkg/gateway/api/helper.go b/pkg/gateway/api/helper.go index d763c101..d211859f 100644 --- a/pkg/gateway/api/helper.go +++ b/pkg/gateway/api/helper.go @@ -32,6 +32,9 @@ var ( // resolveScalarField resolves a dot-notation path (e.g., "status.phase") // against a map and returns the value as a string. resolveScalarField = orktypes.ResolveScalarField + + // validateK8sName tests that a string is a valid Kubernetes name. + validateK8sName = utils.ValidKubernetesName ) // resolvePollURL builds the poll URL for the Gateway API response. diff --git a/pkg/gateway/api/target.go b/pkg/gateway/api/target.go index 070b86a4..6faa77fc 100644 --- a/pkg/gateway/api/target.go +++ b/pkg/gateway/api/target.go @@ -225,11 +225,26 @@ func resolveServeIdentity( crd.Serve.Name, err, ) } + // Ensure serve.name is a valid kubernetes name + name = strings.TrimSpace(name) + if err := validateK8sName(name); err != nil { + return fmt.Errorf( + "serve.name %q is invalid: %w", + name, err, + ) + } obj.SetName(strings.TrimSpace(name)) } else if name, ok := raw["name"].(string); ok && strings.TrimSpace(name) != "" { // serve.name not declared — use the caller-supplied name, matching // full CR mode's behavior for the same case. - obj.SetName(strings.TrimSpace(name)) + name = strings.TrimSpace(name) + if err := validateK8sName(name); err != nil { + return fmt.Errorf( + "name %q is invalid: %w", + name, err, + ) + } + obj.SetName(name) } if crd.HasServeNamespace() { @@ -241,7 +256,15 @@ func resolveServeIdentity( crd.Serve.Namespace, err, ) } - obj.SetNamespace(strings.TrimSpace(ns)) + // Ensure serve.namespace is a valid kubernetes namespace + ns = strings.TrimSpace(ns) + if err := validateK8sName(ns); err != nil { + return fmt.Errorf( + "serve.namespace %q is invalid: %w", + ns, err, + ) + } + obj.SetNamespace(ns) } return nil diff --git a/pkg/katalog/testdata/validate/invalid/bad-serve-target-mode.yaml b/pkg/katalog/testdata/validate/invalid/bad-serve-target-mode.yaml new file mode 100644 index 00000000..b5c921c6 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-serve-target-mode.yaml @@ -0,0 +1,18 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-serve-target-mode + version: 1.0.0 + description: Tests that serve.modes where serve.modes.target is disabled but serve.target is enabled fail validation. +spec: + crds: + widget: + crdFile: ../crd.yaml + serve: + enabled: true + namespace: default + modes: + target: false + target: + primary: + primary: true diff --git a/pkg/katalog/testdata/validate/invalid/bad-serve-target-modes-disabled.yaml b/pkg/katalog/testdata/validate/invalid/bad-serve-target-modes-disabled.yaml new file mode 100644 index 00000000..c0d509d7 --- /dev/null +++ b/pkg/katalog/testdata/validate/invalid/bad-serve-target-modes-disabled.yaml @@ -0,0 +1,16 @@ +apiVersion: orkestra.orkspace.io/v1 +kind: Katalog +metadata: + name: bad-serve-target-mode + version: 1.0.0 + description: Tests that serve.modes where serve is enabled but all modes are disabled fail validation. +spec: + crds: + widget: + crdFile: ../crd.yaml + serve: + enabled: true + namespace: default + modes: + target: false + cr: false \ No newline at end of file diff --git a/pkg/katalog/validate_gateway_clusters.go b/pkg/katalog/validate_gateway_clusters.go index a640c3da..d5eae6ec 100644 --- a/pkg/katalog/validate_gateway_clusters.go +++ b/pkg/katalog/validate_gateway_clusters.go @@ -11,7 +11,7 @@ import ( // Template expressions are parse-checked only — name resolution is deferred to // apply time. func (k *Katalog) ValidateGatewayClusters() error { - if !k.IsGatewayEnabled() { + if !k.IsGatewayEnabled() || !k.Gateway.HasClusters() { return nil } @@ -25,6 +25,10 @@ func (k *Katalog) ValidateGatewayClusters() error { // validateClusterEntries checks that each gateway.clusters entry is structurally valid: // endpoint required, exactly one credential form declared, required fields present. func validateClusterEntries(g *orktypes.GatewayConfig) error { + if !g.HasClusters() { + return nil + } + for name, cfg := range g.Clusters.Entries { prefix := fmt.Sprintf("gateway.clusters.%s", name) diff --git a/pkg/katalog/validate_serve.go b/pkg/katalog/validate_serve.go index 2a116a55..688c37a5 100644 --- a/pkg/katalog/validate_serve.go +++ b/pkg/katalog/validate_serve.go @@ -15,60 +15,70 @@ import ( // This is the single entry point for Serve validation, keeping the main // pipeline clean and grouping all Serve checks together. func (k *Katalog) ValidateServe() error { - // 1. Validate serve.additionalFields (key syntax, enum, uniqueness) + // 1. Validate serve.modes: at least one mode enabled + if err := k.validateServeModes(); err != nil { + return err + } + + // 2. Validate serve.additionalFields (key syntax, enum, uniqueness) if err := k.validateServeAdditionalFields(); err != nil { return err } - // 2. Validate serve.fields path configurations (uniqueness, format, nested) + // 3. Validate serve.fields path configurations (uniqueness, format, nested) if err := k.validateServeFieldPaths(); err != nil { return err } - // 3. Validate serve field order: values don't collide + // 4. Validate serve field order: values don't collide if err := k.validateServeFieldOrder(); err != nil { return err } - // 4. Validate serve.namespace — required on namespaced+serve-enabled CRDs, + // 5. Validate serve.namespace — required on namespaced+serve-enabled CRDs, // rejected on cluster-scoped ones, incompatible with a pinned watch // scope when templated if err := k.validateServeNamespace(); err != nil { return err } - // 5. Validate Serve response config — payload template compilation and + // 6. Validate Serve response config — payload template compilation and // payload/exclude path conflicts (warnings, not errors) if err := k.validateServeResponseConfig(); err != nil { return err } - // 6. Validate Serve tokens and namespace restrictions per CRD + // 7. Validate Serve tokens and namespace restrictions per CRD if err := k.validateServeTokenRestrictions(); err != nil { return err } - // 7. Validate Serve targets per CRD; uniqueness across the katalog + // 8. Validate Serve targets per CRD; uniqueness across the katalog if err := k.validateServeTarget(); err != nil { return err } - // 8. Validate Serve response config (depends on CRD) + // 9. Validate Serve response config (depends on CRD) if err := k.validateServeResponseConfig(); err != nil { return err } - // 9. Validate serve.aliases — name format, routing uniqueness, token references + // 10. Validate serve.aliases — name format, routing uniqueness, token references if err := k.validateServeAliases(); err != nil { return err } - // 10. Validate serve.fields value/values — mutual exclusion, dot-notation keys, + // 11. Validate serve.fields value/values — mutual exclusion, dot-notation keys, // template compilation if err := k.validateServeFieldTranslation(); err != nil { return err } + // 12. Validate serve target field selectors + if err := k.validateServeFieldSelector(); err != nil { + return err + } + return nil } diff --git a/pkg/katalog/validate_serve_aliases.go b/pkg/katalog/validate_serve_aliases.go index 90faced5..9eba654a 100644 --- a/pkg/katalog/validate_serve_aliases.go +++ b/pkg/katalog/validate_serve_aliases.go @@ -34,7 +34,7 @@ func (k *Katalog) validateServeAliases() error { } for crdName, crd := range k.enabledCRDs { - if !crd.ServeEnabled() || crd.Serve == nil { + if !crd.ServeEnabled() || !crd.TargetModeEnabled() { continue } @@ -50,24 +50,11 @@ func (k *Katalog) validateServeAliases() error { } } if len(primaries) == 0 { - return fmt.Errorf(` -────────────────────────────────────────────── -%s serve.target map has no primary: true entry - CRD: %s - -Exactly one entry in the map must declare primary: true to identify the -primary target. Or use the scalar shorthand: target: -──────────────────────────────────────────────`, failureMark(), crdName) + return errServeTargetNoPrimary(crdName) } if len(primaries) > 1 { sort.Strings(primaries) - return fmt.Errorf(` -────────────────────────────────────────────── -%s serve.target map has multiple primary: true entries: %s - CRD: %s - -Exactly one entry may declare primary: true. -──────────────────────────────────────────────`, failureMark(), strings.Join(primaries, ", "), crdName) + return errServeTargetMultiplePrimaries(crdName, primaries) } } @@ -137,6 +124,39 @@ Exactly one entry may declare primary: true. return nil } +// ── error helpers ──────────────────────────────────────────────────────────── + +func errServeTargetNoPrimary(crd string) error { + return fmt.Errorf(` +────────────────────────────────────────────── +%s serve.target map has no primary: true entry + CRD: %s + +Exactly one entry in the map must declare primary: true to identify the +primary target. Or use the scalar shorthand: + serve: + target: myapp +──────────────────────────────────────────────`, failureMark(), crd) +} + +func errServeTargetMultiplePrimaries(crd string, primaries []string) error { + return fmt.Errorf(` +────────────────────────────────────────────── +%s serve.target map has multiple primary: true entries: %s + CRD: %s + +Exactly one entry may declare primary: true. + +Remove the extra primary: true flags: + serve: + target: + app: + primary: true # keep this one + preview: + primary: false # or omit the field +──────────────────────────────────────────────`, failureMark(), strings.Join(primaries, ", "), crd) +} + func errServeAliasInvalidName(crd, alias string) error { return fmt.Errorf(` ────────────────────────────────────────────── diff --git a/pkg/katalog/validate_serve_modes.go b/pkg/katalog/validate_serve_modes.go new file mode 100644 index 00000000..d71c4fb1 --- /dev/null +++ b/pkg/katalog/validate_serve_modes.go @@ -0,0 +1,73 @@ +package katalog + +import "fmt" + +// validateServeModes validates the serve.modes configuration for all enabled CRDs. +// +// It enforces two rules: +// 1. At least one mode (target or CR) must be enabled — a CRD cannot have both modes disabled. +// 2. If target mode is disabled, the CRD must not declare a target — a target is only meaningful +// when target mode is enabled. +// +// Both modes default to true when omitted, preserving backward compatibility with +// existing Katalogs. This validation ensures the API surface is coherent and +// that the platform team's intent is reflected in the configuration. +func (k *Katalog) validateServeModes() error { + for crdName, crd := range k.enabledCRDs { + if !crd.ServeEnabled() { + continue + } + + // Rule 1: At least one mode must be enabled + if !crd.TargetModeEnabled() && !crd.FullCRModeEnabled() { + return errServeModesBothDisabled(crdName) + } + + // Rule 2: Target mode disabled → target must not be set + if !crd.TargetModeEnabled() && !crd.Serve.Target.IsZero() { + return errServeTargetModeDisabledWithTarget(crdName) + + } + } + return nil +} + +// ── error helpers ──────────────────────────────────────────────────────────── + +func errServeModesBothDisabled(crd string) error { + return fmt.Errorf(` +────────────────────────────────────────────── +%s CRD %q: at least one of serve.modes.target or serve.modes.cr must be enabled + +Both modes are disabled — this CRD would be unreachable via the Gateway API. +Callers would have no way to create or update resources of this type. + +Enable at least one mode: + serve: + enabled: true + modes: + target: true # callers submit fields with a target + # or + cr: true # callers submit full Kubernetes CRs +──────────────────────────────────────────────`, failureMark(), crd) +} + +func errServeTargetModeDisabledWithTarget(crd string) error { + return fmt.Errorf(` +────────────────────────────────────────────── +%s CRD %q: serve.modes.target is false but serve.target is set + +Target mode is disabled, but this CRD still declares a target. +A target is only meaningful when target mode is enabled. + +Either: + • Enable target mode: + serve: + modes: + target: true + + • Or remove the target + serve: + target: "" # or omit the field entirely +──────────────────────────────────────────────`, failureMark(), crd) +} diff --git a/pkg/katalog/validate_serve_modes_test.go b/pkg/katalog/validate_serve_modes_test.go new file mode 100644 index 00000000..2f8ea247 --- /dev/null +++ b/pkg/katalog/validate_serve_modes_test.go @@ -0,0 +1,131 @@ +package katalog + +import ( + "strings" + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +func katalogWithServeAndTarget(target string) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": { + Serve: &orktypes.ServeConfig{ + Enabled: true, + Target: orktypes.ServeTargetValue{Shorthand: target}, + }, + }, + }, + } +} + +func katalogWithServeAndModes(target string, modes *orktypes.ServeModes) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": { + Serve: &orktypes.ServeConfig{ + Enabled: true, + Target: orktypes.ServeTargetValue{Shorthand: target}, + Modes: modes, + }, + }, + }, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +func TestValidateServeModes_NoServe(t *testing.T) { + k := &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": {Serve: nil}, + }, + } + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeModes_BothDefault(t *testing.T) { + // Both modes default to true when Modes is nil + k := katalogWithServeAndTarget("myapp") + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeModes_BothExplicitTrue(t *testing.T) { + k := katalogWithServeAndModes("myapp", &orktypes.ServeModes{ + Target: boolPtr(true), + CR: boolPtr(true), + }) + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeModes_TargetOnly(t *testing.T) { + k := katalogWithServeAndModes("myapp", &orktypes.ServeModes{ + Target: boolPtr(true), + CR: boolPtr(false), + }) + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeModes_CROnly(t *testing.T) { + k := katalogWithServeAndModes("", &orktypes.ServeModes{ + Target: boolPtr(false), + CR: boolPtr(true), + }) + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeModes_BothFalse(t *testing.T) { + k := katalogWithServeAndModes("", &orktypes.ServeModes{ + Target: boolPtr(false), + CR: boolPtr(false), + }) + err := k.validateServeModes() + if err == nil { + t.Fatal("expected error when both modes are disabled") + } + if !strings.Contains(err.Error(), "at least one of serve.modes.target or serve.modes.cr must be enabled") { + t.Errorf("error should mention both modes disabled, got: %v", err) + } +} + +func TestValidateServeModes_TargetDisabledButTargetSet(t *testing.T) { + k := katalogWithServeAndModes("myapp", &orktypes.ServeModes{ + Target: boolPtr(false), + CR: boolPtr(true), + }) + err := k.validateServeModes() + if err == nil { + t.Fatal("expected error when target mode is disabled but target is set") + } + if !strings.Contains(err.Error(), "serve.modes.target is false but serve.target is set") { + t.Errorf("error should mention target disabled but target set, got: %v", err) + } +} + +func TestValidateServeModes_ModesNil_WithTarget(t *testing.T) { + k := katalogWithServeAndModes("myapp", nil) + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeModes_ModesEmpty_WithTarget(t *testing.T) { + // Empty Modes struct means both fields are nil → defaults to true + k := katalogWithServeAndModes("myapp", &orktypes.ServeModes{}) + if err := k.validateServeModes(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/katalog/validate_serve_response_config.go b/pkg/katalog/validate_serve_response_config.go index ca5cd5d7..10c752aa 100644 --- a/pkg/katalog/validate_serve_response_config.go +++ b/pkg/katalog/validate_serve_response_config.go @@ -44,7 +44,7 @@ func (k *Katalog) validateServeResponseConfig() error { // Still check if it references any payload keys in the template itself for key := range payloadKeys { if slices.Contains(excludePaths, key) { - warning := fmt.Sprintf("⚠️ CRD %q: exclude template references payload key %q — potential conflict\n", crdName, key) + warning := fmt.Sprintf("%s CRD %q: exclude template references payload key %q — potential conflict\n", warningMark(), crdName, key) crd.Warnings.AddWarning(warning) } } @@ -65,6 +65,7 @@ func (k *Katalog) validateServeResponseConfig() error { crd.Warnings.AddWarning(warning) } } + k.enabledCRDs[crdName] = crd } return nil } diff --git a/pkg/katalog/validate_serve_target_field_selectors.go b/pkg/katalog/validate_serve_target_field_selectors.go new file mode 100644 index 00000000..cfe22e96 --- /dev/null +++ b/pkg/katalog/validate_serve_target_field_selectors.go @@ -0,0 +1,144 @@ +package katalog + +import ( + "fmt" + "strings" + + orktypes "github.com/orkspace/orkestra/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" +) + +// validateServeFieldSelector validates serve.target fieldSelector configuration. +// +// It enforces: +// 1. Each target has at most 3 field selectors — keep it simple, avoid overlapping. +// 2. Field selectors are unique across targets — no two targets can match the same CR. +// 3. Field selectors must exist in the CRD schema — avoid silent misrouting. (TODO) +// 4. If CR mode is disabled for a target, it must have at least one fieldSelector +// — otherwise the target is unreachable via full CR mode. +// 5. Field selectors must be valid dot-notation paths (e.g., "spec.mealPlan"). +func (k *Katalog) validateServeFieldSelector() error { + for crdName, crd := range k.enabledCRDs { + if !crd.ServeEnabled() { + continue + } + + // Skip if no targets with fieldSelector + if !crd.HasServeTargetFieldSelector() { + continue + } + + // Track all selectors for uniqueness (path:value combination) + allSelectors := make(map[string]string) // "path:value" -> target name + + for targetName, cfg := range crd.Serve.Target.Entries { + if !cfg.HasServeTargetFieldSelector() { + continue + } + + selector := cfg.FieldSelector + maxFields := orktypes.MaxServeTargetFieldSelector + + // 1. Max 3 field selectors per target + if len(selector) > maxFields { + return fmt.Errorf( + "%s CRD %q: target %q has %d field selectors — maximum is %d", + failureMark(), crdName, targetName, len(selector), maxFields, + ) + } + + // 2. Validate each field selector format and value + for path, value := range selector { + // Validate that there is no template in path or value + pathIsTemplate := isTemplate(path) + valueIsTemplate := isTemplate(value) + + if pathIsTemplate && valueIsTemplate { + return fmt.Errorf( + "%s CRD %q: target %q has invalid field selector %q: contains template syntax in path and value", + failureMark(), crdName, targetName, path, + ) + } + + if pathIsTemplate { + return fmt.Errorf("%s CRD %q: target %q has invalid field selector path %q: contains template syntax in path", + failureMark(), crdName, targetName, path) + } + + if valueIsTemplate { + return fmt.Errorf( + "%s CRD %q: target %q has invalid field selector value %q: contains template syntax in value", + failureMark(), crdName, targetName, value, + ) + } + + // Validate dot-notation format + if err := validateFieldSelectorPath(path); err != nil { + return fmt.Errorf( + "%s CRD %q: target %q has invalid field selector path %q: %w", + failureMark(), crdName, targetName, path, err, + ) + } + + // Validate value is not empty + if strings.TrimSpace(value) == "" { + return fmt.Errorf( + "%s CRD %q: target %q has empty field selector value for path %q", + failureMark(), crdName, targetName, path, + ) + } + + // TODO: Validate path exists in CRD schema (best-effort) + + // Check uniqueness across targets (path:value must be unique) + key := path + ":" + value + if existingTarget, ok := allSelectors[key]; ok { + return fmt.Errorf( + "%s CRD %q: field selector %q=%q is used by both targets %q and %q — "+ + "field selectors must be unique across targets", + failureMark(), crdName, path, value, existingTarget, targetName, + ) + } + allSelectors[key] = targetName + } + } + + // 3. Warn if a target has fieldSelector but CR mode is disabled + for targetName, cfg := range crd.Serve.Target.Entries { + if cfg.HasServeTargetFieldSelector() && !crd.FullCRModeEnabledFor(targetName) { + crd.Warnings.AddWarning(fmt.Sprintf( + "%s CRD %q: target %q has fieldSelector but CR mode is disabled — "+ + "fieldSelector will have no effect", + warningMark(), crdName, targetName, + )) + } + } + + // Store the modified CRD back in the map + k.enabledCRDs[crdName] = crd + } + return nil +} + +// validateFieldSelectorPath checks that a field selector path is a valid dot-notation path. +func validateFieldSelectorPath(path string) error { + if path == "" { + return fmt.Errorf("field selector path cannot be empty") + } + + if strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") { + return fmt.Errorf("field selector path cannot start or end with a dot. Usage example: 'spec.mealPlan'") + } + + parts := strings.Split(path, ".") + for _, part := range parts { + if part == "" { + return fmt.Errorf("field selector path contains empty segment (double dot)") + } + if errs := validation.IsQualifiedName(part); len(errs) > 0 { + return fmt.Errorf("field selector path segment %q is not a valid Kubernetes name: %s", part, strings.Join(errs, "; ")) + } + } + + return nil +} diff --git a/pkg/katalog/validate_serve_target_field_selectors_test.go b/pkg/katalog/validate_serve_target_field_selectors_test.go new file mode 100644 index 00000000..e1c6b10a --- /dev/null +++ b/pkg/katalog/validate_serve_target_field_selectors_test.go @@ -0,0 +1,405 @@ +package katalog + +import ( + "strings" + "testing" + + orktypes "github.com/orkspace/orkestra/pkg/types" +) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +func katalogWithServeAndFieldSelector(target string, selector map[string]string) *Katalog { + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": { + Serve: &orktypes.ServeConfig{ + Enabled: true, + Target: orktypes.ServeTargetValue{ + Entries: map[string]*orktypes.ServeTargetConfig{ + target: { + FieldSelector: selector, + }, + }, + }, + }, + }, + }, + } +} + +func katalogWithServeTargetsAndFieldSelectors(targets map[string]map[string]string) *Katalog { + entries := make(map[string]*orktypes.ServeTargetConfig) + for name, selector := range targets { + entries[name] = &orktypes.ServeTargetConfig{ + FieldSelector: selector, + } + } + return &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": { + Serve: &orktypes.ServeConfig{ + Enabled: true, + Target: orktypes.ServeTargetValue{ + Entries: entries, + }, + }, + }, + }, + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +func TestValidateServeFieldSelector_NoFieldSelector(t *testing.T) { + k := katalogWithServeAndTarget("myapp") + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeFieldSelector_SingleTargetValid(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "dinner", + }) + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeFieldSelector_MultipleFieldsValid(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "dinner", + "spec.kitchenConfig": "standard", + }) + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeFieldSelector_MaxThreeFields(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.field1": "value1", + "spec.field2": "value2", + "spec.field3": "value3", + }) + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeFieldSelector_MoreThanThreeFields(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.field1": "value1", + "spec.field2": "value2", + "spec.field3": "value3", + "spec.field4": "value4", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when more than 3 field selectors") + } + if !strings.Contains(err.Error(), "maximum is 3") { + t.Errorf("error should mention max 3 fields, got: %v", err) + } +} + +func TestValidateServeFieldSelector_DuplicateSelectorsAcrossTargets(t *testing.T) { + k := katalogWithServeTargetsAndFieldSelectors(map[string]map[string]string{ + "kitchen": {"spec.mealPlan": "dinner"}, + "payments": {"spec.mealPlan": "dinner"}, // duplicate + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when field selectors are duplicated across targets") + } + if !strings.Contains(err.Error(), "field selector") || !strings.Contains(err.Error(), "used by both targets") { + t.Errorf("error should mention duplicate field selector, got: %v", err) + } +} + +func TestValidateServeFieldSelector_UniqueSelectors(t *testing.T) { + k := katalogWithServeTargetsAndFieldSelectors(map[string]map[string]string{ + "kitchen": {"spec.mealPlan": "dinner"}, + "payments": {"spec.paymentMethod": "card"}, + }) + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeFieldSelector_InvalidPathFormat(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + ".invalid": "value", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error for invalid field selector path format") + } + if !strings.Contains(err.Error(), "cannot start or end with a dot") { + t.Errorf("error should mention invalid format, got: %v", err) + } +} + +func TestValidateServeFieldSelector_EmptyPath(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "": "value", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error for empty field selector path") + } + if !strings.Contains(err.Error(), "field selector path cannot be empty") { + t.Errorf("error should mention empty path, got: %v", err) + } +} + +func TestValidateServeFieldSelector_EmptyValue(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error for empty field selector value") + } + if !strings.Contains(err.Error(), "empty field selector value") { + t.Errorf("error should mention empty value, got: %v", err) + } +} + +func TestValidateServeFieldSelector_CRModeDisabledNoSelector(t *testing.T) { + entries := map[string]*orktypes.ServeTargetConfig{ + "kitchen": { + Modes: &orktypes.ServeModes{ + CR: boolPtr(false), + }, + FieldSelector: nil, + }, + } + k := &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": { + Serve: &orktypes.ServeConfig{ + Enabled: true, + Target: orktypes.ServeTargetValue{ + Entries: entries, + }, + }, + }, + }, + } + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServeFieldSelector_CRModeDisabledWithSelector(t *testing.T) { + entries := map[string]*orktypes.ServeTargetConfig{ + "kitchen": { + Modes: &orktypes.ServeModes{ + CR: boolPtr(false), + }, + FieldSelector: map[string]string{ + "spec.mealPlan": "dinner", + }, + }, + } + k := &Katalog{ + enabledCRDs: map[string]orktypes.CRDEntry{ + "myresource": { + Serve: &orktypes.ServeConfig{ + Enabled: true, + Target: orktypes.ServeTargetValue{ + Entries: entries, + }, + }, + }, + }, + } + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Check that a warning was added + entry := k.enabledCRDs["myresource"] + if !entry.Warnings.HasWarnings() { + t.Fatal("expected warning for CR mode disabled with no field selector") + } +} + +func TestValidateServeFieldSelector_TemplateInPath(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.{{.env}}.mealPlan": "dinner", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when field selector path contains template syntax") + } + if !strings.Contains(err.Error(), "template syntax in path") { + t.Errorf("error should mention template syntax in path, got: %v", err) + } +} + +func TestValidateServeFieldSelector_TemplateInValue(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "{{.mealType}}", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when field selector value contains template syntax") + } + if !strings.Contains(err.Error(), "template syntax in value") { + t.Errorf("error should mention template syntax in value, got: %v", err) + } +} + +func TestValidateServeFieldSelector_TemplateInBothPathAndValue(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.{{.env}}.mealPlan": "{{.mealType}}", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when both path and value contain template syntax") + } + // Should mention both + if !strings.Contains(err.Error(), "contains template syntax in path and value") { + t.Errorf("error should mention contains template syntax in path and value, got: %v", err) + } +} + +func TestValidateServeFieldSelector_TemplateMultipleSelectors(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "dinner", + "spec.{{.env}}.name": "production", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when one selector has template syntax") + } + if !strings.Contains(err.Error(), "template syntax in path") { + t.Errorf("error should mention template syntax, got: %v", err) + } + if !strings.Contains(err.Error(), "spec.{{.env}}.name") { + t.Errorf("error should mention the problematic path, got: %v", err) + } +} + +func TestValidateServeFieldSelector_TemplateInValueWithMultipleSelectors(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "{{.meal}}", + "spec.kitchenConfig": "standard", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when one selector value has template syntax") + } + if !strings.Contains(err.Error(), "template syntax in value") { + t.Errorf("error should mention template syntax in value, got: %v", err) + } + if !strings.Contains(err.Error(), "{{.meal}}") { + t.Errorf("error should mention the problematic value, got: %v", err) + } +} + +func TestValidateServeFieldSelector_TemplateWithBraces(t *testing.T) { + // Test different template syntax variations + testCases := []struct { + name string + path string + value string + }{ + { + name: "double curly braces", + path: "spec.{{.env}}.mealPlan", + value: "{{.mealType}}", + }, + { + name: "triple curly braces", + path: "spec.{{{.env}}}.mealPlan", + value: "{{{.mealType}}}", + }, + { + name: "nested template", + path: "spec.{{.env.{{.region}}}}.mealPlan", + value: "{{.mealType.{{.time}}}}", + }, + { + name: "template with pipe", + path: "spec.{{.env | upper}}.mealPlan", + value: "{{.mealType | lower}}", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + tc.path: tc.value, + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error for template syntax") + } + if !strings.Contains(err.Error(), "template syntax") { + t.Errorf("error should mention template syntax, got: %v", err) + } + }) + } +} + +func TestValidateServeFieldSelector_NoTemplateInValidSelectors(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.mealPlan": "dinner", + "spec.kitchenConfig": "standard", + "metadata.namespace": "default", + }) + if err := k.validateServeFieldSelector(); err != nil { + t.Fatalf("unexpected error for valid selectors: %v", err) + } +} + +func TestValidateServeFieldSelector_TemplateInPathMultipleBraces(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec.{{.env}}.mealPlan.{{.time}}": "dinner", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Fatal("expected error when path has multiple template placeholders") + } + if !strings.Contains(err.Error(), "template syntax in path") { + t.Errorf("error should mention template syntax, got: %v", err) + } +} + +// Test template in path with different variable patterns +func TestValidateServeFieldSelector_TemplateVariablePatterns(t *testing.T) { + testCases := []struct { + name string + template string + }{ + {"with dot", "{{.field}}"}, + {"without dot", "{{field}}"}, + {"with nested", "{{.field.subfield}}"}, + {"with index", "{{index .field 0}}"}, + {"with range", "{{range .items}}{{.}}{{end}}"}, + {"with if", "{{if .condition}}value{{end}}"}, + {"with and", "{{and .a .b}}"}, + {"with or", "{{or .a .b}}"}, + {"with not", "{{not .a}}"}, + {"with eq", "{{eq .a .b}}"}, + {"with ne", "{{ne .a .b}}"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k := katalogWithServeAndFieldSelector("kitchen", map[string]string{ + "spec." + tc.template: "value", + }) + err := k.validateServeFieldSelector() + if err == nil { + t.Errorf("expected error for template syntax: %s", tc.template) + } + if !strings.Contains(err.Error(), "template syntax") { + t.Errorf("error should mention template syntax, got: %v", err) + } + }) + } +} diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 957c3ca7..7bdc6922 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -185,4 +185,14 @@ const ( // enabling intent-level gates that fire on the caller's vocabulary before any // field translation has occurred. AnnotationServeIntent = "orkestra.orkspace.io/serve-intent" + // AnnotationServeSelectorTarget records the target that was matched by field selector. + // Set by the gateway when a full CR is routed via fieldSelector. + // Example: "kitchen" + AnnotationServeSelectorTarget = "orkestra.orkspace.io/serve-selector-target" + + // AnnotationServeSelector records the field selector that caused routing. + // Set by the gateway when a full CR is routed via fieldSelector. + // Value is a JSON-encoded map of field paths to values. + // Example: '{"spec.mealPlan":"dinner","spec.kitchenConfig":"standard"}' + AnnotationServeSelector = "orkestra.orkspace.io/serve-selector" ) diff --git a/pkg/types/types_crd_entry.go b/pkg/types/types_crd_entry.go index 2744fe37..d8a21ff5 100644 --- a/pkg/types/types_crd_entry.go +++ b/pkg/types/types_crd_entry.go @@ -309,6 +309,143 @@ func (c *CRDEntry) HasServeName() bool { return c.Serve != nil && c.Serve.Name != "" } +// TargetModeEnabled reports whether target mode is enabled for this CRD. +// Defaults to true when serve.modes is omitted or when serve.modes.target is nil. +func (c *CRDEntry) TargetModeEnabled() bool { + if !c.ServeEnabled() { + return false + } + if c.Serve.Modes == nil || c.Serve.Modes.Target == nil { + return true + } + return *c.Serve.Modes.Target +} + +// FullCRModeEnabled reports whether full CR mode is enabled for this CRD. +// Defaults to true when serve.modes is omitted or when serve.modes.cr is nil. +func (c *CRDEntry) FullCRModeEnabled() bool { + if !c.ServeEnabled() { + return false + } + if c.Serve.Modes == nil || c.Serve.Modes.CR == nil { + return true + } + return *c.Serve.Modes.CR +} + +// HasServeModes reports whether serve.modes is explicitly configured. +func (c *CRDEntry) HasServeModes() bool { + return c.ServeEnabled() && c.Serve.Modes != nil +} + +// effectiveServeModes returns the effective modes for a target, +// merging target-level and CRD-level settings. +// Resolution order: +// 1. Target-level (serve.targets[].modes) +// 2. CRD-level (serve.modes) +// 3. Default (both true) +func (c *CRDEntry) effectiveServeModes(target string) *ServeModes { + result := &ServeModes{ + Target: boolPtr(true), + CR: boolPtr(true), + } + + if !c.ServeEnabled() { + return result + } + + // 1. Start with CRD-level + if c.Serve.Modes != nil { + if c.Serve.Modes.Target != nil { + result.Target = c.Serve.Modes.Target + } + if c.Serve.Modes.CR != nil { + result.CR = c.Serve.Modes.CR + } + } + + // 2. Override with target-level (if set) + if c.Serve.Target.Entries != nil { + if cfg, ok := c.Serve.Target.Entries[target]; ok && cfg.Modes != nil { + if cfg.Modes.Target != nil { + result.Target = cfg.Modes.Target + } + if cfg.Modes.CR != nil { + result.CR = cfg.Modes.CR + } + } + } + + return result +} + +// TargetModeEnabledFor returns whether target mode is enabled for the given target. +func (c *CRDEntry) TargetModeEnabledFor(target string) bool { + if !c.ServeEnabled() { + return false + } + return *c.effectiveServeModes(target).Target +} + +// FullCRModeEnabledFor returns whether CR mode is enabled for the given target. +func (c *CRDEntry) FullCRModeEnabledFor(target string) bool { + if !c.ServeEnabled() { + return false + } + return *c.effectiveServeModes(target).CR +} + +func boolPtr(b bool) *bool { return &b } + +// effectiveServeApplyOverrides returns the effective override +// for a target, merging target-level and CRD-level settings. +func (c *CRDEntry) effectiveServeApplyOverrides(target string) *ServeApplyOverrides { + result := &ServeApplyOverrides{ + TargetConflict: boolPtr(true), // default: allow + ResourceConflict: boolPtr(true), // default: allow + } + + // 1. Start with CRD-level + if c.Serve.Apply != nil && c.Serve.Apply.Overrides != nil { + if c.Serve.Apply.Overrides.TargetConflict != nil { + result.TargetConflict = c.Serve.Apply.Overrides.TargetConflict + } + if c.Serve.Apply.Overrides.ResourceConflict != nil { + result.ResourceConflict = c.Serve.Apply.Overrides.ResourceConflict + } + } + + // 2. Override with target-level (if set) + if c.Serve.Target.Entries != nil { + if cfg, ok := c.Serve.Target.Entries[target]; ok && cfg.Apply != nil && cfg.Apply.Overrides != nil { + if cfg.Apply.Overrides.TargetConflict != nil { + result.TargetConflict = cfg.Apply.Overrides.TargetConflict + } + if cfg.Apply.Overrides.ResourceConflict != nil { + result.ResourceConflict = cfg.Apply.Overrides.ResourceConflict + } + } + } + + return result +} + +func (c *CRDEntry) ServeForceConflictEnabledFor(target string) bool { + if !c.ServeEnabled() { + return false + } + override := c.effectiveServeApplyOverrides(target) + return *override.ResourceConflict +} + +func (c *CRDEntry) ServeTargetOverrideEnabledFor(target string) bool { + if !c.ServeEnabled() { + return false + } + override := c.effectiveServeApplyOverrides(target) + return *override.TargetConflict +} + // HasServeFields reports whether this CRD declares any serve.fields. func (c *CRDEntry) HasServeFields() bool { return c.ServeEnabled() && c.Serve.Fields != nil && len(c.Serve.Fields) > 0 diff --git a/pkg/types/types_serve.go b/pkg/types/types_serve.go index b63c5afc..4fce5bbd 100644 --- a/pkg/types/types_serve.go +++ b/pkg/types/types_serve.go @@ -24,6 +24,10 @@ type ServeConfig struct { // Default: false. Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + // Modes controls which apply modes are allowed for this CRD. + // Both default to true for backward compatibility. + Modes *ServeModes `yaml:"modes,omitempty" json:"modes,omitempty"` + // Include is a path (relative to the katalog file) to a YAML file with a // "fields:" map and/or an "additionalFields:" block (same shape as the // inline equivalents below). Expanded at load time — the result is merged @@ -66,12 +70,27 @@ type ServeConfig struct { // Defaults to the lowercased kind when not set. Target ServeTargetValue `yaml:"target,omitempty" json:"target,omitempty"` - // ForceConflict, when true, sets Force: true on every server-side apply - // for this CRD — the gateway takes ownership of any conflicting fields - // rather than surfacing a conflict error. Equivalent to helm --force-conflict. - // Can be overridden per-request with ?overwrite=true regardless of this setting. - // Default: false. - ForceConflict bool `yaml:"forceConflict,omitempty" json:"forceConflict,omitempty"` + // Apply configures apply-time behaviour for all targets (fallback). + Apply *ServeApplyConfig `yaml:"apply,omitempty" json:"apply,omitempty"` + + // MatchFields is a list of dot-notation field paths that link a full CR + // to this target. When a CR contains all the specified fields, it is + // automatically routed to this target. + // + // Used to enable target-level controls (tokens, response config, permissions) + // for CRs submitted in full CR mode. Without matchFields, full CR mode + // bypasses target-level controls. + // + // Each target must have a unique match list. ork validate enforces this. + // At least one match field is recommended when cr mode is disabled. + // + // Maximum: 3 fields + // + // Example: + // matchFields: + // - spec.mealPlan + // - spec.kitchenConfig + MatchFields []string `yaml:"matchFields,omitempty" json:"matchFields,omitempty"` // Name is a template expression the Gateway API resolves server-side to // decide the CR's metadata.name — e.g. '{{ repoSlug .spec.repository }}'. @@ -156,6 +175,50 @@ func (s *ServeConfig) ClusterAllowed(name string) bool { return false } +type ServeModes struct { + // Target mode — submit fields with a target identifier. + // Default: true. + Target *bool `yaml:"target,omitempty" json:"target,omitempty"` + + // CR mode — submit a full Kubernetes CR (apiVersion + kind). + // Default: true. + CR *bool `yaml:"cr,omitempty" json:"cr,omitempty"` +} + +// TargetModeEnabled returns true if target mode is allowed. +// Default: true +func (s *ServeModes) TargetModeEnabled() bool { + if s == nil || s.Target == nil { + return true // default + } + return *s.Target +} + +// CRModeEnabled returns true if CR mode is allowed. +// Default: true +func (s *ServeModes) CRModeEnabled() bool { + if s == nil || s.CR == nil { + return true // default + } + return *s.CR +} + +// TargetModeEnabled returns true if target mode is allowed. +func (s *ServeConfig) TargetModeEnabled() bool { + if s == nil || s.Modes == nil || s.Modes.Target == nil { + return true // default + } + return *s.Modes.Target +} + +// FullCRModeEnabled returns true if full CR mode is allowed. +func (s *ServeConfig) FullCRModeEnabled() bool { + if s == nil || s.Modes == nil || s.Modes.CR == nil { + return true // default + } + return *s.Modes.CR +} + // ServeAliasConfigSettings is the config block on a target entry. // Mirrors ServeConfigSettings but kept separate so alias-only settings can be // added in future without touching the CRD-level type. diff --git a/pkg/types/types_serve_aliases_test.go b/pkg/types/types_serve_aliases_test.go index 634f048f..e6a8ddef 100644 --- a/pkg/types/types_serve_aliases_test.go +++ b/pkg/types/types_serve_aliases_test.go @@ -82,8 +82,11 @@ func TestServeTargetValue_MarshalYAML_ShorthandRoundtrip(t *testing.T) { } } +// ─── ServeTargetValue JSON tests ──────────────────────────────────────────── + func TestServeTargetValue_UnmarshalJSON_String(t *testing.T) { var tv types.ServeTargetValue + // The custom unmarshaler accepts a string in JSON if err := json.Unmarshal([]byte(`"smartapp"`), &tv); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -102,6 +105,31 @@ func TestServeTargetValue_UnmarshalJSON_Object(t *testing.T) { } } +func TestServeTargetValue_MarshalJSON_ShorthandRoundtrip(t *testing.T) { + // Single primary entry with no extra config → round-trips to scalar. + tv := types.ServeTargetValue{ + Entries: map[string]*types.ServeTargetConfig{ + "myapp": {Primary: true}, + }, + } + b, err := json.Marshal(struct { + Target types.ServeTargetValue `json:"target"` + }{Target: tv}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back struct { + Target types.ServeTargetValue `json:"target"` + } + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Should round-trip to scalar + if back.Target.Shorthand != "myapp" && back.Target.Entries["myapp"] == nil { + t.Errorf("round-trip lost myapp: %+v", back.Target) + } +} + // ── ServeTargetConfig ──────────────────────────────────────────────────────── func TestServeTargetConfig_IsEnabled_Nil(t *testing.T) { @@ -165,7 +193,7 @@ func TestServeTargetConfig_ResponseConfig(t *testing.T) { } } -// ── CRDEntry.ServeTokensFor ─────────────────────────────────────────────────── +// ─── CRDEntry.ServeTokensFor ─────────────────────────────────────────────────── func crdWithTargetMap() *types.CRDEntry { return &types.CRDEntry{ diff --git a/pkg/types/types_serve_apply_config.go b/pkg/types/types_serve_apply_config.go new file mode 100644 index 00000000..04546640 --- /dev/null +++ b/pkg/types/types_serve_apply_config.go @@ -0,0 +1,217 @@ +package types + +import ( + "github.com/orkspace/orkestra/pkg/utils" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + // MaxServeTargetFieldSelector is the maximum number of field selectors per target. + MaxServeTargetFieldSelector = 3 +) + +// ServeApplyOverrides configures override behaviours for apply operations. +type ServeApplyOverrides struct { + // TargetConflict, when true, allows callers to change the target/alias + // of an existing CR via the ?override=true query parameter. + // Default: false — target/alias is immutable after creation. + TargetConflict *bool `yaml:"targetConflict,omitempty" json:"targetConflict,omitempty"` + + // ResourceConflict, when true, sets Force: true on every server-side apply + // for this CRD — the gateway takes ownership of any conflicting fields + // rather than surfacing a conflict error. Equivalent to helm --force-conflict. + // Can be overridden per-request with ?overwrite=true regardless of this setting. + // Default: false. + ResourceConflict *bool `yaml:"resourceConflict,omitempty" json:"resourceConflict,omitempty"` +} + +// ServeApplyConfig configures apply-time behaviour for a CRD or target. +type ServeApplyConfig struct { + // Overrides contains override settings for apply operations. + Overrides *ServeApplyOverrides `yaml:"overrides,omitempty" json:"overrides,omitempty"` +} + +// ─── HasOverride methods ───────────────────────────────────────────────────── + +// HasOverride reports whether the ServeConfig has any override fields set. +// Used to avoid unnecessary config blocks in the Katalog. +func (s *ServeConfig) HasOverride() bool { + if s == nil || s.Apply == nil || s.Apply.Overrides == nil { + return false + } + return s.Apply.Overrides.TargetConflict != nil || s.Apply.Overrides.ResourceConflict != nil +} + +// HasOverride reports whether the ServeTargetConfig has any override fields set. +func (t *ServeTargetConfig) HasOverride() bool { + if t == nil || t.Apply == nil || t.Apply.Overrides == nil { + return false + } + return t.Apply.Overrides.TargetConflict != nil || t.Apply.Overrides.ResourceConflict != nil +} + +// HasOverride reports whether the CRDEntry has any override fields set +// at the CRD level or on any target. +func (c *CRDEntry) HasOverride() bool { + if !c.ServeEnabled() { + return false + } + + // Check CRD-level override + if c.Serve.HasOverride() { + return true + } + + // Check any target-level override + if c.Serve.Target.Entries != nil { + for _, cfg := range c.Serve.Target.Entries { + if cfg.HasOverride() { + return true + } + } + } + + return false +} + +// HasTargetOverride reports whether the CRDEntry has any targetConflict set +// at the CRD level or on any target. +func (c *CRDEntry) HasTargetOverride() bool { + if !c.ServeEnabled() { + return false + } + + if c.Serve.Apply != nil && c.Serve.Apply.Overrides != nil && c.Serve.Apply.Overrides.TargetConflict != nil { + return true + } + + if c.Serve.Target.Entries != nil { + for _, cfg := range c.Serve.Target.Entries { + if cfg.Apply != nil && cfg.Apply.Overrides != nil && cfg.Apply.Overrides.TargetConflict != nil { + return true + } + } + } + + return false +} + +// HasResourceConflict reports whether the CRDEntry has any resourceConflict set +// at the CRD level or on any target. +func (c *CRDEntry) HasResourceConflict() bool { + if !c.ServeEnabled() { + return false + } + + if c.Serve.Apply != nil && c.Serve.Apply.Overrides != nil && c.Serve.Apply.Overrides.ResourceConflict != nil { + return true + } + + if c.Serve.Target.Entries != nil { + for _, cfg := range c.Serve.Target.Entries { + if cfg.Apply != nil && cfg.Apply.Overrides != nil && cfg.Apply.Overrides.ResourceConflict != nil { + return true + } + } + } + + return false +} + +// EffectiveServeTargetForCR returns the effective target for a given CR. +// Resolution order: +// 1. If the CR matches a target via fieldSelector, use that target. +// 2. Otherwise, fall back to the primary target (serve.target). +// 3. Returns empty string if no target is found. +// +// This is the single source of truth for target resolution across the gateway. +func (c *CRDEntry) EffectiveServeTargetForCR(obj *unstructured.Unstructured) string { + if !c.ServeEnabled() { + return "" + } + + // 1. Try fieldSelector + if target := c.ServeTargetForFieldSelector(obj.Object); target != "" { + return target + } + + // 2. Fall back to primary target + return c.ServeTarget() +} + +// EffectiveServeTargetForMap is the same as EffectiveServeTargetForCR but accepts +// a map[string]interface{} instead of an Unstructured. +func (c *CRDEntry) EffectiveServeTargetForMap(obj map[string]interface{}) string { + if !c.ServeEnabled() { + return "" + } + + if target := c.ServeTargetForFieldSelector(obj); target != "" { + return target + } + + return c.ServeTarget() +} + +// ─── FieldSelector methods ───────────────────────────────────────────────────── + +// HasServeTargetFieldSelector reports whether the ServeTargetConfig has any fieldSelector set. +func (t *ServeTargetConfig) HasServeTargetFieldSelector() bool { + if t == nil { + return false + } + return len(t.FieldSelector) > 0 +} + +// Len returns the number of field selectors. +func (t *ServeTargetConfig) Len() int { + if t == nil { + return 0 + } + return len(t.FieldSelector) +} + +// HasServeTargetFieldSelector reports whether the CRDEntry has any fieldSelector set on any target. +func (c *CRDEntry) HasServeTargetFieldSelector() bool { + if !c.ServeEnabled() { + return false + } + if c.Serve.Target.Entries == nil { + return false + } + for _, cfg := range c.Serve.Target.Entries { + if cfg.HasServeTargetFieldSelector() { + return true + } + } + return false +} + +// FieldSelectorForTarget returns the fieldSelector for a specific target. +func (c *CRDEntry) FieldSelectorForTarget(target string) map[string]string { + if !c.ServeEnabled() || c.Serve.Target.Entries == nil { + return nil + } + if cfg, ok := c.Serve.Target.Entries[target]; ok { + return cfg.FieldSelector + } + return nil +} + +// ServeTargetForFieldSelector returns the target name that matches the given fields. +// Returns empty string if no target matches. +func (c *CRDEntry) ServeTargetForFieldSelector(obj map[string]interface{}) string { + if !c.ServeEnabled() || c.Serve.Target.Entries == nil { + return "" + } + + for targetName, cfg := range c.Serve.Target.Entries { + if !cfg.HasServeTargetFieldSelector() { + continue + } + if utils.MatchesAllServeTargetFieldSelectors(obj, cfg.FieldSelector) { + return targetName + } + } + return "" +} diff --git a/pkg/types/types_serve_target.go b/pkg/types/types_serve_target.go index d49d13ff..a709f18b 100644 --- a/pkg/types/types_serve_target.go +++ b/pkg/types/types_serve_target.go @@ -9,10 +9,17 @@ import "strings" // 1. The primary entry's map key in serve.target (map form). // 2. serve.target shorthand string (before load-time expansion). // 3. Lowercased kind — "App" → "app", "DatabaseCluster" → "databasecluster". +// +// Returns an empty string when target mode is disabled (serve.modes.target: false). +// This ensures that when target mode is turned off, the CRD has no target +// regardless of what the configuration declares. func (c *CRDEntry) ServeTarget() string { if c.Serve == nil { return strings.ToLower(c.APITypes.Kind) } + if !c.TargetModeEnabled() { + return "" + } // Shorthand (before scalar expansion at load time). if c.Serve.Target.Shorthand != "" { return c.Serve.Target.Shorthand @@ -27,13 +34,18 @@ func (c *CRDEntry) ServeTarget() string { } // HasServeTarget reports whether this CRD can be addressed by its primary target. -// Returns false when serve is disabled, kind is absent, or the primary entry's -// enabled flag is false. A disabled primary means the CRD is only reachable -// via its alias entries. +// Returns false when: +// - serve is disabled +// - kind is absent +// - target mode is disabled (serve.modes.target: false) +// - the primary entry's enabled flag is false +// +// A disabled primary means the CRD is only reachable via its alias entries. func (c *CRDEntry) HasServeTarget() bool { - if !c.ServeEnabled() || c.APITypes.Kind == "" { + if !c.ServeEnabled() || c.APITypes.Kind == "" || !c.TargetModeEnabled() { return false } + if c.Serve.Target.IsZero() { return true // no target config at all → default enabled } @@ -52,10 +64,21 @@ func (c *CRDEntry) ServeTargetOrEmpty() string { return c.ServeTarget() } +// FirstServeTargetEntry returns the first serve entry in the map +func (c *CRDEntry) FirstServeTargetEntry() *ServeTargetConfig { + if c.Serve == nil || !c.TargetModeEnabled() { + return nil + } + for _, cfg := range c.Serve.Target.Entries { + return cfg + } + return nil +} + // PrimaryTarget returns the TargetConfig whose Primary flag is true, or nil when // no primary entry is declared (scalar shorthand, or no target configured). func (c *CRDEntry) PrimaryTarget() *ServeTargetConfig { - if c.Serve == nil { + if c.Serve == nil || !c.TargetModeEnabled() { return nil } for _, cfg := range c.Serve.Target.Entries { @@ -70,7 +93,7 @@ func (c *CRDEntry) PrimaryTarget() *ServeTargetConfig { // Intended for CLI display only — callers that resolve requests should use // LookupTarget, which filters disabled entries. func (c *CRDEntry) AllServeTargets() map[string]*ServeTargetConfig { - if c.Serve == nil { + if c.Serve == nil || !c.TargetModeEnabled() { return nil } return c.Serve.Target.Entries @@ -79,7 +102,7 @@ func (c *CRDEntry) AllServeTargets() map[string]*ServeTargetConfig { // LookupTarget returns the TargetConfig for the given entry name if it is enabled. // Returns nil for disabled entries, the primary when name matches, and unknown names. func (c *CRDEntry) LookupTarget(name string) *ServeTargetConfig { - if c.Serve == nil { + if c.Serve == nil || !c.TargetModeEnabled() { return nil } cfg, ok := c.Serve.Target.Entries[name] diff --git a/pkg/types/types_serve_target_config.go b/pkg/types/types_serve_target_config.go index 4b3e0fd2..586f1934 100644 --- a/pkg/types/types_serve_target_config.go +++ b/pkg/types/types_serve_target_config.go @@ -54,12 +54,83 @@ func (s *ServeTargetValue) UnmarshalYAML(value *yaml.Node) error { } } +// ─── ServeTargetValue marshaling ───────────────────────────────────────────── + +// isDefaultPrimary reports whether this target config is the default primary +// with no custom fields set. Used to decide when to marshal as shorthand. +func (c *ServeTargetConfig) isDefaultPrimary() bool { + if c == nil { + return true + } + + // Primary is the only field that can be set on a default primary + // If any other field is non-zero, we need to marshal as a map + + // Check Enabled + if c.IsEnabled() { + return false + } + + // Check Apply and all its nested fields + if c.HasApplyConfig() { + if c.HasServeApplyTargetOverride() { + return false + } + if c.HasServeApplyForceConflictOverrides() { + return false + } + } + if c.HasServeTargetFieldSelector() { + return false + } + + // Check Modes and all its nested fields + if c.HasServeModesConfig() { + if c.HasTargetServeMode() { + return false + } + if c.HasFullCRMode() { + return false + } + } + + // Check Tokens + if c.HasTokenRestrictions() { + return false + } + + // Check Config + if c.HasServeAliasConfigSettings() { + return false + } + + // Check Clusters + if c.HasClusters() { + return false + } + + // Check Include + if c.Include != "" { + return false + } + + // Only Primary is set (or nothing) → default primary + return true +} + +// HasCustomConfig reports whether this target has any custom configuration +// beyond the default primary settings. +func (c *ServeTargetConfig) HasCustomConfig() bool { + return !c.isDefaultPrimary() +} + // MarshalYAML serialises as a plain string when there is exactly one entry // that is primary with no additional config (shorthand round-trip). func (s ServeTargetValue) MarshalYAML() (interface{}, error) { if s.Shorthand != "" { return s.Shorthand, nil } + if len(s.Entries) == 1 { for name, cfg := range s.Entries { if cfg != nil && cfg.Primary && cfg.isDefaultPrimary() { @@ -67,24 +138,37 @@ func (s ServeTargetValue) MarshalYAML() (interface{}, error) { } } } + return s.Entries, nil } -// UnmarshalJSON accepts both string and object forms. -func (s *ServeTargetValue) UnmarshalJSON(data []byte) error { - var str string - if err := json.Unmarshal(data, &str); err == nil { - s.Shorthand = str - return nil - } - return json.Unmarshal(data, &s.Entries) -} +// UnmarshalYAML implements yaml.Unmarshaler for ServeTargetValue. +// func (s *ServeTargetValue) UnmarshalYAML(value *yaml.Node) error { +// // Try to unmarshal as a string first (shorthand) +// var shorthand string +// if err := value.Decode(&shorthand); err == nil { +// s.Shorthand = shorthand +// s.Entries = nil +// return nil +// } + +// // Try to unmarshal as a map of target configs +// var entries map[string]*ServeTargetConfig +// if err := value.Decode(&entries); err == nil { +// s.Entries = entries +// s.Shorthand = "" +// return nil +// } + +// return fmt.Errorf("ServeTargetValue: expected string or object") +// } // MarshalJSON serialises as a plain string for the shorthand case. func (s ServeTargetValue) MarshalJSON() ([]byte, error) { if s.Shorthand != "" { return json.Marshal(s.Shorthand) } + if len(s.Entries) == 1 { for name, cfg := range s.Entries { if cfg != nil && cfg.Primary && cfg.isDefaultPrimary() { @@ -92,9 +176,31 @@ func (s ServeTargetValue) MarshalJSON() ([]byte, error) { } } } + return json.Marshal(s.Entries) } +// UnmarshalJSON implements json.Unmarshaler for ServeTargetValue. +func (s *ServeTargetValue) UnmarshalJSON(data []byte) error { + // Try to unmarshal as a string first (shorthand) + var shorthand string + if err := json.Unmarshal(data, &shorthand); err == nil { + s.Shorthand = shorthand + s.Entries = nil + return nil + } + + // Try to unmarshal as a map of target configs + var entries map[string]*ServeTargetConfig + if err := json.Unmarshal(data, &entries); err == nil { + s.Entries = entries + s.Shorthand = "" + return nil + } + + return fmt.Errorf("ServeTargetValue: expected string or object, got %s", string(data)) +} + // ServeTargetConfig is one entry in the serve.target map. // Used for both the primary entry (Primary: true) and aliases (Primary: false). // @@ -118,6 +224,25 @@ type ServeTargetConfig struct { // The primary's config authority role is unaffected by Enabled. Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + // Apply configures apply-time behaviour for this specific target. + Apply *ServeApplyConfig `yaml:"apply,omitempty" json:"apply,omitempty"` + + // FieldSelector is a map of dot-notation field paths to values. + // When a CR matches ALL key-value pairs, it is routed to this target. + // This is a selector — like Service → Pod selection. + // + // Each target must have a unique combination of fieldSelectors. + // ork validate enforces this. Max 3 selectors per target. + // + // Example: + // fieldSelector: + // spec.workloadType: app + // spec.environment: staging + FieldSelector map[string]string `yaml:"fieldSelector,omitempty" json:"fieldSelector,omitempty"` + + // Modes controls which apply modes are allowed for this target. + Modes *ServeModes `yaml:"modes,omitempty" json:"modes,omitempty"` + // Include is a path (relative to the katalog file) to a YAML file with // tokens: and/or config: keys. Inline fields take precedence on merge. Include string `yaml:"include,omitempty" json:"include,omitempty"` @@ -162,6 +287,73 @@ func (t *ServeTargetConfig) ResponseConfig() *ServeResponseConfig { return t.Config.Response } +// HasApplyConfig returns the apply config for this entry, or nil. +func (t *ServeTargetConfig) HasApplyConfig() bool { + if t == nil || t.Apply == nil { + return false + } + return true +} + +// ApplyConfig returns the apply config for this entry, or nil. +func (t *ServeTargetConfig) ApplyConfig() *ServeApplyConfig { + if t == nil || t.Apply == nil { + return nil + } + return t.Apply +} + +// HasServeApplyTargetOverride reports whether this target has apply target override +// enabled. +func (t *ServeTargetConfig) HasServeApplyTargetOverride() bool { + if t == nil || t.Apply == nil || t.Apply.Overrides == nil { + return false + } + return t.Apply.Overrides.TargetConflict != nil +} + +// HasServeApplyForceConflictOverrides reports whether this target has apply force conflict +// enabled. +func (t *ServeTargetConfig) HasServeApplyForceConflictOverrides() bool { + if t == nil || t.Apply == nil || t.Apply.Overrides == nil { + return false + } + return t.Apply.Overrides.ResourceConflict != nil + +} + +// HasServeModesConfig reports whether this target has serve modes enabled. +func (t *ServeTargetConfig) HasServeModesConfig() bool { + if t == nil || t.Modes == nil { + return false + } + return true +} + +// HasTargetServeMode reports whether this target has serve mode enabled. +func (t *ServeTargetConfig) HasTargetServeMode() bool { + if t == nil || t.Modes == nil { + return false + } + return t.Modes.Target != nil +} + +// HasFullCRMode reports whether this target has full CR mode enabled. +func (t *ServeTargetConfig) HasFullCRMode() bool { + if t == nil || t.Modes == nil { + return false + } + return t.Modes.CR != nil +} + +// HasServeAliasConfigSettings reports whether this target has response config enabled. +func (t *ServeTargetConfig) HasServeAliasConfigSettings() bool { + if t == nil || t.Config == nil { + return false + } + return t.Config.Response != nil +} + // HasClusters reports whether this target entry declares its own cluster routing. func (t *ServeTargetConfig) HasClusters() bool { return t != nil && len(t.Clusters) > 0 @@ -174,12 +366,3 @@ func (t *ServeTargetConfig) TargetClusters() []string { } return t.Clusters } - -// isDefaultPrimary reports whether this entry has no config beyond Primary: true -// — used to decide whether the YAML shorthand round-trip is safe. -func (t *ServeTargetConfig) isDefaultPrimary() bool { - if t == nil { - return true - } - return t.IsEnabled() && len(t.Tokens) == 0 && t.Config == nil && t.Include == "" && !t.HasClusters() -} diff --git a/pkg/utils/selector.go b/pkg/utils/selector.go new file mode 100644 index 00000000..15e5205b --- /dev/null +++ b/pkg/utils/selector.go @@ -0,0 +1,228 @@ +package utils + +import ( + "fmt" + "regexp" + "strings" +) + +// MatchOption defines how to compare values +type MatchOption int + +const ( + MatchExact MatchOption = iota + MatchContains + MatchPrefix + MatchSuffix + MatchRegex + MatchIgnoreCase +) + +// FieldSelector represents a selector with comparison options +type FieldSelector struct { + Path string + Value interface{} + Options MatchOption +} + +// MatchesAllFieldSelectors checks if the given object matches all field selectors. +// Supports various comparison strategies and value types. +func MatchesAllFieldSelectors(obj map[string]interface{}, selectors map[string]interface{}) bool { + for path, expectedValue := range selectors { + actualValue, ok := GetNestedPath(obj, path) + if !ok { + return false + } + if !compareValues(actualValue, expectedValue) { + return false + } + } + return true +} + +// MatchesAllFieldSelectorsWithOptions checks if the given object matches all field selectors with options. +func MatchesAllFieldSelectorsWithOptions(obj map[string]interface{}, selectors []FieldSelector) bool { + for _, selector := range selectors { + actualValue, ok := GetNestedPath(obj, selector.Path) + if !ok { + return false + } + if !compareValuesWithOption(actualValue, selector.Value, selector.Options) { + return false + } + } + return true +} + +// compareValues compares two values with type conversion +func compareValues(actual, expected interface{}) bool { + // Handle nil + if actual == nil && expected == nil { + return true + } + if actual == nil || expected == nil { + return false + } + + // Try direct equality first + if actual == expected { + return true + } + + // Convert to string for comparison + actualStr := fmt.Sprintf("%v", actual) + expectedStr := fmt.Sprintf("%v", expected) + + return actualStr == expectedStr +} + +// compareValuesWithOption compares values with comparison options +func compareValuesWithOption(actual, expected interface{}, option MatchOption) bool { + // Handle nil + if actual == nil && expected == nil { + return true + } + if actual == nil || expected == nil { + return false + } + + actualStr := fmt.Sprintf("%v", actual) + expectedStr := fmt.Sprintf("%v", expected) + + switch option { + case MatchExact: + return actualStr == expectedStr + case MatchContains: + return strings.Contains(actualStr, expectedStr) + case MatchPrefix: + return strings.HasPrefix(actualStr, expectedStr) + case MatchSuffix: + return strings.HasSuffix(actualStr, expectedStr) + case MatchIgnoreCase: + return strings.EqualFold(actualStr, expectedStr) + case MatchRegex: + matched, _ := regexp.MatchString(expectedStr, actualStr) + return matched + default: + return actualStr == expectedStr + } +} + +// MatchesAllServeTargetFieldSelectors keeps the original for backward compatibility +func MatchesAllServeTargetFieldSelectors(obj map[string]interface{}, selectors map[string]string) bool { + return MatchesAllFieldSelectors(obj, convertStringMapToInterface(selectors)) +} + +// Helper to convert map[string]string to map[string]interface{} +func convertStringMapToInterface(m map[string]string) map[string]interface{} { + result := make(map[string]interface{}, len(m)) + for k, v := range m { + result[k] = v + } + return result +} + +// MatchesAnyFieldSelector checks if the object matches ANY of the selectors (OR logic) +func MatchesAnyFieldSelector(obj map[string]interface{}, selectors map[string]interface{}) bool { + for path, expectedValue := range selectors { + actualValue, ok := GetNestedPath(obj, path) + if ok && compareValues(actualValue, expectedValue) { + return true + } + } + return len(selectors) == 0 // Empty selectors returns true (matches nothing) +} + +// MatchesAllTypedSelectors supports typed comparisons (int, bool, float, etc.) +func MatchesAllTypedSelectors(obj map[string]interface{}, selectors map[string]interface{}) bool { + for path, expectedValue := range selectors { + actualValue, ok := GetNestedPath(obj, path) + if !ok { + return false + } + if !typedCompare(actualValue, expectedValue) { + return false + } + } + return true +} + +// typedCompare compares values with type awareness +func typedCompare(actual, expected interface{}) bool { + switch exp := expected.(type) { + case int, int32, int64: + // Convert actual to int64 if possible + actualInt, err := toInt64(actual) + if err != nil { + return false + } + expectedInt, err := toInt64(exp) + if err != nil { + return false + } + return actualInt == expectedInt + + case float32, float64: + actualFloat, err := toFloat64(actual) + if err != nil { + return false + } + expectedFloat, err := toFloat64(exp) + if err != nil { + return false + } + return actualFloat == expectedFloat + + case bool: + actualBool, ok := actual.(bool) + if !ok { + return false + } + return actualBool == exp + + case string: + actualStr, ok := actual.(string) + if !ok { + return false + } + return actualStr == exp + + default: + return fmt.Sprintf("%v", actual) == fmt.Sprintf("%v", expected) + } +} + +// Helper functions for type conversion +func toInt64(v interface{}) (int64, error) { + switch val := v.(type) { + case int: + return int64(val), nil + case int32: + return int64(val), nil + case int64: + return val, nil + case float32: + return int64(val), nil + case float64: + return int64(val), nil + default: + return 0, fmt.Errorf("cannot convert %T to int64", v) + } +} + +func toFloat64(v interface{}) (float64, error) { + switch val := v.(type) { + case float32: + return float64(val), nil + case float64: + return val, nil + case int: + return float64(val), nil + case int32: + return float64(val), nil + case int64: + return float64(val), nil + default: + return 0, fmt.Errorf("cannot convert %T to float64", v) + } +} diff --git a/pkg/utils/selector_test.go b/pkg/utils/selector_test.go new file mode 100644 index 00000000..3d13f278 --- /dev/null +++ b/pkg/utils/selector_test.go @@ -0,0 +1,729 @@ +package utils + +import ( + "reflect" + "testing" +) + +func TestMatchesAllFieldSelectors(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + selectors map[string]interface{} + expected bool + }{ + { + name: "exact match - string values", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + "image": "nginx:latest", + }, + }, + selectors: map[string]interface{}{ + "metadata.namespace": "default", + "spec.image": "nginx:latest", + }, + expected: true, + }, + { + name: "exact match - mixed types", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + "enabled": true, + "cpu": 2.5, + }, + }, + selectors: map[string]interface{}{ + "spec.replicas": 3, + "spec.enabled": true, + "spec.cpu": 2.5, + }, + expected: true, + }, + { + name: "no match - wrong value", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "default", + }, + }, + selectors: map[string]interface{}{ + "metadata.namespace": "kube-system", + }, + expected: false, + }, + { + name: "no match - missing path", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app", + }, + }, + selectors: map[string]interface{}{ + "metadata.namespace": "default", + }, + expected: false, + }, + { + name: "empty selectors", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app", + }, + }, + selectors: map[string]interface{}{}, + expected: true, + }, + { + name: "nil object", + obj: nil, + selectors: map[string]interface{}{ + "metadata.name": "my-app", + }, + expected: false, + }, + { + name: "deep nested path", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": "nginx", + }, + }, + }, + }, + }, + selectors: map[string]interface{}{ + "spec.template.metadata.labels.app": "nginx", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MatchesAllFieldSelectors(tt.obj, tt.selectors) + if result != tt.expected { + t.Errorf("MatchesAllFieldSelectors() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestMatchesAllServeTargetFieldSelectors(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + selectors map[string]string + expected bool + }{ + { + name: "exact match - string values", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "default", + }, + "spec": map[string]interface{}{ + "image": "nginx:latest", + }, + }, + selectors: map[string]string{ + "metadata.namespace": "default", + "spec.image": "nginx:latest", + }, + expected: true, + }, + { + name: "type conversion - int to string", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + selectors: map[string]string{ + "spec.replicas": "3", + }, + expected: true, + }, + { + name: "type conversion - bool to string", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "enabled": true, + }, + }, + selectors: map[string]string{ + "spec.enabled": "true", + }, + expected: true, + }, + { + name: "no match - wrong string", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "default", + }, + }, + selectors: map[string]string{ + "metadata.namespace": "kube-system", + }, + expected: false, + }, + { + name: "no match - missing path", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app", + }, + }, + selectors: map[string]string{ + "metadata.namespace": "default", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MatchesAllServeTargetFieldSelectors(tt.obj, tt.selectors) + if result != tt.expected { + t.Errorf("MatchesAllServeTargetFieldSelectors() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestMatchesAllFieldSelectorsWithOptions(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + selectors []FieldSelector + expected bool + }{ + { + name: "exact match", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "my-app", Options: MatchExact}, + }, + expected: true, + }, + { + name: "contains match", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app-production", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "app", Options: MatchContains}, + }, + expected: true, + }, + { + name: "prefix match", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app-production", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "my-", Options: MatchPrefix}, + }, + expected: true, + }, + { + name: "suffix match", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app-production", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "production", Options: MatchSuffix}, + }, + expected: true, + }, + { + name: "ignore case match", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "My-App", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "my-app", Options: MatchIgnoreCase}, + }, + expected: true, + }, + { + name: "multiple selectors - all match", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app-production", + "namespace": "default", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "app", Options: MatchContains}, + {Path: "metadata.namespace", Value: "default", Options: MatchExact}, + }, + expected: true, + }, + { + name: "multiple selectors - one fails", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "my-app-production", + "namespace": "default", + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: "app", Options: MatchContains}, + {Path: "metadata.namespace", Value: "kube-system", Options: MatchExact}, + }, + expected: false, + }, + { + name: "nil value handling", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": nil, + }, + }, + selectors: []FieldSelector{ + {Path: "metadata.name", Value: nil, Options: MatchExact}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MatchesAllFieldSelectorsWithOptions(tt.obj, tt.selectors) + if result != tt.expected { + t.Errorf("MatchesAllFieldSelectorsWithOptions() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestMatchesAnyFieldSelector(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + selectors map[string]interface{} + expected bool + }{ + { + name: "matches at least one", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "default", + }, + }, + selectors: map[string]interface{}{ + "metadata.namespace": "default", + "metadata.name": "my-app", + }, + expected: true, + }, + { + name: "matches none", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "default", + }, + }, + selectors: map[string]interface{}{ + "metadata.namespace": "kube-system", + "metadata.name": "my-app", + }, + expected: false, + }, + { + name: "empty selectors", + obj: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "default", + }, + }, + selectors: map[string]interface{}{}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MatchesAnyFieldSelector(tt.obj, tt.selectors) + if result != tt.expected { + t.Errorf("MatchesAnyFieldSelector() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestMatchesAllTypedSelectors(t *testing.T) { + tests := []struct { + name string + obj map[string]interface{} + selectors map[string]interface{} + expected bool + }{ + { + name: "int comparison", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + selectors: map[string]interface{}{ + "spec.replicas": 3, + }, + expected: true, + }, + { + name: "int vs float - should fail", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + selectors: map[string]interface{}{ + "spec.replicas": 3.0, + }, + expected: true, // conversion handles it + }, + { + name: "bool comparison", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "enabled": true, + }, + }, + selectors: map[string]interface{}{ + "spec.enabled": true, + }, + expected: true, + }, + { + name: "float comparison", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "cpu": 2.5, + }, + }, + selectors: map[string]interface{}{ + "spec.cpu": 2.5, + }, + expected: true, + }, + { + name: "int vs string - should fail", + obj: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + selectors: map[string]interface{}{ + "spec.replicas": "3", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MatchesAllTypedSelectors(tt.obj, tt.selectors) + if result != tt.expected { + t.Errorf("MatchesAllTypedSelectors() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCompareValues(t *testing.T) { + tests := []struct { + name string + actual interface{} + expected interface{} + want bool + }{ + { + name: "equal strings", + actual: "hello", + expected: "hello", + want: true, + }, + { + name: "different strings", + actual: "hello", + expected: "world", + want: false, + }, + { + name: "int and string - should match via conversion", + actual: 3, + expected: "3", + want: true, + }, + { + name: "bool and string - should match via conversion", + actual: true, + expected: "true", + want: true, + }, + { + name: "nil and nil", + actual: nil, + expected: nil, + want: true, + }, + { + name: "nil and non-nil", + actual: nil, + expected: "hello", + want: false, + }, + { + name: "different types with same string representation", + actual: 42, + expected: 42, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := compareValues(tt.actual, tt.expected); got != tt.want { + t.Errorf("compareValues() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompareValuesWithOption(t *testing.T) { + tests := []struct { + name string + actual interface{} + expected interface{} + option MatchOption + want bool + }{ + { + name: "contains - true", + actual: "hello world", + expected: "world", + option: MatchContains, + want: true, + }, + { + name: "contains - false", + actual: "hello world", + expected: "xyz", + option: MatchContains, + want: false, + }, + { + name: "prefix - true", + actual: "hello world", + expected: "hello", + option: MatchPrefix, + want: true, + }, + { + name: "prefix - false", + actual: "hello world", + expected: "world", + option: MatchPrefix, + want: false, + }, + { + name: "suffix - true", + actual: "hello world", + expected: "world", + option: MatchSuffix, + want: true, + }, + { + name: "suffix - false", + actual: "hello world", + expected: "hello", + option: MatchSuffix, + want: false, + }, + { + name: "ignore case - true", + actual: "Hello World", + expected: "hello world", + option: MatchIgnoreCase, + want: true, + }, + { + name: "ignore case - false", + actual: "Hello World", + expected: "goodbye world", + option: MatchIgnoreCase, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := compareValuesWithOption(tt.actual, tt.expected, tt.option); got != tt.want { + t.Errorf("compareValuesWithOption() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToInt64(t *testing.T) { + tests := []struct { + name string + input interface{} + want int64 + wantErr bool + }{ + { + name: "int", + input: 42, + want: 42, + wantErr: false, + }, + { + name: "int32", + input: int32(42), + want: 42, + wantErr: false, + }, + { + name: "int64", + input: int64(42), + want: 42, + wantErr: false, + }, + { + name: "float64", + input: 42.0, + want: 42, + wantErr: false, + }, + { + name: "string - should error", + input: "42", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toInt64(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("toInt64() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("toInt64() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToFloat64(t *testing.T) { + tests := []struct { + name string + input interface{} + want float64 + wantErr bool + }{ + { + name: "float32", + input: float32(42.5), + want: 42.5, + wantErr: false, + }, + { + name: "float64", + input: 42.5, + want: 42.5, + wantErr: false, + }, + { + name: "int", + input: 42, + want: 42.0, + wantErr: false, + }, + { + name: "string - should error", + input: "42.5", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toFloat64(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("toFloat64() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("toFloat64() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConvertStringMapToInterface(t *testing.T) { + tests := []struct { + name string + m map[string]string + want map[string]interface{} + }{ + { + name: "empty map", + m: map[string]string{}, + want: map[string]interface{}{}, + }, + { + name: "non-empty map", + m: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + want: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "nil map", + m: nil, + want: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertStringMapToInterface(tt.m) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertStringMapToInterface() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 42f16f1e..89111a59 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -2,9 +2,13 @@ package utils import ( "errors" + "fmt" "math/rand/v2" "os" + "strings" "time" + + "k8s.io/apimachinery/pkg/util/validation" ) type Status string @@ -105,3 +109,12 @@ func Exit(err error) { } os.Exit(1) } + +// ValidKubernetesName uses IsDNS1123Subdomain tests for a string that conforms to the +// definition of a subdomain in DNS (RFC 1123). +func ValidKubernetesName(name string) error { + if errs := validation.IsDNS1123Label(name); len(errs) > 0 { + return fmt.Errorf("invalid name %q: %s", name, strings.Join(errs, "; ")) + } + return nil +}