diff --git a/.gitignore b/.gitignore index fdeff3c..ca85df4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ *.swp cover.out site/ +examples/crossplane-xr-multiversion/gitops/.worktree/ +examples/crossplane-xr-multiversion/gitops/.demo-state +examples/crossplane-xr-multiversion/gitops/runner/convctl diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6d82d0..bf74b05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,13 +30,16 @@ make build # manager, webhook-server, convctl binaries into bin/ make test-prometheus # promtool unit tests for shipped alerts make test-e2e-load # kind + synthetic ConversionReview batches (see docs/operations/capacity.md) make test-e2e-scale # kind + generated CRD fleet + parallel Get/List (TARGETS/INSTANCES) -make dev-up # kind + cert-manager + Crossplane + kube-prometheus-stack + operator +make dev-up # kind + cert-manager + Crossplane + operator (+ monitoring + Kyverno) +make dev-up DEV_MONITORING=false DEV_KYVERNO=false # skip those extras make dev-down # delete the kind cluster from dev-up ``` -`make dev-up` enables chart ServiceMonitors / PrometheusRules / Grafana dashboards and -installs kube-prometheus-stack with anonymous Grafana (see -`hack/dev-monitoring-values.yaml`). After it finishes: +`make dev-up` installs kube-prometheus-stack (anonymous Grafana; see +`hack/dev-monitoring-values.yaml`) and Kyverno unless you set +`DEV_MONITORING=false` / `DEV_KYVERNO=false`. With monitoring on it also +enables chart ServiceMonitors / PrometheusRules / Grafana dashboards. After +it finishes: ```console kubectl -n monitoring-system port-forward svc/monitoring-grafana 3000:80 diff --git a/Makefile b/Makefile index 9f1898e..041b4e6 100644 --- a/Makefile +++ b/Makefile @@ -168,12 +168,16 @@ test-prometheus: promtool ## Run promtool unit tests for chart PrometheusRule al # Same shape as hack/e2e-common.sh's setup (kind + cert-manager + Crossplane # + this operator's own Helm chart), but left running afterward for -# interactive use instead of being torn down by a test script. Also installs -# kube-prometheus-stack with anonymous Grafana and enables the chart's -# ServiceMonitors / PrometheusRules / dashboard ConfigMaps. A fixed image -# tag (rather than e2e's timestamped one) keeps `make dev-up` idempotent and -# safe to re-run after every code change: it rebuilds, reloads, and restarts -# the running pods so the new code actually takes effect. +# interactive use instead of being torn down by a test script. Optionally +# installs kube-prometheus-stack (anonymous Grafana) and Kyverno +# (MutatingPolicy). A fixed image tag (rather than e2e's timestamped one) +# keeps `make dev-up` idempotent and safe to re-run after every code change: +# it rebuilds, reloads, and restarts the running pods so the new code +# actually takes effect. +# +# Toggle extras (both default on): +# make dev-up DEV_MONITORING=false +# make dev-up DEV_KYVERNO=false DEV_CLUSTER_NAME ?= declarative-conversion-dev DEV_NAMESPACE ?= declarative-conversion-system DEV_RELEASE_NAME ?= declarative-conversion-operator @@ -181,13 +185,20 @@ DEV_IMG_TAG ?= dev DEV_MANAGER_IMG ?= ghcr.io/terasky-oss/declarative-conversion-operator:$(DEV_IMG_TAG) DEV_WEBHOOK_IMG ?= ghcr.io/terasky-oss/declarative-conversion-webhook-server:$(DEV_IMG_TAG) DEV_CERT_MANAGER_VERSION ?= v1.21.1 +DEV_MONITORING ?= true DEV_MONITORING_NAMESPACE ?= monitoring-system DEV_MONITORING_RELEASE ?= monitoring # Pin so re-runs stay reproducible; bump deliberately when upgrading the stack. DEV_KUBE_PROM_STACK_VERSION ?= 88.3.0 +DEV_KYVERNO ?= true +DEV_KYVERNO_NAMESPACE ?= kyverno +DEV_KYVERNO_RELEASE ?= kyverno +# Chart 3.8.x ships Kyverno 1.18. MutatingPolicy mutateExisting is a no-op +# until #16255; generated migrate policies rely on admission instead. +DEV_KYVERNO_CHART_VERSION ?= 3.8.1 .PHONY: dev-up -dev-up: ## Stand up (or refresh) a full local dev environment: kind + cert-manager + Crossplane + kube-prometheus-stack (anonymous Grafana) + this operator with metrics/dashboards enabled. Safe to re-run after code changes. Requires docker, kind, kubectl, and helm on PATH. +dev-up: ## Stand up (or refresh) a local kind env: cert-manager + Crossplane + this operator. DEV_MONITORING=true (default) adds kube-prometheus-stack; DEV_KYVERNO=true (default) adds Kyverno. Safe to re-run after code changes. Requires docker, kind, kubectl, and helm on PATH. @command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 1; } @command -v kind >/dev/null 2>&1 || { echo "kind is required (https://kind.sigs.k8s.io)" >&2; exit 1; } @command -v kubectl >/dev/null 2>&1 || { echo "kubectl is required" >&2; exit 1; } @@ -218,24 +229,51 @@ dev-up: ## Stand up (or refresh) a full local dev environment: kind + cert-manag helm upgrade --install crossplane crossplane-stable/crossplane \ --namespace crossplane-system --create-namespace \ --wait --timeout 180s - @echo "==> Installing kube-prometheus-stack (Prometheus + Grafana, anonymous auth)" - helm repo add prometheus-community https://prometheus-community.github.io/helm-charts --force-update - helm repo update prometheus-community - helm upgrade --install $(DEV_MONITORING_RELEASE) prometheus-community/kube-prometheus-stack \ - --namespace $(DEV_MONITORING_NAMESPACE) --create-namespace \ - --version $(DEV_KUBE_PROM_STACK_VERSION) \ - --values hack/dev-monitoring-values.yaml \ - --wait --timeout 600s - @echo "==> Installing/upgrading $(DEV_RELEASE_NAME) with the locally-built dev images + monitoring" - helm upgrade --install $(DEV_RELEASE_NAME) charts/declarative-conversion-operator \ - --namespace $(DEV_NAMESPACE) --create-namespace \ - --set image.manager.tag=$(DEV_IMG_TAG) \ - --set image.webhookServer.tag=$(DEV_IMG_TAG) \ - --set image.pullPolicy=Never \ - --set metrics.serviceMonitor.enabled=true \ - --set metrics.prometheusRule.enabled=true \ - --set dashboards.enabled=true \ - --wait --timeout 180s + @if [ "$(DEV_MONITORING)" = "true" ]; then \ + echo "==> Installing kube-prometheus-stack (Prometheus + Grafana, anonymous auth)"; \ + helm repo add prometheus-community https://prometheus-community.github.io/helm-charts --force-update; \ + helm repo update prometheus-community; \ + helm upgrade --install $(DEV_MONITORING_RELEASE) prometheus-community/kube-prometheus-stack \ + --namespace $(DEV_MONITORING_NAMESPACE) --create-namespace \ + --version $(DEV_KUBE_PROM_STACK_VERSION) \ + --values hack/dev-monitoring-values.yaml \ + --wait --timeout 600s; \ + else \ + echo "==> Skipping kube-prometheus-stack (DEV_MONITORING=$(DEV_MONITORING))"; \ + fi + @if [ "$(DEV_KYVERNO)" = "true" ]; then \ + echo "==> Installing Kyverno $(DEV_KYVERNO_CHART_VERSION) (MutatingPolicy)"; \ + helm repo add kyverno https://kyverno.github.io/kyverno/ --force-update; \ + helm repo update kyverno; \ + helm upgrade --install $(DEV_KYVERNO_RELEASE) kyverno/kyverno \ + --namespace $(DEV_KYVERNO_NAMESPACE) --create-namespace \ + --version $(DEV_KYVERNO_CHART_VERSION) \ + --set crds.groups.policies.mutatingpolicies=true \ + --wait --timeout 300s; \ + kubectl -n $(DEV_KYVERNO_NAMESPACE) wait --for=condition=Available --timeout=180s deployment --all; \ + kubectl wait --for=condition=Established --timeout=60s crd/mutatingpolicies.policies.kyverno.io; \ + else \ + echo "==> Skipping Kyverno (DEV_KYVERNO=$(DEV_KYVERNO))"; \ + fi + @echo "==> Installing/upgrading $(DEV_RELEASE_NAME) with the locally-built dev images" + @if [ "$(DEV_MONITORING)" = "true" ]; then \ + helm upgrade --install $(DEV_RELEASE_NAME) charts/declarative-conversion-operator \ + --namespace $(DEV_NAMESPACE) --create-namespace \ + --set image.manager.tag=$(DEV_IMG_TAG) \ + --set image.webhookServer.tag=$(DEV_IMG_TAG) \ + --set image.pullPolicy=Never \ + --set metrics.serviceMonitor.enabled=true \ + --set metrics.prometheusRule.enabled=true \ + --set dashboards.enabled=true \ + --wait --timeout 180s; \ + else \ + helm upgrade --install $(DEV_RELEASE_NAME) charts/declarative-conversion-operator \ + --namespace $(DEV_NAMESPACE) --create-namespace \ + --set image.manager.tag=$(DEV_IMG_TAG) \ + --set image.webhookServer.tag=$(DEV_IMG_TAG) \ + --set image.pullPolicy=Never \ + --wait --timeout 180s; \ + fi @echo "==> Restarting pods so the freshly-loaded image content takes effect (tag is fixed across re-runs)" kubectl -n $(DEV_NAMESPACE) rollout restart deployment/$(DEV_RELEASE_NAME)-manager kubectl -n $(DEV_NAMESPACE) rollout status deployment/$(DEV_RELEASE_NAME)-manager --timeout=120s @@ -247,12 +285,21 @@ dev-up: ## Stand up (or refresh) a full local dev environment: kind + cert-manag @echo "==> Local dev environment ready." @echo " kubectl context: kind-$(DEV_CLUSTER_NAME)" @echo " Operator ns: $(DEV_NAMESPACE)" - @echo " Monitoring ns: $(DEV_MONITORING_NAMESPACE)" - @echo " Grafana (no login):" - @echo " kubectl -n $(DEV_MONITORING_NAMESPACE) port-forward svc/$(DEV_MONITORING_RELEASE)-grafana 3000:80" - @echo " open http://localhost:3000 (anonymous Admin; dashboards under search)" - @echo " Prometheus:" - @echo " kubectl -n $(DEV_MONITORING_NAMESPACE) port-forward svc/$(DEV_MONITORING_RELEASE)-kube-prometheus-prometheus 9090:9090" + @if [ "$(DEV_MONITORING)" = "true" ]; then \ + echo " Monitoring ns: $(DEV_MONITORING_NAMESPACE)"; \ + echo " Grafana (no login):"; \ + echo " kubectl -n $(DEV_MONITORING_NAMESPACE) port-forward svc/$(DEV_MONITORING_RELEASE)-grafana 3000:80"; \ + echo " open http://localhost:3000 (anonymous Admin; dashboards under search)"; \ + echo " Prometheus:"; \ + echo " kubectl -n $(DEV_MONITORING_NAMESPACE) port-forward svc/$(DEV_MONITORING_RELEASE)-kube-prometheus-prometheus 9090:9090"; \ + else \ + echo " Monitoring: skipped (DEV_MONITORING=$(DEV_MONITORING))"; \ + fi + @if [ "$(DEV_KYVERNO)" = "true" ]; then \ + echo " Kyverno ns: $(DEV_KYVERNO_NAMESPACE)"; \ + else \ + echo " Kyverno: skipped (DEV_KYVERNO=$(DEV_KYVERNO))"; \ + fi @echo " Made a code change? Just run 'make dev-up' again to rebuild, reload, and restart." @echo " Tear it down with: make dev-down" diff --git a/README.md b/README.md index 4a8db57..769485d 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ make helm-lint helm-template ### Local dev environment -`make dev-up` stands up a full local environment — a [kind](https://kind.sigs.k8s.io/) cluster, cert-manager, Crossplane, [kube-prometheus-stack](https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack) (Prometheus + Grafana with anonymous access), and this operator's own Helm chart installed with images built from your local checkout (ServiceMonitors, PrometheusRules, and Grafana dashboard ConfigMaps enabled) — and leaves it running for interactive use. It's the same setup the e2e tests use (see below), minus the test assertions, the teardown, and the monitoring stack. Safe to re-run after every code change: it rebuilds the images, reloads them into the cluster, and restarts the running pods so the new code actually takes effect. Requires `docker`, `kind`, `kubectl`, and `helm` on `PATH`; tear it down with `make dev-down`. +`make dev-up` stands up a full local environment — a [kind](https://kind.sigs.k8s.io/) cluster, cert-manager, Crossplane, [Kyverno](https://kyverno.io/) (for `demo.sh --demo-mode gitops`), [kube-prometheus-stack](https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack) (Prometheus + Grafana with anonymous access), and this operator's own Helm chart installed with images built from your local checkout (ServiceMonitors, PrometheusRules, and Grafana dashboard ConfigMaps enabled when monitoring is on) — and leaves it running for interactive use. It's the same setup the e2e tests use (see below), minus the test assertions, the teardown, and those optional extras. Skip extras with `DEV_MONITORING=false` and/or `DEV_KYVERNO=false` (both default `true`). Safe to re-run after every code change: it rebuilds the images, reloads them into the cluster, and restarts the running pods so the new code actually takes effect. Requires `docker`, `kind`, `kubectl`, and `helm` on `PATH`; tear it down with `make dev-down`. ### End-to-end tests diff --git a/docs/cli.md b/docs/cli.md index 21758a2..27131b9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -11,11 +11,12 @@ convctl diff --config config.yaml --live [-o json|table] convctl convert --config config.yaml (--xrd xrd.yaml | --crd crd.yaml) --sample obj.yaml --to v2 [-o yaml|json] convctl suggest --config config.yaml (--xrd xrd.yaml | --crd crd.yaml) [-o yaml|json] convctl rehub --config config.yaml (--xrd xrd.yaml | --crd crd.yaml) --to v3 [-o yaml|json] +convctl generate kyverno --xrd xrd.yaml --to v2 [--from v1] [-o yaml|json] convctl patch-preview --config config.yaml --service-name NAME --service-namespace NS --ca-bundle B64 [flags] convctl migrate-storage (--xrd NAME | --crd NAME) [flags] ``` -Roughly in the order you reach for them while authoring a mapping: `suggest` drafts rules for fields nothing covers yet, `validate` and `analyze` check the config statically, `convert` shows what a single object turns into, `test` grades fixtures or every live object, `diff` reports what a config edit changed, and `patch-preview` shows the exact patch the operator will apply once you commit. After a hub/storage-version promotion, `migrate-storage` rewrites live objects (critical for native CRDs; on XRDs the `compositionRef` retarget usually already did, and the remaining job is pruning `storedVersions`). +Roughly in the order you reach for them while authoring a mapping: `suggest` drafts rules for fields nothing covers yet, `validate` and `analyze` check the config statically, `convert` shows what a single object turns into, `test` grades fixtures or every live object, `diff` reports what a config edit changed, and `patch-preview` shows the exact patch the operator will apply once you commit. After a hub/storage-version promotion, `migrate-storage` rewrites live objects (critical for native CRDs; on XRDs the `compositionRef` retarget usually already did, and the remaining job is pruning `storedVersions`). For a GitOps hub flip, `generate kyverno` drafts MutatingPolicies that retarget existing XRs without a per-object name patch. ## `convctl validate` @@ -52,7 +53,11 @@ convctl analyze --crd crd.yaml --config crdconversionconfig.yaml ## `convctl test` -Runs every sample object through every served-version conversion path (round-tripping through the hub) and reports timing, fields converted, rules exercised, and — for any detected loss — exactly which field diverged between which versions and whether it was acknowledged. +Runs every sample object through every conversion path the config declares — hub plus each compiled spoke, among served versions (round-tripping through the hub) — and reports timing, fields converted, rules exercised, and — for any detected loss — exactly which field diverged between which versions and whether it was acknowledged. + +A served version that is not a spoke is not a conversion path. Drop the spoke from the config before setting `served: false`; `convctl test` still runs in that window. A sample whose own `apiVersion` is that dropped version is an **ERROR** — move the object in git to a remaining spoke or the hub first. + +`--samples` may be a GitOps `apps/` tree. Documents that are not the XRD/CRD's group and kind (for example `kustomization.yaml`) are ignored. ```console convctl test --xrd xrd.yaml --config xrdconversionconfig.yaml --samples ./samples/ @@ -274,6 +279,63 @@ convctl rehub --config examples/crossplane-xr-multiversion/04-add-v3/xrdconversi See [Changing the hub version](configuration/xrdconversionconfig.md#changing-the-hub-version) for the in-cluster promote sequence (`referenceable` / Composition retarget). +## `convctl generate kyverno` + +Drafts two [`policies.kyverno.io/v1` MutatingPolicies](https://kyverno.io/docs/policy-types/mutating-policy/) +that retarget existing Crossplane XRs onto a new hub Composition. XRD-only +(native CRDs have no Composition). Prints YAML/JSON to stdout and **never +applies** it. + +```console +convctl generate kyverno --xrd xrd.yaml --to v2 +convctl generate kyverno --xrd xrd.yaml --from v1 --to v2 +``` + +| Flag | Description | +|---|---| +| `-x, --xrd` | Path to an XRD YAML file. **Required.** Group + kind scope the Composition labeler; group, plural, and every served version fill the migrate policy's `matchConstraints`. | +| `--to` | Target `xrd-api-version` label. Must be a version on the XRD. **Required.** | +| `--from` | Optional canary: only migrate XRs whose selector is missing or equals this version. Without `--from`, anything not already labeled `--to` is migrated. | +| `--label-key` | Label key (default `xrd-api-version`). | +| `--composition-policy-name` | Name of the Composition labeler (default `label-compositions-`). | +| `--migrate-policy-name` | Name of the XR migrate policy (default `set-composition-version-selector-`). Same object on every hub flip — update `--from` / `--to`, do not create a new policy. | +| `-o, --output` | `yaml` (default, multi-doc) or `json`. | + +**Document 1 — per-XRD Composition labeler.** Admission writes +`xrd-api-version` from the version element of `compositeTypeRef.apiVersion` +(`example.org/v2` → `v2`). **Do not** put that label on Composition YAML in +git — Kyverno is the source of truth. XRD targeting (kind + group) lives in +the mutation CEL; Kyverno 1.18 silently ignores `matchConditions` that read +`object.spec`. Not a cluster-wide catch-all: XRDs that never evolve their API +do not need this policy. Admission and `mutateExisting` are on. + +**Document 2 — standing XR migrate policy** (`set-composition-version-selector-`). +One object per XRD. Re-generate with the new `--from` / `--to` on a hub flip +and apply the same `metadata.name` — do not create `migrate-*-to-vN`. +Admission and `mutateExisting` are on. Removes `spec.crossplane.compositionRef` +and `compositionRevisionRef`, then sets `compositionSelector.matchLabels` to +`--to`. Extra admin selector keys are left intact. Crossplane then re-selects; +`Automatic` writes a new revision pin. That write also persists the XR at the +new `referenceable` version. Admission is the path that works on Kyverno +1.18.1 — `mutateExisting` alone never creates UpdateRequests +([kyverno#16255](https://github.com/kyverno/kyverno/pull/16255)), so +already-existing XRs need a write (re-apply or annotate) after the policy +lands. UPDATE matches on `oldObject` so a later pin write does not rematch. + +Crossplane pins `compositionRef` at create time and ignores the selector until +that pin is removed. `compositionUpdatePolicy: Automatic` only walks revisions +of the already-pinned Composition. **Do not** use XRD `enforcedCompositionRef` +to chase hub versions — that field is immutable. + +If more than one Composition matches the selector after the pin is cleared, +Crossplane picks at random. A version-only selector is safe only when there is +one Composition per hub version. + +A worked apply order lives in +[`examples/crossplane-xr-multiversion/gitops/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/crossplane-xr-multiversion/gitops). +Run it with the main demo: `./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops` +(add `--gitops-engine flux|argo` for a live GitHub + in-cluster runner walkthrough). + ## `convctl patch-preview` Prints the exact server-side-apply object the operator would send to the target XRD or CRD to point its `spec.conversion` at a webhook server. Useful for reviewing a change before granting the operator write access to a production XRD, and for understanding what "the operator patches your XRD" actually means in concrete YAML. @@ -332,7 +394,7 @@ After you promote a new storage version (`storage: true` on a CRD, `referenceabl **XRD vs CRD — this command is not equally urgent.** -- **XRDs.** Crossplane will not let a Composition's `compositeTypeRef` change, so promoting the hub means a *new* Composition and a `compositionRef` patch on every existing XR. That write persists the object at the new `referenceable` version, so etcd is usually already rewritten by the time you deprecate an old spoke. What still blocks dropping the version block is the generated CRD's `status.storedVersions`, which never shrinks on its own. `--prune-stored-versions` is the step that matters; the empty SSA pass is belt-and-suspenders (XRs you forgot to retarget, or anything that was never patched). +- **XRDs.** Crossplane will not let a Composition's `compositeTypeRef` change, so promoting the hub means a *new* Composition and a write of every existing XR (a `compositionRef` name patch, or the GitOps [`generate kyverno`](#convctl-generate-kyverno) migrate policy that strips the pin and re-selects). That write persists the object at the new `referenceable` version, so etcd is usually already rewritten by the time you deprecate an old spoke. What still blocks dropping the version block is the generated CRD's `status.storedVersions`, which never shrinks on its own. `--prune-stored-versions` is the step that matters; the empty SSA pass is belt-and-suspenders (XRs you forgot to retarget, or anything that was never patched). - **CRDs.** Flipping `storage: true` does **not** write existing objects. There is no Crossplane-style retarget. Empty SSA is the actual etcd rewrite, and skipping it leaves CRs encoded at the old version indefinitely. This is the critical path. `migrate-storage` does the rewrite with an **empty server-side-apply patch** (`apiVersion`, `kind`, `metadata.name`, and `metadata.namespace` only) under a dedicated field manager, with force-conflicts. The apply claims only identity fields; the write still goes through the persist path, so etcd is re-encoded at the current storage version. Conversion webhooks — including this operator — run as they would on any write. diff --git a/docs/configuration/xrdconversionconfig.md b/docs/configuration/xrdconversionconfig.md index 762a8f5..9b7f3ac 100644 --- a/docs/configuration/xrdconversionconfig.md +++ b/docs/configuration/xrdconversionconfig.md @@ -167,7 +167,7 @@ A worked, apply-able walkthrough of this sequence — including creating a new C One K8s-level detail this doesn't handle automatically: `status.storedVersions` on the generated CRD never shrinks, so Kubernetes rejects dropping an old version from the XRD until that list is pruned — even when every XR has already been rewritten. -On an XRD that is not true of the etcd bytes themselves. Promoting the hub requires a new Composition (`compositeTypeRef` is immutable) and a `compositionRef` patch on every existing XR; those writes persist objects at the new `referenceable` version. Native CRDs have no equivalent "must write every object" step, so empty SSA is the actual rewrite there. For XRDs, run [`convctl migrate-storage --prune-stored-versions`](../cli.md#convctl-migrate-storage) anyway (catches anything you forgot to retarget) — the prune is the part that unblocks deleting the version block: +On an XRD that is not true of the etcd bytes themselves. Promoting the hub requires a new Composition (`compositeTypeRef` is immutable) and a write of every existing XR so etcd is stored at the new `referenceable` version. The staged example patches `compositionRef` by name. The GitOps path is [`convctl generate kyverno`](../cli.md#convctl-generate-kyverno): a per-XRD policy labels Compositions from `compositeTypeRef`, and a migrate policy strips the pin and sets `compositionSelector.matchLabels.xrd-api-version` so Crossplane re-selects. **Do not** use XRD `enforcedCompositionRef` to chase hub versions — that field is immutable. Native CRDs have no equivalent "must write every object" step, so empty SSA is the actual rewrite there. For XRDs, run [`convctl migrate-storage --prune-stored-versions`](../cli.md#convctl-migrate-storage) anyway (catches anything you forgot to retarget) — the prune is the part that unblocks deleting the version block: ```console convctl migrate-storage --xrd xwidgets.example.org --prune-stored-versions diff --git a/docs/examples/index.md b/docs/examples/index.md index de66dd0..76b74f5 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -17,7 +17,7 @@ isolation, these show a complete, runnable config you can copy and adapt. | [`field-rename/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/field-rename) | One field was renamed between two versions — the smallest useful config, and a demonstration of why identical fields need no rule. | [`FieldRename`](../strategies/field-rename.md) | | [`enum-remap/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/enum-remap) | The same field's allowed values were abbreviated (`Large` → `L`). Shows which mistakes `validate` catches versus which only `test` catches. | [`EnumRemap`](../strategies/enum-remap.md) | | [`for-each/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/for-each) | Every element of an array changed shape, with nested rules scoped to one element. | [`ForEach`](../strategies/for-each.md) | -| [`crossplane-xr-multiversion/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/crossplane-xr-multiversion) | Staged Crossplane XR lifecycle: one-version XRD + ConfigMap Composition, add a spoke, promote the hub (new Composition + retarget `compositionRef`), add `v3`, promote `v3` as the standard, deprecate `v1` (including `convctl migrate-storage` and dropping the version block). | [`FieldRename`](../strategies/field-rename.md) — see the [lifecycle walkthrough](xr-lifecycle.md) | +| [`crossplane-xr-multiversion/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/crossplane-xr-multiversion) | Staged Crossplane XR lifecycle: one-version XRD + ConfigMap Composition, add a spoke, promote the hub (new Composition + retarget `compositionRef`), add `v3`, promote `v3` as the standard, deprecate `v1` (including `convctl migrate-storage` and dropping the version block). GitOps alternative: [`gitops/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/crossplane-xr-multiversion/gitops) + [`convctl generate kyverno`](../cli.md#convctl-generate-kyverno) (`--gitops-engine simulate\|flux\|argo`). | [`FieldRename`](../strategies/field-rename.md) — see the [lifecycle walkthrough](xr-lifecycle.md) | | [`native-crd/`](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/native-crd) | The same model against a plain Kubernetes CRD, with no Crossplane anywhere. | [`FieldRename`](../strategies/field-rename.md), [`Delete`](../strategies/delete.md) | For a single fixture that exercises *every* built-in strategy at once, see the diff --git a/docs/examples/xr-lifecycle.md b/docs/examples/xr-lifecycle.md index cf14b00..cc4c158 100644 --- a/docs/examples/xr-lifecycle.md +++ b/docs/examples/xr-lifecycle.md @@ -14,12 +14,21 @@ no `provider-kubernetes`. On a cluster with Crossplane and this operator already installed (`make dev-up`), [`demo.sh`](https://github.com/terasky-oss/declarative-conversion-operator/blob/main/examples/crossplane-xr-multiversion/demo.sh) is a demo-magic walkthrough: each command is typed out, and `convctl` is run -against intentional mistakes before the good config is applied. +against intentional mistakes before the good config is applied. Default +`--demo-mode patches` retargets with `kubectl patch`; `--demo-mode gitops` +uses [`convctl generate kyverno`](../cli.md#convctl-generate-kyverno). +`--gitops-engine` defaults to `simulate`. `flux` or `argo` drive a real +GitHub repo (PRs, in-cluster self-hosted Actions runner, then Flux/Argo +sync). GitHub-hosted Actions cannot reach kind; the demo does not use ACT. +`convctl migrate-storage` stays local. ```console -./examples/crossplane-xr-multiversion/demo.sh # Enter to type, Enter to run -./examples/crossplane-xr-multiversion/demo.sh -n # no pauses +./examples/crossplane-xr-multiversion/demo.sh # patches (default) +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops # Kyverno retarget (simulate) +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops --gitops-engine flux --create-repo +./examples/crossplane-xr-multiversion/demo.sh -n # no pauses ./examples/crossplane-xr-multiversion/demo.sh --cleanup +./examples/crossplane-xr-multiversion/demo.sh --cleanup --delete-repo # only if this run created the repo ``` | Stage | Snapshot | What to notice | @@ -48,3 +57,12 @@ ordering, lives in the [example README](https://github.com/terasky-oss/declarati Hub-promotion safety (`KeepServingStale`) is documented in [XRDConversionConfig: Changing the hub version](../configuration/xrdconversionconfig.md#changing-the-hub-version). Use [`convctl rehub`](../cli.md#convctl-rehub) as the draft step when rewriting rules for a new hub. + +To retarget existing XRs without a per-object `compositionRef` patch, see the +[GitOps example](https://github.com/terasky-oss/declarative-conversion-operator/tree/main/examples/crossplane-xr-multiversion/gitops) +and [`convctl generate kyverno`](../cli.md#convctl-generate-kyverno). Do not use +XRD `enforcedCompositionRef` for hub flips — the field is immutable. +`--gitops-engine flux|argo` adds GitHub PRs and an in-cluster self-hosted +Actions runner so CI can run `convctl test --live`; `migrate-storage` stays +a local command. `--delete-repo` with `--cleanup` only deletes a repo the +demo created. diff --git a/docs/observability.md b/docs/observability.md index c6455f4..ae2dff3 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -12,7 +12,8 @@ Enable chart `ServiceMonitor`s with `metrics.serviceMonitor.enabled=true` (and optional `PrometheusRule` / Grafana dashboard ConfigMaps — see [Installation](installation.md)). `make dev-up` turns those on automatically and installs kube-prometheus-stack with anonymous Grafana -(`hack/dev-monitoring-values.yaml`). Trust boundary and NetworkPolicy: +(`hack/dev-monitoring-values.yaml`) unless you pass `DEV_MONITORING=false`. +Trust boundary and NetworkPolicy: [security/metrics.md](security/metrics.md). **Label note:** target identity uses the Prometheus label `target` (XRD or diff --git a/examples/crossplane-xr-multiversion/06-deprecate-v1/widget.yaml b/examples/crossplane-xr-multiversion/06-deprecate-v1/widget.yaml new file mode 100644 index 0000000..73184d2 --- /dev/null +++ b/examples/crossplane-xr-multiversion/06-deprecate-v1/widget.yaml @@ -0,0 +1,11 @@ +# App-team YAML after v1 leaves the conversion plan. The object must not +# stay at example.org/v1 — convctl test against apps/ fails when a sample's +# version has no compiled plan. v2 is still a served spoke. +apiVersion: example.org/v2 +kind: XWidget +metadata: + name: demo + namespace: default +spec: + widgetName: demo-widget + capacity: Large diff --git a/examples/crossplane-xr-multiversion/README.md b/examples/crossplane-xr-multiversion/README.md index 18d0fb4..3497ec1 100644 --- a/examples/crossplane-xr-multiversion/README.md +++ b/examples/crossplane-xr-multiversion/README.md @@ -44,9 +44,17 @@ needs [`pv`](https://www.ivarch.com/programs/pv.shtml); without it the script passes `-d` for you. ```console -# Interactive (Enter to type, Enter to run) +# Interactive (Enter to type, Enter to run). Default --demo-mode patches. ./examples/crossplane-xr-multiversion/demo.sh +# Same lifecycle; retarget via generated Kyverno policies (needs make dev-up) +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops + +# Live GitOps: GitHub PRs + in-cluster Actions runner + Flux or Argo +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops --gitops-engine flux --create-repo +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops --gitops-engine argo \ + --github-repo "$USER/platform" --git-prefix xwidget-demo + # No pauses — good for recording ./examples/crossplane-xr-multiversion/demo.sh -n @@ -56,8 +64,13 @@ passes `-d` for you. # Auto-advance after 3s ./examples/crossplane-xr-multiversion/demo.sh -w 3 -# Wipe leftover XRs / XRD / conversion config +# Wipe leftover XRs / XRD / conversion config (and Flux/Argo/runner if this run installed them) ./examples/crossplane-xr-multiversion/demo.sh --cleanup +# Also delete the GitHub repo only if this process created it: +./examples/crossplane-xr-multiversion/demo.sh --cleanup --delete-repo + +# Resume after a failure (does not wipe cluster or git) +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops --gitops-engine flux --from-stage 6 ``` The broken configs live in [`mistakes/`](mistakes/). They are never applied. @@ -357,6 +370,39 @@ then drop-the-block sequence. --- +## GitOps: retarget without naming every XR + +The patches in this walkthrough (`patches/retarget-v2.json`) are the honest +Crossplane default: `compositionRef` is pinned at create time and +`defaultCompositionRef` does not move existing objects. + +[`gitops/`](gitops/) is the platform-repo shape of the same lifecycle. +`convctl generate kyverno` emits a per-XRD Composition labeler (admission +writes `xrd-api-version` from `compositeTypeRef`; that label is never in git) +and a migrate policy (admission, not background-only) that strips both pins and sets +`compositionSelector.matchLabels.xrd-api-version`. App-team YAML never names a +Composition. + +Do **not** use XRD `enforcedCompositionRef` to chase hub versions — that field +is immutable. + +```console +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops +# Kyverno is installed by make dev-up (DEV_KYVERNO=true, the default) + +# Optional: real GitHub + Flux or Argo (see gitops/README.md) +./examples/crossplane-xr-multiversion/demo.sh --demo-mode gitops --gitops-engine flux --create-repo +``` + +`--gitops-engine` defaults to `simulate` (direct apply of the `gitops/` tree). +`flux` and `argo` need `gh` authenticated (`gh auth status`), plus `git`, +`helm`, `docker`, and `kind`. They register an **in-cluster self-hosted +Actions runner** so PR jobs can run `convctl test --live` against kind. +GitHub-hosted Actions cannot reach kind; this demo does not use ACT. +`convctl migrate-storage` stays a local command even with a live engine. + +--- + ## Reference - [Field Rename](https://terasky-oss.github.io/declarative-conversion-operator/strategies/field-rename/) diff --git a/examples/crossplane-xr-multiversion/demo.sh b/examples/crossplane-xr-multiversion/demo.sh index 955c1ad..ffec528 100755 --- a/examples/crossplane-xr-multiversion/demo.sh +++ b/examples/crossplane-xr-multiversion/demo.sh @@ -10,14 +10,23 @@ # apply so you see the CLI catch them. # # Prerequisites: cluster with Crossplane v2 and this operator (`make dev-up`). -# Simulated typing requires `pv` (or pass -d). +# --demo-mode gitops also needs Kyverno (installed by make dev-up unless +# DEV_KYVERNO=false). Live engines (flux|argo) need gh, git, helm, docker, +# and kind. Simulated typing requires `pv` (or pass -d). # # Usage: -# ./demo.sh type each command; Enter to type, Enter to run -# ./demo.sh -n demo-magic: no pauses (recording / CI) -# ./demo.sh -d demo-magic: print commands instantly (no typing) -# ./demo.sh -w 3 demo-magic: auto-advance after 3s -# ./demo.sh --cleanup wipe leftover demo objects and exit +# ./demo.sh type each command; Enter to type, Enter to run +# ./demo.sh --demo-mode patches default: kubectl patch compositionRef +# ./demo.sh --demo-mode gitops Kyverno MutatingPolicies; engine defaults to simulate +# ./demo.sh --demo-mode gitops --gitops-engine simulate +# ./demo.sh --demo-mode gitops --gitops-engine flux --create-repo +# ./demo.sh --demo-mode gitops --gitops-engine argo --github-repo $USER/platform --git-prefix xwidget-demo +# ./demo.sh -n demo-magic: no pauses (recording / CI) +# ./demo.sh -d demo-magic: print commands instantly (no typing) +# ./demo.sh -w 3 demo-magic: auto-advance after 3s +# ./demo.sh --cleanup wipe leftover demo objects and exit +# ./demo.sh --cleanup --delete-repo also gh repo delete if this run created the repo +# ./demo.sh --from-stage 6 resume after a failure (0–6; does not wipe) # set -u @@ -25,17 +34,103 @@ EXAMPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${EXAMPLE_DIR}/../.." && pwd)" NS="${DEMO_NAMESPACE:-default}" CLEANUP_ONLY=0 - -usage() { sed -n '2,20p' "$0"; } +DEMO_MODE=patches +GITOPS_ENGINE=simulate +GITHUB_REPO_FLAG="" +CREATE_REPO=0 +CREATE_REPO_NAME="xwidget-lifecycle-demo" +GIT_PREFIX="" +REPO_VISIBILITY=private +DELETE_REPO=0 +CREATED_REPO=0 +FROM_STAGE=0 + +usage() { sed -n '2,30p' "$0"; } die() { echo "error: $*" >&2; exit 1; } +# shift 2 is a no-op when only the flag remains, which would loop forever. +require_flag_value() { + if [[ $# -lt 2 || -z "${2}" || "${2}" == -* ]]; then + die "$1 requires a value" + fi +} + # Long flags we own; everything else is forwarded to demo-magic's getopts # (-h -d -n -c -w). forward=() while [[ $# -gt 0 ]]; do case "$1" in --cleanup) CLEANUP_ONLY=1; shift ;; + --demo-mode) + require_flag_value "$@" + DEMO_MODE="$2" + shift 2 + ;; + --demo-mode=*) + DEMO_MODE="${1#*=}" + shift + ;; + --gitops-engine) + require_flag_value "$@" + GITOPS_ENGINE="$2" + shift 2 + ;; + --gitops-engine=*) + GITOPS_ENGINE="${1#*=}" + shift + ;; + --github-repo) + require_flag_value "$@" + GITHUB_REPO_FLAG="$2" + shift 2 + ;; + --github-repo=*) + GITHUB_REPO_FLAG="${1#*=}" + shift + ;; + --create-repo) + CREATE_REPO=1 + if [[ "${2:-}" != "" && "${2:-}" != -* ]]; then + CREATE_REPO_NAME="$2" + shift 2 + else + shift + fi + ;; + --create-repo=*) + CREATE_REPO=1 + CREATE_REPO_NAME="${1#*=}" + shift + ;; + --git-prefix) + require_flag_value "$@" + GIT_PREFIX="$2" + shift 2 + ;; + --git-prefix=*) + GIT_PREFIX="${1#*=}" + shift + ;; + --repo-visibility) + require_flag_value "$@" + REPO_VISIBILITY="$2" + shift 2 + ;; + --repo-visibility=*) + REPO_VISIBILITY="${1#*=}" + shift + ;; + --delete-repo) DELETE_REPO=1; shift ;; + --from-stage) + require_flag_value "$@" + FROM_STAGE="$2" + shift 2 + ;; + --from-stage=*) + FROM_STAGE="${1#*=}" + shift + ;; --yes|--auto) forward+=(-n); shift ;; --fast) forward+=(-d); shift ;; --help) usage; exit 0 ;; @@ -44,6 +139,50 @@ while [[ $# -gt 0 ]]; do done set -- "${forward[@]}" +case "${DEMO_MODE}" in + patches|gitops) ;; + *) die "invalid --demo-mode ${DEMO_MODE} (want patches or gitops)" ;; +esac + +case "${GITOPS_ENGINE}" in + simulate|flux|argo) ;; + *) die "invalid --gitops-engine ${GITOPS_ENGINE} (want simulate, flux, or argo)" ;; +esac + +if [[ "${GITOPS_ENGINE}" != simulate && "${DEMO_MODE}" != gitops ]]; then + die "--gitops-engine ${GITOPS_ENGINE} requires --demo-mode gitops" +fi + +if [[ "${DEMO_MODE}" == patches && "${GITOPS_ENGINE}" != simulate ]]; then + die "--gitops-engine is only valid with --demo-mode gitops" +fi + +case "${REPO_VISIBILITY}" in + public|private) ;; + *) die "invalid --repo-visibility ${REPO_VISIBILITY} (want public or private)" ;; +esac + +if [[ -n "${GIT_PREFIX}" && "${CREATE_REPO}" -eq 1 ]]; then + die "--git-prefix is for an existing --github-repo; new repos write at /" +fi + +if [[ "${GITOPS_ENGINE}" == simulate && "${CLEANUP_ONLY}" -ne 1 ]]; then + if [[ -n "${GITHUB_REPO_FLAG}" || "${CREATE_REPO}" -eq 1 ]]; then + die "--github-repo / --create-repo require --gitops-engine flux or argo" + fi +fi + +if ! [[ "${FROM_STAGE}" =~ ^[0-6]$ ]]; then + die "invalid --from-stage ${FROM_STAGE} (want 0-6)" +fi + +demo_stage() { + [[ "$1" -ge "${FROM_STAGE}" ]] +} + +# shellcheck source=gitops/lib.sh +source "${EXAMPLE_DIR}/gitops/lib.sh" + cleanup_demo() { kubectl delete xwidgets.example.org --all -n "${NS}" --wait=true --timeout=90s >/dev/null 2>&1 || true kubectl annotate xrdconversionconfig xwidgets-conversion \ @@ -51,13 +190,22 @@ cleanup_demo() { kubectl delete xrdconversionconfig xwidgets-conversion --wait=true --timeout=60s >/dev/null 2>&1 || true kubectl delete composition xwidgets.example.org xwidgets-v2.example.org xwidgets-v3.example.org --wait=true --timeout=60s >/dev/null 2>&1 || true kubectl delete compositeresourcedefinition.apiextensions.crossplane.io/xwidgets.example.org --wait=true --timeout=90s >/dev/null 2>&1 || true + kubectl delete mutatingpolicy.policies.kyverno.io label-compositions-xwidgets set-composition-version-selector-xwidgets migrate-xwidgets-to-v2 migrate-xwidgets-to-v3 --wait=true --timeout=60s >/dev/null 2>&1 || true + kubectl delete clusterrole kyverno-xwidgets-view kyverno-xwidgets-mutate --wait=true --timeout=30s >/dev/null 2>&1 || true kubectl delete configmap -n "${NS}" demo-settings from-v2-settings from-v3-settings after-promote-settings on-v3-settings >/dev/null 2>&1 || true } command -v kubectl >/dev/null 2>&1 || die "kubectl not found on PATH" if [[ "${CLEANUP_ONLY}" -eq 1 ]]; then + # Stop Flux/Argo first so they cannot recreate objects during cleanup_demo. + if gitops_is_live || [[ "${DELETE_REPO}" -eq 1 ]] || [[ -f "${GITOPS_STATE_FILE}" ]]; then + gitops_cleanup_cluster + fi cleanup_demo + if gitops_is_live || [[ "${DELETE_REPO}" -eq 1 ]] || [[ -f "${GITOPS_STATE_FILE}" ]]; then + gitops_cleanup_repo + fi echo "demo objects removed" exit 0 fi @@ -69,6 +217,10 @@ kubectl get crd compositeresourcedefinitions.apiextensions.crossplane.io >/dev/n || die "Crossplane is not installed. Run 'make dev-up' first." kubectl wait --for=condition=Available --timeout=120s conversionwebhookserver/default >/dev/null 2>&1 \ || die "ConversionWebhookServer/default is not Available yet." +if [[ "${DEMO_MODE}" == gitops ]]; then + kubectl get crd mutatingpolicies.policies.kyverno.io >/dev/null 2>&1 \ + || die "Kyverno MutatingPolicy CRD is missing. Run 'make dev-up' (DEV_KYVERNO=true, the default) or omit --demo-mode gitops." +fi # Always use this checkout's convctl so a stale PATH binary cannot drift. mkdir -p "${REPO_ROOT}/bin" @@ -144,56 +296,115 @@ note() { cd "${EXAMPLE_DIR}" set -e - +if [[ "${FROM_STAGE}" -gt 0 ]]; then + note "Resuming from stage ${FROM_STAGE} (cluster and git left as-is)." + if gitops_is_live; then + gitops_state_load + [[ -n "${GITHUB_REPO:-}" ]] || die "--from-stage ${FROM_STAGE} needs a previous live run (${GITOPS_STATE_FILE} missing GITHUB_REPO)" + [[ -d "${GITOPS_WORKTREE}/.git" ]] || die "git worktree missing at ${GITOPS_WORKTREE}; cannot resume" + note "Repo: ${GITHUB_REPO} prefix: $(gitops_rel_prefix) runner label: ${GITOPS_RUNNER_LABEL}" + fi +else section "XWidget API evolution — live demo" cat </dev/null) +} + +kyverno_xr_discovery_ok() { + kubectl get ns kyverno >/dev/null 2>&1 || return 0 + kubectl get crd xwidgets.example.org >/dev/null 2>&1 || return 0 + local group ver out + group="$(kubectl get crd xwidgets.example.org -o jsonpath='{.spec.group}' 2>/dev/null || true)" + [[ -n "${group}" ]] || return 0 + while IFS= read -r ver; do + [[ -n "${ver}" ]] || continue + out="$(kubectl apply --dry-run=server --server-side -f - 2>&1 </dev/null 2>&1 || return 0 + { + kubectl rollout restart deploy/kyverno-admission-controller -n kyverno + kubectl rollout restart deploy/kyverno-background-controller -n kyverno 2>/dev/null || true + kubectl rollout status deploy/kyverno-admission-controller -n kyverno --timeout=120s + kubectl rollout status deploy/kyverno-background-controller -n kyverno --timeout=120s 2>/dev/null || true + local i + for i in $(seq 1 40); do + kyverno_xr_discovery_ok && return 0 + sleep 2 + done + } >/dev/null 2>&1 || true +} + +kyverno_refresh_if_stale() { + kubectl get deploy -n kyverno kyverno-admission-controller >/dev/null 2>&1 || return 0 + kyverno_xr_discovery_ok && return 0 + kyverno_restart_admission +} + +gitops_is_live() { + [[ "${DEMO_MODE:-}" == gitops && "${GITOPS_ENGINE:-simulate}" != simulate ]] +} + +gitops_manifest_root() { + if [[ -n "${GIT_PREFIX:-}" ]]; then + echo "${GITOPS_WORKTREE}/${GIT_PREFIX}" + else + echo "${GITOPS_WORKTREE}" + fi +} + +gitops_rel_prefix() { + if [[ -n "${GIT_PREFIX:-}" ]]; then + echo "${GIT_PREFIX}" + else + echo "." + fi +} + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +gitops_state_write() { + ( + umask 077 + cat > "${GITOPS_STATE_FILE}" </dev/null 2>&1 || die "gh not found on PATH (required for --gitops-engine ${GITOPS_ENGINE})" + command -v git >/dev/null 2>&1 || die "git not found on PATH (required for --gitops-engine ${GITOPS_ENGINE})" + command -v helm >/dev/null 2>&1 || die "helm not found on PATH (required for --gitops-engine ${GITOPS_ENGINE})" + command -v docker >/dev/null 2>&1 || die "docker not found on PATH (required to build/load the convctl runner image)" + command -v kind >/dev/null 2>&1 || die "kind not found on PATH (required to load the convctl runner image)" + gh auth status >/dev/null 2>&1 || die "gh is not authenticated. Run 'gh auth login' (repo + workflow scopes)." +} + +gitops_resolve_kind_cluster() { + if [[ -n "${KIND_CLUSTER_NAME:-${DEV_CLUSTER_NAME:-}}" ]]; then + KIND_CLUSTER="${KIND_CLUSTER_NAME:-${DEV_CLUSTER_NAME}}" + return + fi + local ctx + ctx="$(kubectl config current-context 2>/dev/null || true)" + if [[ "${ctx}" == kind-* ]]; then + KIND_CLUSTER="${ctx#kind-}" + return + fi + KIND_CLUSTER="declarative-conversion-dev" +} + +gitops_resolve_repo() { + if [[ "${CREATE_REPO:-0}" -eq 1 && -n "${GITHUB_REPO_FLAG:-}" ]]; then + die "--create-repo and --github-repo are mutually exclusive" + fi + if [[ "${CREATE_REPO:-0}" -eq 1 ]]; then + local name="${CREATE_REPO_NAME:-xwidget-lifecycle-demo}" + if [[ "${name}" == */* ]]; then + GITHUB_REPO="${name}" + else + local owner + owner="$(gh api user --jq .login)" + GITHUB_REPO="${owner}/${name}" + fi + return + fi + if [[ -n "${GITHUB_REPO_FLAG:-}" ]]; then + GITHUB_REPO="${GITHUB_REPO_FLAG}" + return + fi + die "--gitops-engine ${GITOPS_ENGINE} requires --github-repo owner/name or --create-repo" +} + +# --------------------------------------------------------------------------- +# Repo + worktree +# --------------------------------------------------------------------------- + +gitops_set_push_url() { + # Do not embed gh auth token in origin — git stores remotes in plaintext + # .git/config. The worktree uses gh's credential helper instead. + git -C "${GITOPS_WORKTREE}" config --local credential.https://github.com.helper "" + git -C "${GITOPS_WORKTREE}" config --local --add credential.https://github.com.helper "!gh auth git-credential" + git -C "${GITOPS_WORKTREE}" remote set-url origin "https://github.com/${GITHUB_REPO}.git" +} + +gitops_create_or_use_repo() { + if [[ "${CREATE_REPO:-0}" -eq 1 ]]; then + if gh repo view "${GITHUB_REPO}" >/dev/null 2>&1; then + echo "Reusing existing ${GITHUB_REPO} (previous --create-repo run). --delete-repo will still remove it." + else + echo "Creating GitHub repo ${GITHUB_REPO} (${REPO_VISIBILITY:-private})…" + gh repo create "${GITHUB_REPO}" --"${REPO_VISIBILITY:-private}" --clone=false + fi + CREATED_REPO=1 + gitops_state_write + else + gh repo view "${GITHUB_REPO}" >/dev/null 2>&1 \ + || die "GitHub repo ${GITHUB_REPO} not found (or no access). Check --github-repo." + CREATED_REPO=0 + if [[ -z "${GIT_PREFIX:-}" ]]; then + echo "note: writing at the repo root of ${GITHUB_REPO}. Pass --git-prefix PATH to keep existing files." >&2 + fi + fi +} + +gitops_clone_worktree() { + rm -rf "${GITOPS_WORKTREE}" + mkdir -p "${GITOPS_WORKTREE}" + local default="" + default="$(gh repo view "${GITHUB_REPO}" --jq '.defaultBranchRef.name // empty' 2>/dev/null || true)" + if [[ -n "${default}" ]]; then + gh repo clone "${GITHUB_REPO}" "${GITOPS_WORKTREE}" + GITOPS_BASE_BRANCH="${default}" + git -C "${GITOPS_WORKTREE}" checkout "${GITOPS_BASE_BRANCH}" + else + git -C "${GITOPS_WORKTREE}" init -b main + git -C "${GITOPS_WORKTREE}" remote add origin "https://github.com/${GITHUB_REPO}.git" + GITOPS_BASE_BRANCH=main + fi + gitops_set_push_url +} + +gitops_git() { + git -C "${GITOPS_WORKTREE}" \ + -c user.email=xwidget-demo@users.noreply.github.com \ + -c user.name=xwidget-demo \ + "$@" +} + +gitops_write_kustomization() { + local dir="$1" + mkdir -p "${dir}" + { + echo "apiVersion: kustomize.config.k8s.io/v1beta1" + echo "kind: Kustomization" + echo "resources:" + local found=0 + local f rel + while IFS= read -r f; do + rel="${f#"${dir}"/}" + echo " - ${rel}" + found=1 + done < <(find "${dir}" \( -name samples -o -name .git \) -prune -o \ + -type f \( -name '*.yaml' -o -name '*.yml' \) ! -name 'kustomization.yaml' -print | sort) + if [[ "${found}" -eq 0 ]]; then + echo " []" + fi + } > "${dir}/kustomization.yaml" + # "resources: / []" is invalid; rewrite empty as resources: [] + if grep -q '^resources:$' "${dir}/kustomization.yaml" && grep -q '^ \[\]$' "${dir}/kustomization.yaml"; then + printf '%s\n' \ + "apiVersion: kustomize.config.k8s.io/v1beta1" \ + "kind: Kustomization" \ + "resources: []" > "${dir}/kustomization.yaml" + fi +} + +gitops_refresh_kustomizations() { + local root + root="$(gitops_manifest_root)" + mkdir -p "${root}/platform" "${root}/apps" "${root}/conversion" + # Conversion config is a sibling tree so Flux can apply the XRD first + # (dependsOn) while CI still sees both files in the same commit. + rm -f "${root}/platform/xrdconversionconfig.yaml" + gitops_write_kustomization "${root}/platform" + gitops_write_kustomization "${root}/apps" + gitops_write_kustomization "${root}/conversion" + cat > "${root}/kustomization.yaml" <<'EOF' +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - platform + - apps +EOF +} + +gitops_yaml_name() { + awk 'BEGIN{m=0} /^metadata:/{m=1} m && /^ name:/{print $2; exit}' "$1" +} + +gitops_write_workflow() { + mkdir -p "${GITOPS_WORKTREE}/.github/workflows" + sed -e "s|__CONVCTL_ROOT__|$(gitops_rel_prefix)|g" \ + -e "s|__DEFAULT_BRANCH__|${GITOPS_BASE_BRANCH:-main}|g" \ + "${GITOPS_DIR}/workflow/convctl.yaml" \ + > "${GITOPS_WORKTREE}/.github/workflows/convctl.yaml" +} + +gitops_seed_main() { + local root + root="$(gitops_manifest_root)" + mkdir -p "${root}/platform" \ + "${root}/apps" \ + "${root}/conversion" + gitops_write_workflow + + if [[ "${CREATED_REPO}" -eq 1 ]]; then + cp "${GITOPS_DIR}/repo-README.md" "${GITOPS_WORKTREE}/README.md" + elif [[ -n "${GIT_PREFIX:-}" ]]; then + cp "${GITOPS_DIR}/repo-README.md" "${root}/README.md" + fi + + cp "${EXAMPLE_DIR}/functions.yaml" "${root}/platform/functions.yaml" + gitops_refresh_kustomizations + + gitops_git add -A + if gitops_git diff --cached --quiet; then + echo "Seed already present on main." + return + fi + gitops_git commit -m "seed: workflow, functions, empty apps" + gitops_git push -u origin "${GITOPS_BASE_BRANCH:-main}" +} + +# --------------------------------------------------------------------------- +# convctl image + in-cluster runner +# --------------------------------------------------------------------------- + +# kind load docker-image / image-archive both call +# `ctr images import --all-platforms`, which fails on Buildx attestation +# indexes ("content digest … not found"). Import one platform ourselves. +gitops_docker_platform() { + case "$(uname -m)" in + aarch64|arm64) echo linux/arm64 ;; + *) echo linux/amd64 ;; + esac +} + +gitops_kind_node() { + kind get nodes --name "${KIND_CLUSTER}" | head -n1 +} + +gitops_kind_import_image() { + local image="$1" + local node platform + node="$(gitops_kind_node)" + [[ -n "${node}" ]] || die "no kind nodes for cluster ${KIND_CLUSTER}" + platform="$(gitops_docker_platform)" + echo "Importing ${image} into ${node} (${platform})…" + docker save "${image}" | docker exec -i "${node}" \ + ctr --namespace=k8s.io images import --platform "${platform}" --digests --snapshotter=overlayfs - +} + +gitops_build_convctl_image() { + gitops_resolve_kind_cluster + local platform runner_flat + platform="$(gitops_docker_platform)" + runner_flat="xwidget-demo-actions-runner:dev" + mkdir -p "${GITOPS_DIR}/runner" + [[ -x "${REPO_ROOT}/bin/convctl" ]] || die "bin/convctl missing; demo.sh should have built it" + cp "${REPO_ROOT}/bin/convctl" "${GITOPS_DIR}/runner/convctl" + docker build --platform "${platform}" --provenance=false --sbom=false \ + -t "${GITOPS_CONVCTL_IMAGE}" -f "${GITOPS_DIR}/runner/Dockerfile" "${GITOPS_DIR}/runner" + rm -f "${GITOPS_DIR}/runner/convctl" + + echo "Pulling ${GITOPS_RUNNER_IMAGE} and flattening to ${runner_flat} (no attestations)…" + docker pull --platform "${platform}" "${GITOPS_RUNNER_IMAGE}" + docker build --platform "${platform}" --provenance=false --sbom=false \ + -t "${runner_flat}" - </dev/null 2>&1 || true + kubectl delete pod -n actions-runner -l app.kubernetes.io/name=xwidget-demo-runner --wait=true --timeout=120s >/dev/null 2>&1 || true + gitops_remove_github_runner + # Broker sessions linger a few seconds after DELETE. + sleep 5 + + name="kind-xwidget-demo-$(date +%s)" + echo "Requesting Actions runner registration token for ${GITHUB_REPO} (name ${name})…" + token="$(gh api -X POST "repos/${GITHUB_REPO}/actions/runners/registration-token" --jq .token)" + [[ -n "${token}" ]] || die "failed to get Actions runner registration token for ${GITHUB_REPO}" + + kubectl create namespace actions-runner --dry-run=client -o yaml | kubectl apply -f - + kubectl -n actions-runner delete configmap runner-entrypoint --ignore-not-found + kubectl -n actions-runner create configmap runner-entrypoint \ + --from-file=entrypoint.sh="${GITOPS_DIR}/runner/entrypoint.sh" + kubectl -n actions-runner delete secret runner-auth --ignore-not-found + kubectl -n actions-runner create secret generic runner-auth \ + --from-literal=repo="${GITHUB_REPO}" \ + --from-literal=token="${token}" \ + --from-literal=name="${name}" + sed -e "s|xwidget-demo-convctl:dev|${GITOPS_CONVCTL_IMAGE}|g" \ + -e "s|ghcr.io/actions/actions-runner:2.336.0|${GITOPS_RUNNER_IMAGE}|g" \ + "${GITOPS_DIR}/runner/manifests.yaml" | kubectl apply -f - + + kubectl -n actions-runner rollout status deployment/xwidget-demo-runner --timeout=180s + INSTALLED_RUNNER=1 +} + +gitops_wait_runner() { + echo "Waiting for GitHub to see runner label ${GITOPS_RUNNER_LABEL} online…" + local i status + for i in $(seq 1 60); do + status="$(gh api "repos/${GITHUB_REPO}/actions/runners" \ + --jq ".runners[] | select(.labels[]?.name==\"${GITOPS_RUNNER_LABEL}\") | .status" \ + 2>/dev/null | head -n1 || true)" + if [[ "${status}" == online ]]; then + echo "Runner is online." + return 0 + fi + sleep 5 + done + die "self-hosted runner did not come online (check kubectl -n actions-runner logs)" +} + +# --------------------------------------------------------------------------- +# Flux / Argo +# --------------------------------------------------------------------------- + +gitops_git_secret_token() { + gh auth token +} + +gitops_install_flux() { + echo "Installing Flux ${GITOPS_FLUX_VERSION}…" + kubectl apply --server-side --force-conflicts \ + -f "https://github.com/fluxcd/flux2/releases/download/${GITOPS_FLUX_VERSION}/install.yaml" + kubectl -n flux-system wait --for=condition=Available --timeout=300s deployment --all + + local url="https://github.com/${GITHUB_REPO}" + kubectl -n flux-system delete secret gitops-auth --ignore-not-found + kubectl -n flux-system create secret generic gitops-auth \ + --from-literal=username=git \ + --from-literal=password="$(gitops_git_secret_token)" + + kubectl apply -f - </dev/null + helm repo update argo >/dev/null + helm upgrade --install argocd argo/argo-cd \ + --namespace argocd --create-namespace \ + --version "${GITOPS_ARGO_CHART_VERSION}" \ + --set configs.cm."timeout\.reconciliation"=30s \ + --wait --timeout 10m + + kubectl -n argocd delete secret "repo-${GITOPS_APP_NAME}" --ignore-not-found + kubectl -n argocd create secret generic "repo-${GITOPS_APP_NAME}" \ + --from-literal=type=git \ + --from-literal=url="https://github.com/${GITHUB_REPO}" \ + --from-literal=username=git \ + --from-literal=password="$(gitops_git_secret_token)" + kubectl -n argocd label secret "repo-${GITOPS_APP_NAME}" \ + argocd.argoproj.io/secret-type=repository --overwrite + + local path + path="$(gitops_rel_prefix)" + kubectl apply -f - </dev/null + kubectl -n flux-system annotate kustomization "${GITOPS_APP_NAME}" \ + "reconcile.fluxcd.io/requestedAt=${now}" --overwrite >/dev/null + kubectl -n flux-system annotate kustomization "${GITOPS_APP_NAME}-conversion" \ + "reconcile.fluxcd.io/requestedAt=${now}" --overwrite >/dev/null 2>/dev/null || true + kubectl -n flux-system annotate kustomization "${GITOPS_APP_NAME}-apps" \ + "reconcile.fluxcd.io/requestedAt=${now}" --overwrite >/dev/null 2>/dev/null || true + ;; + argo) + kubectl -n argocd patch application "${GITOPS_APP_NAME}" --type merge \ + -p '{"operation":{"initiatedBy":{"username":"xwidget-demo"},"sync":{"revision":"HEAD"}}}' \ + >/dev/null 2>&1 || true + ;; + esac +} + +# kubectl wait --for=condition=Ready returns immediately if the Kustomization +# is still Ready from the *previous* commit (seed, last stage). Wait until +# Flux/Argo has attempted the SHA we just merged. +gitops_wait_sync() { + local want + want="$(gitops_head_sha)" + echo "Waiting for ${GITOPS_ENGINE} to reconcile ${want:0:12}…" + case "${GITOPS_ENGINE}" in + flux) gitops_wait_flux "${want}" ;; + argo) gitops_wait_argo "${want}" ;; + *) die "gitops_wait_sync: unknown engine ${GITOPS_ENGINE}" ;; + esac +} + +gitops_flux_revision() { + kubectl -n flux-system get kustomization "$1" \ + -o jsonpath='{.status.lastAppliedRevision}' 2>/dev/null || true +} + +gitops_wait_flux() { + local want="$1" i src attempted applied ready msg conv apps + local kyverno_kicked=0 + local have_apps=0 + kubectl -n flux-system get kustomization "${GITOPS_APP_NAME}-apps" >/dev/null 2>&1 && have_apps=1 + for i in $(seq 1 72); do + if [[ $((i % 6)) -eq 1 ]]; then + gitops_request_reconcile + fi + src="$(kubectl -n flux-system get gitrepository "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.artifact.revision}' 2>/dev/null || true)" + attempted="$(kubectl -n flux-system get kustomization "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.lastAttemptedRevision}' 2>/dev/null || true)" + applied="$(gitops_flux_revision "${GITOPS_APP_NAME}")" + conv="$(gitops_flux_revision "${GITOPS_APP_NAME}-conversion")" + apps="$(gitops_flux_revision "${GITOPS_APP_NAME}-apps")" + ready="$(kubectl -n flux-system get kustomization "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)" + msg="$(kubectl -n flux-system get kustomization "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true)" + if [[ "${applied}" == *"${want}"* && "${conv}" == *"${want}"* ]]; then + if [[ "${have_apps}" -eq 0 || "${apps}" == *"${want}"* ]]; then + echo "Flux applied ${want:0:12} (platform + conversion${have_apps:+ + apps})." + kyverno_refresh_if_stale + return 0 + fi + fi + # Flux is already failing on a GVR Kyverno has not discovered. Restart + # now — waiting until Ready would never call the post-success refresh. + if [[ "${kyverno_kicked}" -eq 0 ]] && kyverno_discovery_stale_msg "${msg}"; then + kyverno_restart_admission + gitops_request_reconcile + kyverno_kicked=1 + fi + if [[ "${attempted}" == *"${want}"* && "${ready}" != True ]]; then + echo "Flux is applying ${want:0:12}: ${msg:-not Ready yet}" + elif [[ "${applied}" == *"${want}"* && "${conv}" != *"${want}"* ]]; then + echo "Flux platform applied ${want:0:12}; waiting for conversion Kustomization…" + fi + sleep 5 + done + echo "Flux GitRepository revision: ${src:-?}" >&2 + echo "Flux lastAttemptedRevision: ${attempted:-?}" >&2 + echo "Flux lastAppliedRevision: ${applied:-?}" >&2 + echo "Flux conversion lastAppliedRevision: ${conv:-?}" >&2 + echo "Flux Ready: ${ready:-?} ${msg}" >&2 + die "Flux did not reconcile ${want:0:12}" +} + +gitops_wait_argo() { + local want="$1" i sync health rev + for i in $(seq 1 60); do + if [[ $((i % 6)) -eq 1 ]]; then + gitops_request_reconcile + fi + sync="$(kubectl -n argocd get application "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.sync.status}' 2>/dev/null || true)" + health="$(kubectl -n argocd get application "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.health.status}' 2>/dev/null || true)" + rev="$(kubectl -n argocd get application "${GITOPS_APP_NAME}" \ + -o jsonpath='{.status.sync.revision}' 2>/dev/null || true)" + if [[ "${rev}" == "${want}"* && "${sync}" == Synced ]]; then + echo "Argo synced ${want:0:12} (health=${health:-?})." + kyverno_refresh_if_stale + return 0 + fi + sleep 5 + done + die "Argo Application ${GITOPS_APP_NAME} did not sync ${want:0:12} (sync=${sync:-?} rev=${rev:-?})" +} + +# --------------------------------------------------------------------------- +# Ship a stage as a PR +# --------------------------------------------------------------------------- + +gitops_copy_named_yaml() { + local src="$1" dest_dir="$2" + local name + name="$(gitops_yaml_name "${src}")" + [[ -n "${name}" ]] || name="$(basename "${src}" .yaml)" + mkdir -p "${dest_dir}" + cp "${src}" "${dest_dir}/${name}.yaml" +} + +# convctl generate kyverno emits labeler + migrate in one file. Kustomize +# rejects duplicate GVKs, so live ship keeps the standing labeler and +# writes the migrate document to metadata.name.yaml. A hub flip updates +# --from/--to on the same MutatingPolicy; do not mint a second object. +gitops_copy_policy() { + local src="$1" dest_dir="$2" + mkdir -p "${dest_dir}" + if grep -qE '^ name: (set-composition-version-selector-|migrate-)' "${src}"; then + local tmp name + tmp="$(mktemp)" + awk ' + BEGIN { doc = ""; keep = 0 } + /^---[[:space:]]*$/ { + if (keep && doc != "") printf "%s", doc + doc = ""; keep = 0 + next + } + { doc = doc $0 "\n" } + /^ name: set-composition-version-selector-/ { keep = 1 } + /^ name: migrate-/ { keep = 1 } + END { if (keep && doc != "") printf "%s", doc } + ' "${src}" > "${tmp}" + name="$(awk '/^metadata:/{m=1} m && /^ name:/{print $2; exit}' "${tmp}")" + [[ -n "${name}" ]] || name="$(basename "${src}" .yaml)" + # Drop leftover versioned generate dumps so kustomize sees one migrate doc. + rm -f "${dest_dir}"/from-v*-to-v*.yaml "${dest_dir}"/migrate-*.yaml + mv "${tmp}" "${dest_dir}/${name}.yaml" + else + cp "${src}" "${dest_dir}/$(basename "${src}")" + fi +} + +gitops_copy_stage() { + local stage="$1" + local skip_xrd="${2:-0}" + local skip_config="${3:-0}" + local xrd_override="${4:-}" + local config_override="${5:-}" + local omit_config="${6:-0}" + local root + root="$(gitops_manifest_root)" + + [[ -d "${EXAMPLE_DIR}/${stage}" ]] || die "stage directory not found: ${stage}" + + if [[ -n "${xrd_override}" ]]; then + cp "${EXAMPLE_DIR}/${xrd_override}" "${root}/platform/xrd.yaml" + elif [[ "${skip_xrd}" -eq 0 && -f "${EXAMPLE_DIR}/${stage}/xrd.yaml" ]]; then + cp "${EXAMPLE_DIR}/${stage}/xrd.yaml" "${root}/platform/xrd.yaml" + fi + + mkdir -p "${root}/conversion" + rm -f "${root}/platform/xrdconversionconfig.yaml" + if [[ "${omit_config:-0}" -eq 1 ]]; then + rm -f "${root}/conversion/xrdconversionconfig.yaml" + elif [[ -n "${config_override}" ]]; then + cp "${EXAMPLE_DIR}/${config_override}" "${root}/conversion/xrdconversionconfig.yaml" + elif [[ "${skip_config}" -eq 0 && -f "${EXAMPLE_DIR}/${stage}/xrdconversionconfig.yaml" ]]; then + cp "${EXAMPLE_DIR}/${stage}/xrdconversionconfig.yaml" "${root}/conversion/xrdconversionconfig.yaml" + fi + + if [[ -f "${EXAMPLE_DIR}/${stage}/composition.yaml" ]]; then + gitops_copy_named_yaml \ + "${EXAMPLE_DIR}/${stage}/composition.yaml" \ + "${root}/platform/compositions" + fi + + if [[ -d "${EXAMPLE_DIR}/${stage}/samples" ]]; then + rm -rf "${root}/platform/samples" + cp -a "${EXAMPLE_DIR}/${stage}/samples" "${root}/platform/samples" + fi +} + +gitops_wait_pr_checks() { + local pr="$1" + local want_sha="${2:-}" + if [[ -z "${want_sha}" ]]; then + want_sha="$(gh pr view "${pr}" --repo "${GITHUB_REPO}" --json headRefOid --jq .headRefOid 2>/dev/null || true)" + fi + echo "Waiting for checks on ${pr}${want_sha:+ at ${want_sha:0:7}}…" + local i n + for i in $(seq 1 60); do + if [[ -n "${want_sha}" ]]; then + n="$(gh api "repos/${GITHUB_REPO}/commits/${want_sha}/check-runs" --jq '.total_count' 2>/dev/null || echo 0)" + else + n="$(gh pr view "${pr}" --repo "${GITHUB_REPO}" --json statusCheckRollup \ + --jq '.statusCheckRollup | length' 2>/dev/null || echo 0)" + fi + if [[ "${n}" -gt 0 ]]; then + break + fi + sleep 5 + done + gh pr checks "${pr}" --repo "${GITHUB_REPO}" --watch +} + +gitops_ship() { + local branch="" title="" body="" stage="" + local skip_xrd=0 skip_config=0 omit_config=0 expect_fail=0 fix_open_pr=0 + local xrd_override="" config_override="" + local apps=() policies=() extras=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --branch) branch="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --body) body="$2"; shift 2 ;; + --stage) stage="$2"; shift 2 ;; + --skip-xrd) skip_xrd=1; shift ;; + --skip-config) skip_config=1; shift ;; + --omit-config) omit_config=1; skip_config=1; shift ;; + --xrd) xrd_override="$2"; shift 2 ;; + --config-override) config_override="$2"; shift 2 ;; + --app) apps+=("$2"); shift 2 ;; + --policy) policies+=("$2"); shift 2 ;; + --extra) extras+=("$2"); shift 2 ;; + --expect-fail) expect_fail=1; shift ;; + --fix-open-pr) fix_open_pr=1; shift ;; + *) die "gitops_ship: unknown flag $1" ;; + esac + done + [[ -n "${title}" ]] || die "gitops_ship: --title is required" + local base="${GITOPS_BASE_BRANCH:-main}" + local pr="" + if [[ "${fix_open_pr}" -eq 1 ]]; then + [[ -n "${GITOPS_OPEN_PR:-}" && -n "${GITOPS_OPEN_BRANCH:-}" ]] \ + || die "gitops_ship --fix-open-pr: no open expect-fail PR (run --expect-fail first)" + [[ -n "${stage}" ]] || die "gitops_ship --fix-open-pr: --stage is required" + branch="${GITOPS_OPEN_BRANCH}" + pr="${GITOPS_OPEN_PR}" + gitops_git checkout "${branch}" + config_override="" + else + [[ -n "${branch}" ]] || die "gitops_ship: --branch is required" + gitops_git checkout "${base}" + gitops_git pull --ff-only origin "${base}" || true + if gitops_git show-ref --verify --quiet "refs/heads/${branch}"; then + branch="${branch}-$(date +%s)" + fi + gitops_git checkout -b "${branch}" + fi + + local root + root="$(gitops_manifest_root)" + mkdir -p "${root}/platform" "${root}/apps" + + if [[ -n "${stage}" ]]; then + gitops_copy_stage "${stage}" "${skip_xrd}" "${skip_config}" "${xrd_override}" "${config_override}" "${omit_config}" + elif [[ -n "${xrd_override}" || -n "${config_override}" ]]; then + gitops_copy_stage "06-deprecate-v1" "${skip_xrd}" "${skip_config}" "${xrd_override}" "${config_override}" "${omit_config}" + fi + + local f + for f in "${apps[@]+"${apps[@]}"}"; do + cp "${EXAMPLE_DIR}/${f}" "${root}/apps/$(basename "${f}")" + done + for f in "${policies[@]+"${policies[@]}"}"; do + gitops_copy_policy "${EXAMPLE_DIR}/${f}" "${root}/platform/policies" + done + for f in "${extras[@]+"${extras[@]}"}"; do + gitops_copy_named_yaml "${EXAMPLE_DIR}/${f}" "${root}/platform/extras" + done + + gitops_refresh_kustomizations + gitops_write_workflow + + gitops_git add -A + if gitops_git diff --cached --quiet; then + echo "No changes to ship for ${title}; already on ${base}." + gitops_git checkout "${base}" + gitops_wait_sync + return 0 + fi + gitops_git commit -m "${title}" + gitops_git push -u origin "${branch}" + + if [[ "${fix_open_pr}" -eq 1 ]]; then + echo "Pushed fix to ${pr}" + gh pr edit "${pr}" --repo "${GITHUB_REPO}" --title "${title}" || true + local sha rc=0 + sha="$(gitops_git rev-parse HEAD)" + gitops_wait_pr_checks "${pr}" "${sha}" || rc=$? + if [[ "${rc}" -ne 0 ]]; then + gitops_git checkout "${base}" + die "CI failed for fix-up on ${pr}" + fi + gh pr merge "${pr}" --repo "${GITHUB_REPO}" --squash --delete-branch + GITOPS_OPEN_PR="" + GITOPS_OPEN_BRANCH="" + gitops_git checkout "${base}" + gitops_git pull --ff-only origin "${base}" + gitops_wait_sync + return 0 + fi + + [[ -n "${body}" ]] || body="Shipped by examples/crossplane-xr-multiversion/demo.sh (${title})." + pr="$(gh pr create --repo "${GITHUB_REPO}" \ + --base "${base}" --head "${branch}" \ + --title "${title}" \ + --body "${body}")" + echo "Opened ${pr}" + + local sha rc=0 + sha="$(gitops_git rev-parse HEAD)" + gitops_wait_pr_checks "${pr}" "${sha}" || rc=$? + + if [[ "${expect_fail}" -eq 1 ]]; then + if [[ "${rc}" -eq 0 ]]; then + gh pr close "${pr}" --repo "${GITHUB_REPO}" --delete-branch \ + --comment "demo: expected convctl CI to fail, but it passed" || true + gitops_git checkout "${base}" + die "expected CI to fail for ${title}" + fi + echo "CI failed as expected. Leaving ${pr} open for a fix-up push." + GITOPS_OPEN_PR="${pr}" + GITOPS_OPEN_BRANCH="${branch}" + return 0 + fi + + if [[ "${rc}" -ne 0 ]]; then + gitops_git checkout "${base}" + die "CI failed for ${pr}" + fi + + gh pr merge "${pr}" --repo "${GITHUB_REPO}" --squash --delete-branch + gitops_git checkout "${base}" + gitops_git pull --ff-only origin "${base}" + gitops_wait_sync +} + +# --------------------------------------------------------------------------- +# Bootstrap / cleanup +# --------------------------------------------------------------------------- + +gitops_bootstrap() { + gitops_require_tools + gitops_resolve_repo + gitops_create_or_use_repo + gitops_clone_worktree + gitops_seed_main + gitops_build_convctl_image + gitops_install_runner + gitops_wait_runner + case "${GITOPS_ENGINE}" in + flux) gitops_install_flux ;; + argo) gitops_install_argo ;; + *) die "unknown --gitops-engine ${GITOPS_ENGINE}" ;; + esac + gitops_state_write + gitops_wait_sync + echo "Waiting for composition functions from git…" + kubectl wait --for=condition=Healthy --timeout=180s function/function-go-templating + kubectl wait --for=condition=Healthy --timeout=180s function/function-auto-ready +} + +gitops_remove_github_runner() { + local id + # Remove every demo runner (fixed name or kind-xwidget-demo-). + while read -r id; do + [[ -z "${id}" ]] && continue + gh api -X DELETE "repos/${GITHUB_REPO}/actions/runners/${id}" >/dev/null 2>&1 || true + done < <(gh api "repos/${GITHUB_REPO}/actions/runners" --jq \ + '.runners[] | select((.name | startswith("kind-xwidget-demo")) or ([.labels[].name] | index("xwidget-demo"))) | .id' \ + 2>/dev/null || true) +} + +# Delete demo Flux/Argo apps so they stop recreating objects. Safe when +# those engines are not installed. +gitops_stop_reconcile() { + kubectl delete kustomization "${GITOPS_APP_NAME}" "${GITOPS_APP_NAME}-conversion" "${GITOPS_APP_NAME}-apps" -n flux-system --ignore-not-found >/dev/null 2>&1 || true + kubectl delete application "${GITOPS_APP_NAME}" "${GITOPS_APP_NAME}-conversion" -n argocd --ignore-not-found >/dev/null 2>&1 || true +} + +gitops_cleanup_cluster() { + gitops_state_load + if [[ "${INSTALLED_RUNNER:-0}" -eq 1 || "${GITOPS_ENGINE:-}" == flux || "${GITOPS_ENGINE:-}" == argo ]]; then + if [[ -n "${GITHUB_REPO:-}" ]]; then + gitops_remove_github_runner + fi + kubectl delete -f "${GITOPS_DIR}/runner/manifests.yaml" --wait=true --timeout=120s >/dev/null 2>&1 || true + kubectl delete namespace actions-runner --wait=true --timeout=120s >/dev/null 2>&1 || true + kubectl delete clusterrole xwidget-demo-runner --ignore-not-found >/dev/null 2>&1 || true + kubectl delete clusterrolebinding xwidget-demo-runner --ignore-not-found >/dev/null 2>&1 || true + fi + if [[ "${INSTALLED_ENGINE:-0}" -eq 1 || "${GITOPS_ENGINE:-}" == flux ]]; then + kubectl delete kustomization "${GITOPS_APP_NAME}" "${GITOPS_APP_NAME}-conversion" "${GITOPS_APP_NAME}-apps" -n flux-system --ignore-not-found >/dev/null 2>&1 || true + kubectl delete gitrepository "${GITOPS_APP_NAME}" -n flux-system --ignore-not-found >/dev/null 2>&1 || true + kubectl delete namespace flux-system --wait=true --timeout=180s >/dev/null 2>&1 || true + fi + if [[ "${INSTALLED_ENGINE:-0}" -eq 1 || "${GITOPS_ENGINE:-}" == argo ]]; then + kubectl delete application "${GITOPS_APP_NAME}" "${GITOPS_APP_NAME}-conversion" -n argocd --ignore-not-found >/dev/null 2>&1 || true + helm uninstall argocd -n argocd >/dev/null 2>&1 || true + kubectl delete namespace argocd --wait=true --timeout=180s >/dev/null 2>&1 || true + fi +} + +gitops_cleanup_repo() { + gitops_state_load + if [[ "${DELETE_REPO:-0}" -eq 1 ]]; then + if [[ -n "${GITHUB_REPO_FLAG:-}" ]]; then + echo "note: --delete-repo ignored (never deleting a --github-repo you passed in)." >&2 + else + # --create-repo on this command names the demo-owned repo, even when + # .demo-state is missing (bootstrap died after gh repo create). + if [[ "${CREATE_REPO:-0}" -eq 1 ]]; then + gitops_resolve_repo + CREATED_REPO=1 + fi + if [[ "${CREATED_REPO:-0}" -ne 1 ]]; then + echo "note: --delete-repo ignored (no demo-created repo recorded). Re-run with --create-repo --cleanup --delete-repo." >&2 + elif [[ -z "${GITHUB_REPO:-}" ]]; then + echo "note: --delete-repo set but no GITHUB_REPO in ${GITOPS_STATE_FILE}." >&2 + else + echo "Deleting GitHub repo ${GITHUB_REPO} (created by this demo)…" + gh repo delete "${GITHUB_REPO}" --yes + fi + fi + fi + rm -rf "${GITOPS_WORKTREE}" + # Keep .demo-state when we created a repo but did not delete it, so a later + # --cleanup --delete-repo can still see CREATED_REPO=1. + if [[ "${DELETE_REPO:-0}" -eq 1 || "${CREATED_REPO:-0}" -ne 1 ]]; then + rm -f "${GITOPS_STATE_FILE}" + fi +} diff --git a/examples/crossplane-xr-multiversion/gitops/platform/README.md b/examples/crossplane-xr-multiversion/gitops/platform/README.md new file mode 100644 index 0000000..1766808 --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/platform/README.md @@ -0,0 +1,20 @@ +# Platform snapshots + +These are the parent-directory stage trees. They are not copied here so the +XRD, conversion config, and Composition stay a single source of truth. + +| GitOps revision | Apply | +|---|---| +| v1 only | [`../../01-v1-only/`](../../01-v1-only/) (`xrd.yaml`, `composition.yaml`) | +| Add v2 spoke | [`../../02-add-v2/`](../../02-add-v2/) | +| Promote v2 | [`../../03-promote-v2/`](../../03-promote-v2/) | +| Add v3 spoke | [`../../04-add-v3/`](../../04-add-v3/) | +| Promote v3 | [`../../05-promote-v3/`](../../05-promote-v3/) | + +In a real platform repo you would copy those files (or vendor this example) +next to [`../policies/`](../policies/). Composition names stay versioned +(`xwidgets-v2.example.org`) because `compositeTypeRef` is immutable. + +`--gitops-engine flux|argo` does that copy into the demo GitHub repo +(`platform/` + `apps/`) via [`../lib.sh`](../lib.sh). Simulate mode keeps +applying the parent stage directories directly. diff --git a/examples/crossplane-xr-multiversion/gitops/policies/from-v1-to-v2.yaml b/examples/crossplane-xr-multiversion/gitops/policies/from-v1-to-v2.yaml new file mode 100644 index 0000000..d97bba5 --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/policies/from-v1-to-v2.yaml @@ -0,0 +1,148 @@ +# Generated by convctl generate kyverno. Review before apply. convctl never applies this. +apiVersion: policies.kyverno.io/v1 +kind: MutatingPolicy +metadata: + name: label-compositions-xwidgets +spec: + evaluation: + admission: + enabled: true + mutateExisting: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: + - apiextensions.crossplane.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - compositions + mutations: + - applyConfiguration: + expression: |- + ( + has(object.spec.compositeTypeRef) && has(object.spec.compositeTypeRef.kind) && object.spec.compositeTypeRef.kind == "XWidget" && has(object.spec.compositeTypeRef.apiVersion) && object.spec.compositeTypeRef.apiVersion.startsWith("example.org/") + ? + Object{ + metadata: Object.metadata{ + labels: { + "xrd-api-version": object.spec.compositeTypeRef.apiVersion.split("/")[1] + } + } + } + : + Object{} + ) + patchType: ApplyConfiguration +--- +apiVersion: policies.kyverno.io/v1 +kind: MutatingPolicy +metadata: + name: set-composition-version-selector-xwidgets +spec: + evaluation: + admission: + enabled: true + mutateExisting: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: + - example.org + apiVersions: + - v2 + - v1 + operations: + - CREATE + - UPDATE + resources: + - xwidgets + mutations: + - jsonPatch: + expression: |- + ( + (oldObject != null && has(oldObject.spec) ? (has(oldObject.spec.crossplane) && (!has(oldObject.spec.crossplane.compositionSelector) || !has(oldObject.spec.crossplane.compositionSelector.matchLabels) || !("xrd-api-version" in oldObject.spec.crossplane.compositionSelector.matchLabels) || oldObject.spec.crossplane.compositionSelector.matchLabels["xrd-api-version"] == "v1")) : (has(object.spec.crossplane) && (!has(object.spec.crossplane.compositionSelector) || !has(object.spec.crossplane.compositionSelector.matchLabels) || !("xrd-api-version" in object.spec.crossplane.compositionSelector.matchLabels) || object.spec.crossplane.compositionSelector.matchLabels["xrd-api-version"] == "v1"))) + ? + ( + ( + has(object.spec.crossplane.compositionRef) + ? + [ + JSONPatch{ + op: "remove", + path: "/spec/crossplane/compositionRef" + } + ] + : + [] + ) + + + ( + has(object.spec.crossplane.compositionRevisionRef) + ? + [ + JSONPatch{ + op: "remove", + path: "/spec/crossplane/compositionRevisionRef" + } + ] + : + [] + ) + + + ( + has(object.spec.crossplane.compositionSelector) && + has(object.spec.crossplane.compositionSelector.matchLabels) && + ("xrd-api-version" in object.spec.crossplane.compositionSelector.matchLabels) + ? + [ + JSONPatch{ + op: "replace", + path: "/spec/crossplane/compositionSelector/matchLabels/xrd-api-version", + value: "v2" + } + ] + : + has(object.spec.crossplane.compositionSelector) && + has(object.spec.crossplane.compositionSelector.matchLabels) + ? + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector/matchLabels/xrd-api-version", + value: "v2" + } + ] + : + has(object.spec.crossplane.compositionSelector) + ? + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector/matchLabels", + value: { + "xrd-api-version": "v2" + } + } + ] + : + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector", + value: { + "matchLabels": { + "xrd-api-version": "v2" + } + } + } + ] + ) + ) + : + [] + ) + patchType: JSONPatch diff --git a/examples/crossplane-xr-multiversion/gitops/policies/from-v2-to-v3.yaml b/examples/crossplane-xr-multiversion/gitops/policies/from-v2-to-v3.yaml new file mode 100644 index 0000000..25f165e --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/policies/from-v2-to-v3.yaml @@ -0,0 +1,149 @@ +# Generated by convctl generate kyverno. Review before apply. convctl never applies this. +apiVersion: policies.kyverno.io/v1 +kind: MutatingPolicy +metadata: + name: label-compositions-xwidgets +spec: + evaluation: + admission: + enabled: true + mutateExisting: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: + - apiextensions.crossplane.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - compositions + mutations: + - applyConfiguration: + expression: |- + ( + has(object.spec.compositeTypeRef) && has(object.spec.compositeTypeRef.kind) && object.spec.compositeTypeRef.kind == "XWidget" && has(object.spec.compositeTypeRef.apiVersion) && object.spec.compositeTypeRef.apiVersion.startsWith("example.org/") + ? + Object{ + metadata: Object.metadata{ + labels: { + "xrd-api-version": object.spec.compositeTypeRef.apiVersion.split("/")[1] + } + } + } + : + Object{} + ) + patchType: ApplyConfiguration +--- +apiVersion: policies.kyverno.io/v1 +kind: MutatingPolicy +metadata: + name: set-composition-version-selector-xwidgets +spec: + evaluation: + admission: + enabled: true + mutateExisting: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: + - example.org + apiVersions: + - v3 + - v2 + - v1 + operations: + - CREATE + - UPDATE + resources: + - xwidgets + mutations: + - jsonPatch: + expression: |- + ( + (oldObject != null && has(oldObject.spec) ? (has(oldObject.spec.crossplane) && (!has(oldObject.spec.crossplane.compositionSelector) || !has(oldObject.spec.crossplane.compositionSelector.matchLabels) || !("xrd-api-version" in oldObject.spec.crossplane.compositionSelector.matchLabels) || oldObject.spec.crossplane.compositionSelector.matchLabels["xrd-api-version"] == "v2")) : (has(object.spec.crossplane) && (!has(object.spec.crossplane.compositionSelector) || !has(object.spec.crossplane.compositionSelector.matchLabels) || !("xrd-api-version" in object.spec.crossplane.compositionSelector.matchLabels) || object.spec.crossplane.compositionSelector.matchLabels["xrd-api-version"] == "v2"))) + ? + ( + ( + has(object.spec.crossplane.compositionRef) + ? + [ + JSONPatch{ + op: "remove", + path: "/spec/crossplane/compositionRef" + } + ] + : + [] + ) + + + ( + has(object.spec.crossplane.compositionRevisionRef) + ? + [ + JSONPatch{ + op: "remove", + path: "/spec/crossplane/compositionRevisionRef" + } + ] + : + [] + ) + + + ( + has(object.spec.crossplane.compositionSelector) && + has(object.spec.crossplane.compositionSelector.matchLabels) && + ("xrd-api-version" in object.spec.crossplane.compositionSelector.matchLabels) + ? + [ + JSONPatch{ + op: "replace", + path: "/spec/crossplane/compositionSelector/matchLabels/xrd-api-version", + value: "v3" + } + ] + : + has(object.spec.crossplane.compositionSelector) && + has(object.spec.crossplane.compositionSelector.matchLabels) + ? + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector/matchLabels/xrd-api-version", + value: "v3" + } + ] + : + has(object.spec.crossplane.compositionSelector) + ? + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector/matchLabels", + value: { + "xrd-api-version": "v3" + } + } + ] + : + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector", + value: { + "matchLabels": { + "xrd-api-version": "v3" + } + } + } + ] + ) + ) + : + [] + ) + patchType: JSONPatch diff --git a/examples/crossplane-xr-multiversion/gitops/policies/kyverno-rbac.yaml b/examples/crossplane-xr-multiversion/gitops/policies/kyverno-rbac.yaml new file mode 100644 index 0000000..c5d0915 --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/policies/kyverno-rbac.yaml @@ -0,0 +1,39 @@ +# Extra verbs Kyverno's controllers need so MutatingPolicy is Ready and +# mutateExisting can label Compositions and retarget XWidgets. +# +# RBACPermissionsGranted is a *reports* check ("Policy is not ready for +# reporting"). Admission + background aggregation alone leaves the migrate +# policy Ready=false even when those SAs can patch. +# https://kyverno.io/docs/installation/customization/ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kyverno-xwidgets-view + labels: + app.kubernetes.io/part-of: declarative-conversion-operator + rbac.kyverno.io/aggregate-to-reports-controller: "true" + rbac.kyverno.io/aggregate-to-background-controller: "true" + rbac.kyverno.io/aggregate-to-admission-controller: "true" +rules: + - apiGroups: [example.org] + resources: [xwidgets] + verbs: [get, list, watch] + - apiGroups: [apiextensions.crossplane.io] + resources: [compositions] + verbs: [get, list, watch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kyverno-xwidgets-mutate + labels: + app.kubernetes.io/part-of: declarative-conversion-operator + rbac.kyverno.io/aggregate-to-background-controller: "true" + rbac.kyverno.io/aggregate-to-admission-controller: "true" +rules: + - apiGroups: [example.org] + resources: [xwidgets] + verbs: [patch, update] + - apiGroups: [apiextensions.crossplane.io] + resources: [compositions] + verbs: [patch, update] diff --git a/examples/crossplane-xr-multiversion/gitops/policies/label-compositions-xwidgets.yaml b/examples/crossplane-xr-multiversion/gitops/policies/label-compositions-xwidgets.yaml new file mode 100644 index 0000000..176cc51 --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/policies/label-compositions-xwidgets.yaml @@ -0,0 +1,40 @@ +# Generated by convctl generate kyverno. Review before apply. convctl never applies this. +# First document only (Composition labeler). The migrate document is applied at hub promote. +apiVersion: policies.kyverno.io/v1 +kind: MutatingPolicy +metadata: + name: label-compositions-xwidgets +spec: + evaluation: + admission: + enabled: true + mutateExisting: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: + - apiextensions.crossplane.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - compositions + mutations: + - applyConfiguration: + expression: |- + ( + has(object.spec.compositeTypeRef) && has(object.spec.compositeTypeRef.kind) && object.spec.compositeTypeRef.kind == "XWidget" && has(object.spec.compositeTypeRef.apiVersion) && object.spec.compositeTypeRef.apiVersion.startsWith("example.org/") + ? + Object{ + metadata: Object.metadata{ + labels: { + "xrd-api-version": object.spec.compositeTypeRef.apiVersion.split("/")[1] + } + } + } + : + Object{} + ) + patchType: ApplyConfiguration diff --git a/examples/crossplane-xr-multiversion/gitops/repo-README.md b/examples/crossplane-xr-multiversion/gitops/repo-README.md new file mode 100644 index 0000000..c1122d9 --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/repo-README.md @@ -0,0 +1,18 @@ +# XWidget lifecycle demo + +This repository is the GitOps desired state for the +[declarative-conversion-operator](https://github.com/terasky-oss/declarative-conversion-operator) +multi-version Crossplane XR walkthrough. + +| Path | Who | What | +|---|---|---| +| `platform/` | Platform | XRD, Compositions, Kyverno policies, functions | +| `conversion/` | Platform | `XRDConversionConfig` (same PR as the XRD; Flux applies this *after* the XRD is Ready) | +| `apps/` | App team | XWidgets. No `compositionRef`. | +| `.github/workflows/convctl.yaml` | CI | `convctl validate` / `test --samples` / `test --live` on the in-cluster runner; posts the output as a PR comment | + +PRs must go green on the self-hosted `xwidget-demo` runner before merge. +Flux or Argo then reconciles `main` onto the demo cluster. + +`convctl migrate-storage` is cluster housekeeping, not desired-state YAML — +it is run locally from the walkthrough, not from this repo. diff --git a/examples/crossplane-xr-multiversion/gitops/runner/.dockerignore b/examples/crossplane-xr-multiversion/gitops/runner/.dockerignore new file mode 100644 index 0000000..496044e --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/runner/.dockerignore @@ -0,0 +1,2 @@ +* +!convctl diff --git a/examples/crossplane-xr-multiversion/gitops/runner/Dockerfile b/examples/crossplane-xr-multiversion/gitops/runner/Dockerfile new file mode 100644 index 0000000..e7bb6ec --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/runner/Dockerfile @@ -0,0 +1,4 @@ +# Tiny image: only the convctl binary. demo.sh kind-loads this into the +# cluster; an init container copies /convctl onto the Actions runner pod. +FROM alpine:3.21 +COPY convctl /convctl diff --git a/examples/crossplane-xr-multiversion/gitops/runner/entrypoint.sh b/examples/crossplane-xr-multiversion/gitops/runner/entrypoint.sh new file mode 100755 index 0000000..f34f48e --- /dev/null +++ b/examples/crossplane-xr-multiversion/gitops/runner/entrypoint.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# GitHub Actions runner entrypoint for the kind demo cluster. +# Writes an in-cluster kubeconfig so `convctl test --live` uses the pod SA, +# then registers with GitHub and polls for jobs. +set -euo pipefail + +export PATH="/opt/convctl:${PATH}" + +mkdir -p "${HOME}/.kube" +cat > "${HOME}/.kube/config" <> "${GITHUB_PATH}" + + - name: convctl validate / test + id: convctl + env: + CONVCTL_ROOT: __CONVCTL_ROOT__ + run: | + set -euo pipefail + root="${CONVCTL_ROOT:-.}" + xrd="${root}/platform/xrd.yaml" + cfg="${root}/conversion/xrdconversionconfig.yaml" + if [[ ! -f "${cfg}" ]]; then + cfg="${root}/platform/xrdconversionconfig.yaml" + fi + samples="${root}/apps" + run_convctl() { + if [[ ! -f "${xrd}" ]]; then + echo "No XRD in this commit; skip convctl" + return 0 + fi + if [[ ! -f "${cfg}" ]]; then + echo "No conversion config yet (one-version XRD); skip convctl" + return 0 + fi + echo "=== validate ===" + convctl validate --config "${cfg}" --xrd "${xrd}" + if [[ -d "${samples}" ]]; then + echo + echo "=== test --samples (apps/ XRs of this XRD kind) ===" + convctl test --config "${cfg}" --xrd "${xrd}" --samples "${samples}" + fi + echo + echo "=== test --live ===" + convctl test --config "${cfg}" --xrd "${xrd}" --live + } + set +e + run_convctl > convctl-output.txt 2>&1 + rc=$? + set -e + cat convctl-output.txt + exit "${rc}" + + - name: Comment convctl output on the PR + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + let output = '(no convctl output)'; + try { + output = fs.readFileSync('convctl-output.txt', 'utf8'); + } catch (_) {} + if (output.length > 60000) { + output = output.slice(0, 60000) + '\n… truncated'; + } + const passed = '${{ steps.convctl.outcome }}' === 'success'; + const body = [ + marker, + '## convctl', + '', + `**Result:** ${passed ? 'passed' : 'failed'}`, + '', + '
', + 'output', + '', + '```', + output.replace(/```/g, "'''"), + '```', + '', + '
', + '', + ].join('\n'); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/internal/cli/generate.go b/internal/cli/generate.go new file mode 100644 index 0000000..8703a30 --- /dev/null +++ b/internal/cli/generate.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 The declarative-conversion-operator Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newGenerateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Short: "Emit helper manifests from an XRD (never applied)", + Long: `Print generated helper manifests to stdout. convctl never applies them. + +Use this when a GitOps repo should own the YAML — review the draft, commit it, +and let the cluster's controllers act on it.`, + } + cmd.AddCommand(newGenerateKyvernoCmd()) + return cmd +} + +func newGenerateKyvernoCmd() *cobra.Command { + var ( + xrdPath, to, from, labelKey string + compositionName, migrateName string + output string + ) + cmd := &cobra.Command{ + Use: "kyverno", + Short: "Draft Kyverno MutatingPolicies that retarget XRs onto a new hub Composition", + Long: `Print two policies.kyverno.io/v1 MutatingPolicies for an XRD that is evolving +its API. Nothing is applied. + + 1. A per-XRD Composition labeler. Admission writes xrd-api-version from + the version element of spec.compositeTypeRef (example.org/v2 → v2). + That label is never in git. XRD targeting (kind + group) lives in the + mutation CEL — Kyverno 1.18 silently ignores matchConditions that + read object.spec. XRDs that never change their API do not need this + policy — do not generate it for them. + 2. A standing XR migrate policy (one per XRD). Admission (and + mutateExisting) strips compositionRef and compositionRevisionRef and + sets compositionSelector.matchLabels to the --to version so Crossplane + re-selects. Re-generate with the new --from/--to on a hub flip and + apply the same metadata.name. Admission is required: Kyverno 1.18.1 + never runs MutatingPolicy mutateExisting in the background. + +Crossplane pins compositionRef at create time and ignores the selector until +that pin is removed. compositionUpdatePolicy: Automatic only walks revisions +of the already-pinned Composition. Do not use XRD enforcedCompositionRef to +chase hub versions — that field is immutable. + +--to must name a version on the XRD. --from, if set, limits the migrate +policy to XRs whose selector is missing or still equals that version +(a canary). Without --from, anything not already labeled --to is migrated.`, + RunE: func(cmd *cobra.Command, args []string) error { + switch output { + case "yaml", "json": + default: + return fmt.Errorf("invalid --output value %q (want yaml or json)", output) + } + docs, err := RunGenerateKyverno(GenerateKyvernoOptions{ + XRDPath: xrdPath, + To: to, + From: from, + LabelKey: labelKey, + CompositionPolicyName: compositionName, + MigratePolicyName: migrateName, + }) + if err != nil { + return err + } + if output == "json" { + return writeJSON(cmd, docs) + } + data, err := encodeKyvernoYAML(docs) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } + cmd.Flags().StringVarP(&xrdPath, "xrd", "x", "", "Path to an XRD YAML file (required)") + cmd.Flags().StringVar(&to, "to", "", "Target xrd-api-version label; must be a version on the XRD (required)") + cmd.Flags().StringVar(&from, "from", "", "Only migrate XRs whose selector is missing or equals this version (optional canary)") + cmd.Flags().StringVar(&labelKey, "label-key", defaultXRDAPIVersionLabel, "Label key written on Compositions and XR compositionSelector.matchLabels") + cmd.Flags().StringVar(&compositionName, "composition-policy-name", "", "Name of the Composition labeler (default label-compositions-)") + cmd.Flags().StringVar(&migrateName, "migrate-policy-name", "", "Name of the XR migrate policy (default set-composition-version-selector-)") + cmd.Flags().StringVarP(&output, "output", "o", "yaml", "Output format: yaml|json") + _ = cmd.MarkFlagRequired("xrd") + _ = cmd.MarkFlagRequired("to") + registerYAMLFileCompletions(cmd, "xrd") + registerOutputCompletions(cmd, "yaml", "json") + return cmd +} diff --git a/internal/cli/generate_kyverno.go b/internal/cli/generate_kyverno.go new file mode 100644 index 0000000..87ec7fd --- /dev/null +++ b/internal/cli/generate_kyverno.go @@ -0,0 +1,477 @@ +/* +Copyright 2026 The declarative-conversion-operator Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + "strings" + "unicode" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + sigsyaml "sigs.k8s.io/yaml" +) + +const ( + defaultXRDAPIVersionLabel = "xrd-api-version" + kyvernoMutatingAPIVersion = "policies.kyverno.io/v1" + kyvernoMutatingKind = "MutatingPolicy" +) + +// GenerateKyvernoOptions configures RunGenerateKyverno. +type GenerateKyvernoOptions struct { + XRDPath string + To string + From string + LabelKey string + CompositionPolicyName string + MigratePolicyName string +} + +type xrdIdentity struct { + Group string + Kind string + Plural string + Versions []string + ServedVersions []string +} + +// kyvernoMutatingPolicy is the subset of policies.kyverno.io/v1 MutatingPolicy +// this command emits. Struct field order keeps YAML/JSON goldens stable. +type kyvernoMutatingPolicy struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata kyvernoObjectMeta `json:"metadata"` + Spec kyvernoMutatingSpec `json:"spec"` +} + +type kyvernoObjectMeta struct { + Name string `json:"name"` +} + +type kyvernoMutatingSpec struct { + Evaluation kyvernoEvaluation `json:"evaluation"` + MatchConstraints kyvernoMatchConstraints `json:"matchConstraints"` + // MatchConditions must stay empty. Kyverno 1.18 can ignore a + // MutatingPolicy whose matchConditions read object.spec + // (kyverno/kyverno#15353). XRD / selector filters live in the mutation CEL. + MatchConditions []kyvernoMatchCondition `json:"matchConditions,omitempty"` + Mutations []kyvernoMutation `json:"mutations"` +} + +type kyvernoEvaluation struct { + Admission kyvernoToggle `json:"admission"` + MutateExisting kyvernoToggle `json:"mutateExisting"` +} + +type kyvernoToggle struct { + Enabled bool `json:"enabled"` +} + +type kyvernoMatchConstraints struct { + ResourceRules []kyvernoResourceRule `json:"resourceRules"` +} + +type kyvernoResourceRule struct { + APIGroups []string `json:"apiGroups"` + APIVersions []string `json:"apiVersions"` + Resources []string `json:"resources"` + Operations []string `json:"operations"` +} + +type kyvernoMatchCondition struct { + Name string `json:"name"` + Expression string `json:"expression"` +} + +type kyvernoMutation struct { + PatchType string `json:"patchType"` + ApplyConfiguration *kyvernoApplyConfiguration `json:"applyConfiguration,omitempty"` + JSONPatch *kyvernoJSONPatch `json:"jsonPatch,omitempty"` +} + +type kyvernoApplyConfiguration struct { + Expression string `json:"expression"` +} + +type kyvernoJSONPatch struct { + Expression string `json:"expression"` +} + +// RunGenerateKyverno drafts a per-XRD Composition labeler and a per-hub XR +// migrate MutatingPolicy. It never applies anything. +func RunGenerateKyverno(opts GenerateKyvernoOptions) ([]kyvernoMutatingPolicy, error) { + if opts.XRDPath == "" { + return nil, fmt.Errorf("--xrd is required") + } + if opts.To == "" { + return nil, fmt.Errorf("--to is required: name the XRD version the migrate policy writes") + } + labelKey := opts.LabelKey + if labelKey == "" { + labelKey = defaultXRDAPIVersionLabel + } + + xrd, err := LoadXRD(opts.XRDPath) + if err != nil { + return nil, err + } + id, err := xrdIdentityFrom(xrd) + if err != nil { + return nil, err + } + if !containsString(id.Versions, opts.To) { + return nil, fmt.Errorf("--to %q is not a version on the XRD (have %s)", opts.To, strings.Join(id.Versions, ", ")) + } + if opts.From != "" && !containsString(id.Versions, opts.From) { + return nil, fmt.Errorf("--from %q is not a version on the XRD (have %s)", opts.From, strings.Join(id.Versions, ", ")) + } + + compName := opts.CompositionPolicyName + if compName == "" { + compName = "label-compositions-" + dns1123Label(id.Plural) + } + migName := opts.MigratePolicyName + if migName == "" { + // Stable per-XRD name. A hub flip updates --from/--to on the + // same MutatingPolicy; do not mint migrate-*-to-vN objects. + migName = "set-composition-version-selector-" + dns1123Label(id.Plural) + } + + return []kyvernoMutatingPolicy{ + compositionLabelerPolicy(compName, id, labelKey), + xrMigratePolicy(migName, id, labelKey, opts.From, opts.To), + }, nil +} + +func xrdIdentityFrom(xrd *unstructured.Unstructured) (xrdIdentity, error) { + var id xrdIdentity + var err error + id.Group, _, err = unstructured.NestedString(xrd.Object, "spec", "group") + if err != nil { + return id, fmt.Errorf("reading spec.group: %w", err) + } + id.Kind, _, err = unstructured.NestedString(xrd.Object, "spec", "names", "kind") + if err != nil { + return id, fmt.Errorf("reading spec.names.kind: %w", err) + } + id.Plural, _, err = unstructured.NestedString(xrd.Object, "spec", "names", "plural") + if err != nil { + return id, fmt.Errorf("reading spec.names.plural: %w", err) + } + if id.Group == "" || id.Kind == "" || id.Plural == "" { + return id, fmt.Errorf("XRD %q must set spec.group, spec.names.kind, and spec.names.plural", xrd.GetName()) + } + + raw, found, err := unstructured.NestedSlice(xrd.Object, "spec", "versions") + if err != nil { + return id, fmt.Errorf("reading spec.versions: %w", err) + } + if !found || len(raw) == 0 { + return id, fmt.Errorf("XRD %q has no spec.versions", xrd.GetName()) + } + for i, item := range raw { + vm, ok := item.(map[string]any) + if !ok { + return id, fmt.Errorf("spec.versions[%d] is not an object", i) + } + name, _, err := unstructured.NestedString(vm, "name") + if err != nil || name == "" { + return id, fmt.Errorf("spec.versions[%d] has no name", i) + } + id.Versions = append(id.Versions, name) + served, found, err := unstructured.NestedBool(vm, "served") + if err != nil { + return id, fmt.Errorf("spec.versions[%d].served: %w", i, err) + } + if !found || served { + id.ServedVersions = append(id.ServedVersions, name) + } + } + if len(id.ServedVersions) == 0 { + return id, fmt.Errorf("XRD %q has no served versions", xrd.GetName()) + } + return id, nil +} + +func compositionLabelerPolicy(name string, id xrdIdentity, labelKey string) kyvernoMutatingPolicy { + return kyvernoMutatingPolicy{ + APIVersion: kyvernoMutatingAPIVersion, + Kind: kyvernoMutatingKind, + Metadata: kyvernoObjectMeta{Name: name}, + Spec: kyvernoMutatingSpec{ + Evaluation: kyvernoEvaluation{ + Admission: kyvernoToggle{Enabled: true}, + MutateExisting: kyvernoToggle{Enabled: true}, + }, + MatchConstraints: kyvernoMatchConstraints{ + ResourceRules: []kyvernoResourceRule{{ + APIGroups: []string{"apiextensions.crossplane.io"}, + APIVersions: []string{"v1"}, + Resources: []string{"compositions"}, + Operations: []string{"CREATE", "UPDATE"}, + }}, + }, + Mutations: []kyvernoMutation{{ + PatchType: "ApplyConfiguration", + ApplyConfiguration: &kyvernoApplyConfiguration{ + Expression: compositionLabelCEL(id, labelKey), + }, + }}, + }, + } +} + +func xrMigratePolicy(name string, id xrdIdentity, labelKey, from, to string) kyvernoMutatingPolicy { + return kyvernoMutatingPolicy{ + APIVersion: kyvernoMutatingAPIVersion, + Kind: kyvernoMutatingKind, + Metadata: kyvernoObjectMeta{Name: name}, + Spec: kyvernoMutatingSpec{ + Evaluation: kyvernoEvaluation{ + // Admission must be on. Kyverno 1.18.1 never creates + // UpdateRequests for MutatingPolicy mutateExisting + // (kyverno/kyverno#16255), so admission:false is a no-op. + Admission: kyvernoToggle{Enabled: true}, + MutateExisting: kyvernoToggle{Enabled: true}, + }, + MatchConstraints: kyvernoMatchConstraints{ + ResourceRules: []kyvernoResourceRule{{ + APIGroups: []string{id.Group}, + APIVersions: append([]string(nil), id.ServedVersions...), + Resources: []string{id.Plural}, + Operations: []string{"CREATE", "UPDATE"}, + }}, + }, + Mutations: []kyvernoMutation{{ + PatchType: "JSONPatch", + JSONPatch: &kyvernoJSONPatch{ + Expression: migrateJSONPatchCEL(labelKey, from, to), + }, + }}, + }, + } +} + +func compositionMatchCEL(id xrdIdentity) string { + return fmt.Sprintf( + `has(object.spec.compositeTypeRef) && has(object.spec.compositeTypeRef.kind) && object.spec.compositeTypeRef.kind == %s && has(object.spec.compositeTypeRef.apiVersion) && object.spec.compositeTypeRef.apiVersion.startsWith(%s)`, + celString(id.Kind), celString(id.Group+"/")) +} + +func compositionLabelCEL(id xrdIdentity, labelKey string) string { + // ApplyConfiguration Object.metadata.labels{ "hyphen-key": ... } is invalid + // CEL (quoted keys are not field identifiers). A map literal is. + return strings.TrimSpace(fmt.Sprintf(` +( + %s + ? + Object{ + metadata: Object.metadata{ + labels: { + %s: object.spec.compositeTypeRef.apiVersion.split("/")[1] + } + } + } + : + Object{} +) +`, compositionMatchCEL(id), celString(labelKey))) +} + +func migrateMatchCEL(labelKey, from, to string) string { + // UPDATE: decide from oldObject (the live XR). After the selector is + // --to, Crossplane writing compositionRef back must not rematch. + // CREATE: oldObject is null; decide from the incoming object. + live := migrateNeedsCEL("oldObject", labelKey, from, to) + incoming := migrateNeedsCEL("object", labelKey, from, to) + return fmt.Sprintf( + `(oldObject != null && has(oldObject.spec) ? (%s) : (%s))`, + live, incoming) +} + +func migrateNeedsCEL(obj, labelKey, from, to string) string { + key := celString(labelKey) + sel := obj + ".spec.crossplane.compositionSelector" + labels := sel + ".matchLabels" + cmp := fmt.Sprintf("%s[%s] != %s", labels, key, celString(to)) + if from != "" { + cmp = fmt.Sprintf("%s[%s] == %s", labels, key, celString(from)) + } + return fmt.Sprintf( + `has(%s.spec.crossplane) && (!has(%s) || !has(%s) || !(%s in %s) || %s)`, + obj, sel, labels, key, labels, cmp) +} + +func migrateJSONPatchCEL(labelKey, from, to string) string { + key := celString(labelKey) + val := celString(to) + ptr := jsonPointerEscape(labelKey) + return strings.TrimSpace(fmt.Sprintf(` +( + %s + ? + ( + ( + has(object.spec.crossplane.compositionRef) + ? + [ + JSONPatch{ + op: "remove", + path: "/spec/crossplane/compositionRef" + } + ] + : + [] + ) + + + ( + has(object.spec.crossplane.compositionRevisionRef) + ? + [ + JSONPatch{ + op: "remove", + path: "/spec/crossplane/compositionRevisionRef" + } + ] + : + [] + ) + + + ( + has(object.spec.crossplane.compositionSelector) && + has(object.spec.crossplane.compositionSelector.matchLabels) && + (%s in object.spec.crossplane.compositionSelector.matchLabels) + ? + [ + JSONPatch{ + op: "replace", + path: "/spec/crossplane/compositionSelector/matchLabels/%s", + value: %s + } + ] + : + has(object.spec.crossplane.compositionSelector) && + has(object.spec.crossplane.compositionSelector.matchLabels) + ? + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector/matchLabels/%s", + value: %s + } + ] + : + has(object.spec.crossplane.compositionSelector) + ? + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector/matchLabels", + value: { + %s: %s + } + } + ] + : + [ + JSONPatch{ + op: "add", + path: "/spec/crossplane/compositionSelector", + value: { + "matchLabels": { + %s: %s + } + } + } + ] + ) + ) + : + [] +) +`, migrateMatchCEL(labelKey, from, to), key, ptr, val, ptr, val, key, val, key, val)) +} + +func celString(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '\\', '"': + b.WriteByte('\\') + b.WriteRune(r) + case '\n': + b.WriteString(`\n`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} + +func jsonPointerEscape(s string) string { + s = strings.ReplaceAll(s, "~", "~0") + s = strings.ReplaceAll(s, "/", "~1") + return s +} + +func dns1123Label(s string) string { + s = strings.ToLower(s) + var b strings.Builder + prevHyphen := false + for _, r := range s { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + prevHyphen = false + continue + } + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-' { + if !prevHyphen { + b.WriteByte('-') + prevHyphen = true + } + } + } + return strings.Trim(b.String(), "-") +} + +func encodeKyvernoYAML(docs []kyvernoMutatingPolicy) ([]byte, error) { + var b strings.Builder + b.WriteString("# Generated by convctl generate kyverno. Review before apply. convctl never applies this.\n") + for i, doc := range docs { + if i > 0 { + b.WriteString("---\n") + } + data, err := sigsyaml.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("marshaling policy: %w", err) + } + b.Write(data) + } + return []byte(b.String()), nil +} + +func containsString(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/internal/cli/generate_kyverno_test.go b/internal/cli/generate_kyverno_test.go new file mode 100644 index 0000000..52df450 --- /dev/null +++ b/internal/cli/generate_kyverno_test.go @@ -0,0 +1,187 @@ +/* +Copyright 2026 The declarative-conversion-operator Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunGenerateKyverno_FromV1ToV2(t *testing.T) { + t.Parallel() + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + docs, err := RunGenerateKyverno(GenerateKyvernoOptions{ + XRDPath: filepath.Join(root, "03-promote-v2", "xrd.yaml"), + From: "v1", + To: "v2", + }) + if err != nil { + t.Fatal(err) + } + got, err := encodeKyvernoYAML(docs) + if err != nil { + t.Fatal(err) + } + wantPath := filepath.Join(root, "gitops", "policies", "from-v1-to-v2.yaml") + assertGoldenYAML(t, wantPath, got) + if docs[0].Metadata.Name != "label-compositions-xwidgets" { + t.Fatalf("composition policy name: got %q", docs[0].Metadata.Name) + } + if docs[1].Metadata.Name != "set-composition-version-selector-xwidgets" { + t.Fatalf("migrate policy name: got %q", docs[1].Metadata.Name) + } + labeler := docs[0].Spec.Mutations[0].ApplyConfiguration.Expression + if !strings.Contains(labeler, `kind == "XWidget"`) { + t.Fatalf("labeler should match XRD kind, got:\n%s", labeler) + } + if !strings.Contains(labeler, `startsWith("example.org/")`) { + t.Fatalf("labeler should match XRD group, got:\n%s", labeler) + } + if strings.Contains(labeler, "Object.metadata.labels{") { + t.Fatalf("hyphenated label keys must use a CEL map literal, not Object.metadata.labels{}, got:\n%s", labeler) + } + if len(docs[0].Spec.MatchConditions) != 0 { + t.Fatalf("labeler must not use matchConditions (Kyverno 1.18 ignores object.spec there)") + } + migrate := docs[1].Spec.Mutations[0].JSONPatch.Expression + if !strings.Contains(migrate, `== "v1"`) { + t.Fatalf("migrate --from v1 should compare == v1, got:\n%s", migrate) + } + if !strings.Contains(migrate, "oldObject") { + t.Fatalf("migrate must match UPDATE against oldObject so a later compositionRef write does not rematch, got:\n%s", migrate) + } + if len(docs[1].Spec.MatchConditions) != 0 { + t.Fatalf("migrate must not use matchConditions (Kyverno 1.18 ignores object.spec there)") + } + if !docs[1].Spec.Evaluation.Admission.Enabled { + t.Fatal("migrate must enable admission; Kyverno 1.18.1 mutateExisting is a no-op") + } + labelerOnly, err := encodeKyvernoYAML(docs[:1]) + if err != nil { + t.Fatal(err) + } + standalone, err := os.ReadFile(filepath.Join(root, "gitops", "policies", "label-compositions-xwidgets.yaml")) + if err != nil { + t.Fatal(err) + } + // Standalone file may carry an extra comment line after the generate banner. + if !bytes.Contains(standalone, bytes.SplitN(labelerOnly, []byte("apiVersion:"), 2)[1]) { + t.Fatalf("label-compositions-xwidgets.yaml should contain the generated labeler body") + } +} + +func TestRunGenerateKyverno_FromV2ToV3(t *testing.T) { + t.Parallel() + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + docs, err := RunGenerateKyverno(GenerateKyvernoOptions{ + XRDPath: filepath.Join(root, "05-promote-v3", "xrd.yaml"), + From: "v2", + To: "v3", + }) + if err != nil { + t.Fatal(err) + } + got, err := encodeKyvernoYAML(docs) + if err != nil { + t.Fatal(err) + } + wantPath := filepath.Join(root, "gitops", "policies", "from-v2-to-v3.yaml") + assertGoldenYAML(t, wantPath, got) + if got := docs[1].Spec.MatchConstraints.ResourceRules[0].APIVersions; len(got) != 3 { + t.Fatalf("v3 XRD served versions: got %v", got) + } +} + +func TestRunGenerateKyverno_WithoutFrom(t *testing.T) { + t.Parallel() + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + docs, err := RunGenerateKyverno(GenerateKyvernoOptions{ + XRDPath: filepath.Join(root, "03-promote-v2", "xrd.yaml"), + To: "v2", + }) + if err != nil { + t.Fatal(err) + } + expr := docs[1].Spec.Mutations[0].JSONPatch.Expression + if !strings.Contains(expr, `!= "v2"`) { + t.Fatalf("without --from, migrate should select anything not already v2, got:\n%s", expr) + } + if strings.Contains(expr, `== "v1"`) { + t.Fatalf("without --from, should not pin to v1, got:\n%s", expr) + } +} + +func TestRunGenerateKyverno_UnknownTo(t *testing.T) { + t.Parallel() + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + _, err := RunGenerateKyverno(GenerateKyvernoOptions{ + XRDPath: filepath.Join(root, "03-promote-v2", "xrd.yaml"), + To: "v9", + }) + if err == nil { + t.Fatal("expected error for --to not on the XRD") + } + if !strings.Contains(err.Error(), "v9") { + t.Fatalf("error should name the bad version, got %v", err) + } +} + +func TestRunGenerateKyverno_UnknownFrom(t *testing.T) { + t.Parallel() + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + _, err := RunGenerateKyverno(GenerateKyvernoOptions{ + XRDPath: filepath.Join(root, "03-promote-v2", "xrd.yaml"), + From: "v9", + To: "v2", + }) + if err == nil { + t.Fatal("expected error for --from not on the XRD") + } +} + +func TestDNS1123Label(t *testing.T) { + t.Parallel() + if got := dns1123Label("XWidgets"); got != "xwidgets" { + t.Fatalf("got %q", got) + } + if got := dns1123Label("xwidgets.example.org"); got != "xwidgets-example-org" { + t.Fatalf("got %q", got) + } +} + +func assertGoldenYAML(t *testing.T, path string, got []byte) { + t.Helper() + if os.Getenv("UPDATE_GOLDENS") == "1" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading golden %s: %v", path, err) + } + if string(got) != string(want) { + t.Fatalf("generated YAML drifted from %s\n--- got ---\n%s\n--- want ---\n%s", path, got, want) + } +} diff --git a/internal/cli/live.go b/internal/cli/live.go index 5bb98b0..66aea73 100644 --- a/internal/cli/live.go +++ b/internal/cli/live.go @@ -82,6 +82,18 @@ func xrdResourceInfo(xrd *unstructured.Unstructured) (group, plural string, err return group, plural, nil } +func xrdGroupKind(xrd *unstructured.Unstructured) (group, kind string, err error) { + group, found, err := unstructured.NestedString(xrd.Object, "spec", "group") + if err != nil || !found || group == "" { + return "", "", fmt.Errorf("xrd is missing spec.group") + } + kind, found, err = unstructured.NestedString(xrd.Object, "spec", "names", "kind") + if err != nil || !found || kind == "" { + return "", "", fmt.Errorf("xrd is missing spec.names.kind") + } + return group, kind, nil +} + // FetchLiveSamples lists every existing instance of the XRD's generated // composite resource type at hubVersion. See fetchLiveSamplesByGVR for why // hubVersion specifically, and why pagination isn't capped. diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 0af1809..4be0ce3 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -156,9 +156,6 @@ func LoadSamples(dir string) ([]Sample, error) { } for i, doc := range docs { apiVersion, _ := doc["apiVersion"].(string) - if apiVersion == "" { - return fmt.Errorf("%s (document %d): missing apiVersion; samples must declare which version they represent", rel, i) - } samples = append(samples, Sample{File: rel, Index: i, Object: doc, Version: versionFromAPIVersion(apiVersion)}) } return nil @@ -169,6 +166,37 @@ func LoadSamples(dir string) ([]Sample, error) { return samples, nil } +// filterSamplesByGVK keeps objects of the XRD/CRD's group and kind so a +// GitOps apps/ tree (XRs plus kustomization.yaml or Helm values) can be +// passed to --samples. Other documents are ignored, not errors. A document +// of the target kind with no apiVersion is an error — that object cannot +// declare which version it represents. +func filterSamplesByGVK(samples []Sample, group, kind string) ([]Sample, error) { + var out []Sample + for _, s := range samples { + k, _ := s.Object["kind"].(string) + if k != kind { + continue + } + api, _ := s.Object["apiVersion"].(string) + if api == "" { + return nil, fmt.Errorf("%s: %s object missing apiVersion; samples must declare which version they represent", s.File, kind) + } + if apiGroup(api) != group { + continue + } + out = append(out, s) + } + return out, nil +} + +func apiGroup(apiVersion string) string { + if i := strings.LastIndex(apiVersion, "/"); i >= 0 { + return apiVersion[:i] + } + return "" +} + func decodeAllDocuments(path string) ([]map[string]any, error) { f, err := os.Open(path) if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index 583c5a6..adfc95a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -53,8 +53,8 @@ cluster. Every command works against either resource type: } root.AddCommand( newValidateCmd(), newAnalyzeCmd(), newTestCmd(), newDiffCmd(), - newConvertCmd(), newSuggestCmd(), newRehubCmd(), newPatchPreviewCmd(), - newMigrateStorageCmd(), newVersionCmd(), + newConvertCmd(), newSuggestCmd(), newRehubCmd(), newGenerateCmd(), + newPatchPreviewCmd(), newMigrateStorageCmd(), newVersionCmd(), ) if err := root.Execute(); err != nil { @@ -175,8 +175,10 @@ func newTestCmd() *cobra.Command { cmd := &cobra.Command{ Use: "test", Short: "Round-trip samples through every conversion path", - Long: `Run every sample through every served-version conversion path and report pass/loss, -timing, and rule coverage. + Long: `Run every sample through every conversion path the config declares (hub plus +each compiled spoke, among served versions) and report pass/loss, timing, and +rule coverage. A served version that is not a spoke is skipped — drop the +spoke from the config before setting served: false, then un-serve. Works against an XRDConversionConfig (--xrd) or a CRDConversionConfig (--crd). The config's own kind decides which schema flag is required. diff --git a/internal/cli/test.go b/internal/cli/test.go index 625e8e1..197f9d5 100644 --- a/internal/cli/test.go +++ b/internal/cli/test.go @@ -75,11 +75,17 @@ func (o TestOptions) effectiveConcurrency(samples int) int { // RunTest loads the config, its target XRD or CRD, and samples, validates // the configuration exactly like the controller and admission webhook -// would, then tests every sample across every served-version pair +// would, then tests every sample across every configured-version pair // (spoke_i -> hub -> spoke_j, including the hub itself as source or // target), reporting timing, pass/loss/fail, rules exercised, and // precisely which fields diverged where. // +// Targets are the hub plus every spoke the config compiled a plan for, +// intersected with served versions. A served version the config does not +// claim is not a conversion path — dropping a spoke before setting +// served:false is the required order, and test must still run in that +// window. --version-pair further restricts that set. +// // Which of XRDPath/CRDPath applies is determined by the config's own // kind, not by which field the caller happened to set. func RunTest(opts TestOptions) (*Report, error) { @@ -133,8 +139,16 @@ func runTestXRD(opts TestOptions) (*Report, error) { if err != nil { return nil, err } + group, kind, gvkErr := xrdGroupKind(xrd) + if gvkErr != nil { + return nil, gvkErr + } + samples, err = filterSamplesByGVK(samples, group, kind) + if err != nil { + return nil, err + } if len(samples) == 0 { - return nil, fmt.Errorf("no sample files found under %s", opts.SamplesDir) + return nil, fmt.Errorf("no %s.%s objects under %s", kind, group, opts.SamplesDir) } } @@ -184,8 +198,12 @@ func runTestCRD(opts TestOptions) (*Report, error) { if err != nil { return nil, err } + samples, err = filterSamplesByGVK(samples, crd.Spec.Group, crd.Spec.Names.Kind) + if err != nil { + return nil, err + } if len(samples) == 0 { - return nil, fmt.Errorf("no sample files found under %s", opts.SamplesDir) + return nil, fmt.Errorf("no %s.%s objects under %s", crd.Spec.Names.Kind, crd.Spec.Group, opts.SamplesDir) } } @@ -204,14 +222,15 @@ func runTestCRD(opts TestOptions) (*Report, error) { } // runTestCommon is runTestXRD/runTestCRD's shared tail: exercising every -// sample across every served-version pair is entirely independent of +// sample across every configured-version pair is entirely independent of // whether the target is an XRD or a native CRD, once a Router and an // AnalyzeReport already exist. func runTestCommon(opts TestOptions, resourceKind, resourceName, configName, hubVersion string, samples []Sample, versions []engine.VersionSchema, report engine.AnalyzeReport, router *engine.Router, start time.Time) (*Report, error) { served := servedVersions(versions) - targets := served + configured := configuredVersions(hubVersion, report, served) + targets := configured if len(opts.RestrictVersionPairs) > 0 { - targets = restrictTargets(served, opts.RestrictVersionPairs) + targets = restrictTargets(targets, opts.RestrictVersionPairs) } lossyPaths := buildLossyPathIndex(report) @@ -247,7 +266,7 @@ func runTestCommon(opts TestOptions, resourceKind, resourceName, configName, hub go func() { defer wg.Done() for i := range next { - sr, counts, usage := testOneSample(opts, router, hubVersion, lossyPaths, report, samples[i], targets) + sr, counts, usage := testOneSample(opts, router, hubVersion, lossyPaths, report, samples[i], configured, targets) mu.Lock() results[i] = sr @@ -301,10 +320,25 @@ type sampleCounts struct { // a sample stay sequential: they're cheap next to the coordination cost, // and keeping the unit of parallelism at the sample level is what makes // deterministic result ordering trivial. -func testOneSample(opts TestOptions, router *engine.Router, hubVersion string, lossyPaths map[string]map[string]bool, report engine.AnalyzeReport, s Sample, targets []string) (SampleResult, sampleCounts, map[string]int) { +func testOneSample(opts TestOptions, router *engine.Router, hubVersion string, lossyPaths map[string]map[string]bool, report engine.AnalyzeReport, s Sample, configured, targets []string) (SampleResult, sampleCounts, map[string]int) { sr := SampleResult{File: s.File, AssertedVersion: s.Version} var counts sampleCounts usage := map[string]int{} + if !containsString(configured, s.Version) { + pr := PathResult{From: s.Version, To: s.Version, Result: "error"} + pr.Issues = append(pr.Issues, Issue{ + Field: "(sample)", + From: s.Version, + To: s.Version, + Type: "error", + Detail: fmt.Sprintf("sample is %s but the conversion config has no compiled plan for that version — move the object to a remaining spoke or the hub before dropping this version", s.Version), + Sample: s.File, + }) + sr.Paths = append(sr.Paths, pr) + counts.pathsTested++ + counts.errors++ + return sr, counts, usage + } for _, target := range targets { if opts.SkipIdentity && target == s.Version { continue @@ -330,6 +364,32 @@ func ruleID(spokeVersion string, rr engine.RuleResult) string { return fmt.Sprintf("%s:rule[%d]:%s", spokeVersion, rr.Index, rr.Strategy) } +// configuredVersions is hub + every spoke with a compiled plan, keeping +// the XRD/CRD served-version order. Served versions the config does not +// declare are omitted — the same reason validate allows a still-served +// version that is no longer a spoke. +func configuredVersions(hub string, report engine.AnalyzeReport, served []string) []string { + want := map[string]bool{} + if hub != "" { + want[hub] = true + } + for _, sr := range report.SpokeReports { + if sr.CompiledPlan != nil { + want[sr.Version] = true + } + } + var out []string + for _, v := range served { + if want[v] { + out = append(out, v) + } + } + if len(out) == 0 { + return served + } + return out +} + func restrictTargets(served []string, pairs []string) []string { set := map[string]bool{} for _, p := range pairs { diff --git a/internal/cli/test_test.go b/internal/cli/test_test.go index 29ccf9e..f8ba75f 100644 --- a/internal/cli/test_test.go +++ b/internal/cli/test_test.go @@ -17,6 +17,8 @@ limitations under the License. package cli import ( + "os" + "path/filepath" "strings" "testing" ) @@ -434,3 +436,140 @@ func TestRunTest_NoSamples_IsAnError(t *testing.T) { t.Fatalf("expected an error when the samples directory is empty") } } + +// Drop the spoke from the config before setting served:false. The XRD still +// serves the dropped version; test must exercise hub + remaining spokes only. +func TestRunTest_DropSpokeWhileStillServed(t *testing.T) { + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + rep, err := RunTest(TestOptions{ + XRDPath: filepath.Join(root, "05-promote-v3", "xrd.yaml"), + ConfigPath: filepath.Join(root, "06-deprecate-v1", "xrdconversionconfig.yaml"), + SamplesDir: filepath.Join(root, "06-deprecate-v1", "samples"), + }) + if err != nil { + t.Fatalf("drop-spoke-first must be testable against the still-serving XRD: %v", err) + } + for _, s := range rep.Samples { + for _, p := range s.Paths { + if p.From == "v1" || p.To == "v1" { + t.Fatalf("v1 is no longer a spoke; should not test %s→%s", p.From, p.To) + } + } + } + if rep.Summary.Errors != 0 { + t.Fatalf("expected 0 errors, got %d", rep.Summary.Errors) + } +} + +func TestRunTest_DropSpokeLeavesV1App(t *testing.T) { + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + rep, err := RunTest(TestOptions{ + XRDPath: filepath.Join(root, "05-promote-v3", "xrd.yaml"), + ConfigPath: filepath.Join(root, "06-deprecate-v1", "xrdconversionconfig.yaml"), + SamplesDir: filepath.Join(root, "gitops", "apps"), + }) + if err != nil { + t.Fatalf("apps/ is a valid --samples tree: %v", err) + } + if rep.Summary.Errors == 0 { + t.Fatal("expected ERROR: gitops/apps/widget.yaml is still v1 after the spoke was dropped") + } + found := false + for _, s := range rep.Samples { + if s.AssertedVersion != "v1" { + continue + } + found = true + if len(s.Paths) == 0 || s.Paths[0].Result != "error" { + t.Fatalf("v1 app XR should error, got %+v", s.Paths) + } + } + if !found { + t.Fatal("expected to test the v1 demo XR from gitops/apps") + } +} + +func TestRunTest_VersionPairDoesNotErrorConfiguredSample(t *testing.T) { + rep, err := RunTest(TestOptions{ + XRDPath: "testdata/xrd.yaml", + ConfigPath: "testdata/config.yaml", + SamplesDir: "testdata/samples", + RestrictVersionPairs: []string{"v2"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rep.Summary.Errors != 0 { + t.Fatalf("--version-pair must not treat a still-configured sample version as missing a plan, got %d errors", rep.Summary.Errors) + } + var sawV1ToV2 bool + for _, s := range rep.Samples { + for _, p := range s.Paths { + if p.To != "v2" { + t.Fatalf("--version-pair v2 should only test destinations in {v2}, got %s→%s", p.From, p.To) + } + if s.AssertedVersion == "v1" && p.To == "v2" { + sawV1ToV2 = true + } + } + } + if !sawV1ToV2 { + t.Fatal("expected the v1 sample to still convert to the restricted v2 target") + } +} + +func TestFilterSamplesByGVK_IgnoresUnrelatedAndRejectsKindWithoutAPIVersion(t *testing.T) { + dir := t.TempDir() + unrelated := "replicaCount: 2\n" + if err := os.WriteFile(filepath.Join(dir, "values.yaml"), []byte(unrelated), 0o644); err != nil { + t.Fatal(err) + } + samples, err := LoadSamples(dir) + if err != nil { + t.Fatalf("Helm values without apiVersion must be loadable: %v", err) + } + got, err := filterSamplesByGVK(samples, "example.org", "Foo") + if err != nil { + t.Fatalf("unrelated YAML should be ignored: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected no Foo samples, got %+v", got) + } + + bad := "kind: Foo\nspec: {}\n" + if err := os.WriteFile(filepath.Join(dir, "orphan.yaml"), []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + samples, err = LoadSamples(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if _, err := filterSamplesByGVK(samples, "example.org", "Foo"); err == nil { + t.Fatal("expected error for a Foo object with no apiVersion") + } +} + +func TestRunTest_DropSpokeWithV2App(t *testing.T) { + root := filepath.Join("..", "..", "examples", "crossplane-xr-multiversion") + rep, err := RunTest(TestOptions{ + XRDPath: filepath.Join(root, "05-promote-v3", "xrd.yaml"), + ConfigPath: filepath.Join(root, "06-deprecate-v1", "xrdconversionconfig.yaml"), + SamplesDir: filepath.Join(root, "06-deprecate-v1"), + }) + if err != nil { + t.Fatalf("06-deprecate-v1/widget.yaml + samples should test: %v", err) + } + if rep.Summary.Errors != 0 { + t.Fatalf("v2 app + v2/v3 fixtures must pass after dropping the v1 spoke, got %d errors", rep.Summary.Errors) + } + for _, s := range rep.Samples { + if s.AssertedVersion == "v1" { + t.Fatalf("should not pick up non-XR YAML as a v1 sample: %+v", s) + } + for _, p := range s.Paths { + if p.From == "v1" || p.To == "v1" { + t.Fatalf("v1 is no longer a spoke; should not test %s→%s", p.From, p.To) + } + } + } +} diff --git a/internal/webhookserver/server.go b/internal/webhookserver/server.go index 47dc9d9..4ecd51d 100644 --- a/internal/webhookserver/server.go +++ b/internal/webhookserver/server.go @@ -213,6 +213,7 @@ func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) { } objSpan.End() out["apiVersion"] = review.Request.DesiredAPIVersion + ensureConvertedMetadata(out, obj) b, err := json.Marshal(out) if err != nil { s.writeReview(w, review.Request.UID, nil, fmt.Sprintf("marshaling converted object: %v", err)) @@ -270,6 +271,21 @@ func (s *Server) writeReview(w http.ResponseWriter, uid types.UID, converted []r _ = json.NewEncoder(w).Encode(resp) } +// ensureConvertedMetadata keeps the ConversionReview contract: the +// apiserver treats a missing or null metadata field as +// "invalid metadata: missing metadata in converted object". Flux SSA +// prune converts a field-set fragment that often has no metadata. +func ensureConvertedMetadata(out, original map[string]any) { + if md, ok := out["metadata"]; ok && md != nil { + return + } + if md, ok := original["metadata"]; ok && md != nil { + out["metadata"] = md + return + } + out["metadata"] = map[string]any{} +} + func versionOf(apiVersion string) string { parts := strings.SplitN(apiVersion, "/", 2) return parts[len(parts)-1] diff --git a/internal/webhookserver/server_test.go b/internal/webhookserver/server_test.go index 7deae8e..8c6f901 100644 --- a/internal/webhookserver/server_test.go +++ b/internal/webhookserver/server_test.go @@ -100,6 +100,50 @@ func TestHandleConvert_Success(t *testing.T) { } } +func TestHandleConvert_PartialObjectStillHasMetadata(t *testing.T) { + hub := "v3" + spoke := "v2" + plan := &engine.Plan{ + HubVersion: hub, SpokeVersion: spoke, + HubToSpoke: []engine.Op{}, SpokeToHub: []engine.Op{}, + } + registry := NewRegistry() + registry.Set("xwidgets.example.org", &CompiledEntry{ + Router: &engine.Router{Hub: hub, Plans: map[string]*engine.Plan{spoke: plan}}, + }) + s := &Server{Registry: registry, Metrics: newTestMetrics()} + + // Flux SSA prune sends a field-set fragment — often no metadata. + obj := map[string]any{"apiVersion": "example.org/v2", "kind": "XWidget", "spec": map[string]any{"widgetName": "demo"}} + raw, _ := json.Marshal(obj) + review := extv1.ConversionReview{Request: &extv1.ConversionRequest{ + UID: "ssa", DesiredAPIVersion: "example.org/v3", + Objects: []runtime.RawExtension{{Raw: raw}}, + }} + body, _ := json.Marshal(review) + req := httptest.NewRequest("POST", "/convert/xwidgets.example.org", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleConvert(rec, req) + + var got extv1.ConversionReview + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decoding response: %v", err) + } + if got.Response.Result.Status != metav1.StatusSuccess { + t.Fatalf("expected Success, got %+v", got.Response.Result) + } + if len(got.Response.ConvertedObjects) != 1 { + t.Fatalf("expected exactly one converted object, got %d", len(got.Response.ConvertedObjects)) + } + var converted map[string]any + if err := json.Unmarshal(got.Response.ConvertedObjects[0].Raw, &converted); err != nil { + t.Fatalf("decoding converted object: %v", err) + } + if converted["metadata"] == nil { + t.Fatalf("apiserver rejects converted objects with no metadata, got %v", converted) + } +} + func TestRegistry_RecordErrorPreservesRouter(t *testing.T) { r := NewRegistry() router := &engine.Router{Hub: "v2"} diff --git a/pkg/engine/compile_test.go b/pkg/engine/compile_test.go index e9b5375..4968aaf 100644 --- a/pkg/engine/compile_test.go +++ b/pkg/engine/compile_test.go @@ -197,6 +197,31 @@ func TestConvert_PreservesKindAndMetadata(t *testing.T) { } } +func TestConvert_AlwaysEmitsMetadata(t *testing.T) { + hub := objSchema(map[string]extv1.JSONSchemaProps{"storageGB": strSchema()}) + spoke := objSchema(map[string]extv1.JSONSchemaProps{"storageSize": strSchema()}) + rs := RuleSet{HubVersion: "v2", SpokeVersion: "v1", Rules: []Rule{ + {Strategy: StrategyFieldRename, Params: FieldRenameParams{HubPath: ParsePath("storageGB"), SpokePath: ParsePath("storageSize")}}, + }} + plan, _, err := Compile(rs, &hub, &spoke) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, in := range []map[string]any{ + {"kind": "XWidget", "storageGB": "100"}, + {"kind": "XWidget", "metadata": nil, "storageGB": "100"}, + } { + out, err := Convert(ConvertInput{Plan: plan, Direction: HubToSpoke, Object: in}) + if err != nil { + t.Fatalf("convert: %v", err) + } + md, ok := out["metadata"].(map[string]any) + if !ok || md == nil { + t.Fatalf("SSA prune objects have no metadata; converted object must still have a metadata map, got %v", out) + } + } +} + func TestUncoveredField_FailsClosedByDefault(t *testing.T) { hub := objSchema(map[string]extv1.JSONSchemaProps{"a": strSchema(), "b": strSchema()}) spoke := objSchema(map[string]extv1.JSONSchemaProps{"a": strSchema()}) diff --git a/pkg/engine/convert.go b/pkg/engine/convert.go index 2629629..4e01f83 100644 --- a/pkg/engine/convert.go +++ b/pkg/engine/convert.go @@ -45,8 +45,14 @@ func Convert(in ConvertInput) (map[string]any, error) { if kind, ok := in.Object["kind"]; ok { output["kind"] = kind } - if md, ok := in.Object["metadata"]; ok { + // Kubernetes rejects a ConversionReview object with no metadata + // ("missing metadata in converted object"). SSA prune can send a + // partial object whose metadata is absent or JSON null — still emit + // an object so the apiserver can merge. + if md, ok := in.Object["metadata"]; ok && md != nil { output["metadata"] = deepCopyValue(md) + } else { + output["metadata"] = map[string]any{} } ctx := &execContext{input: in.Object, output: output} for _, op := range in.Plan.ops(in.Direction) {