From 303c488c422d7d7ddc84464e955de10f6ce7e801 Mon Sep 17 00:00:00 2001 From: vrabbi Date: Sun, 16 Aug 2026 17:38:45 +0300 Subject: [PATCH 1/4] feat: GitOps XRD lifecycle demo, generate kyverno, and conversion metadata Add convctl generate kyverno, tighten convctl test to hub+spokes plus kind-filtered apps samples, always emit conversion metadata so Flux SSA prune succeeds, and ship the Flux/Argo GitOps demo with a standing migrate policy and --from-stage resume. --- .gitignore | 3 + CONTRIBUTING.md | 11 +- Makefile | 109 +- README.md | 2 +- docs/cli.md | 67 +- docs/configuration/xrdconversionconfig.md | 2 +- docs/examples/index.md | 2 +- docs/examples/xr-lifecycle.md | 24 +- docs/observability.md | 3 +- .../06-deprecate-v1/widget.yaml | 11 + examples/crossplane-xr-multiversion/README.md | 50 +- examples/crossplane-xr-multiversion/demo.sh | 443 ++++++- .../gitops/.gitignore | 3 + .../gitops/README.md | 201 ++++ .../gitops/apps/widget.yaml | 13 + .../crossplane-xr-multiversion/gitops/lib.sh | 1062 +++++++++++++++++ .../gitops/platform/README.md | 20 + .../gitops/policies/from-v1-to-v2.yaml | 148 +++ .../gitops/policies/from-v2-to-v3.yaml | 149 +++ .../gitops/policies/kyverno-rbac.yaml | 39 + .../policies/label-compositions-xwidgets.yaml | 40 + .../gitops/repo-README.md | 18 + .../gitops/runner/.dockerignore | 2 + .../gitops/runner/Dockerfile | 4 + .../gitops/runner/entrypoint.sh | 44 + .../gitops/runner/manifests.yaml | 130 ++ .../gitops/workflow/convctl.yaml | 117 ++ internal/cli/generate.go | 111 ++ internal/cli/generate_kyverno.go | 477 ++++++++ internal/cli/generate_kyverno_test.go | 187 +++ internal/cli/live.go | 12 + internal/cli/loader.go | 26 + internal/cli/root.go | 10 +- internal/cli/test.go | 65 +- internal/cli/test_test.go | 78 ++ internal/webhookserver/server.go | 16 + internal/webhookserver/server_test.go | 39 + pkg/engine/compile_test.go | 25 + pkg/engine/convert.go | 8 +- 39 files changed, 3650 insertions(+), 121 deletions(-) create mode 100644 examples/crossplane-xr-multiversion/06-deprecate-v1/widget.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/.gitignore create mode 100644 examples/crossplane-xr-multiversion/gitops/README.md create mode 100644 examples/crossplane-xr-multiversion/gitops/apps/widget.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/lib.sh create mode 100644 examples/crossplane-xr-multiversion/gitops/platform/README.md create mode 100644 examples/crossplane-xr-multiversion/gitops/policies/from-v1-to-v2.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/policies/from-v2-to-v3.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/policies/kyverno-rbac.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/policies/label-compositions-xwidgets.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/repo-README.md create mode 100644 examples/crossplane-xr-multiversion/gitops/runner/.dockerignore create mode 100644 examples/crossplane-xr-multiversion/gitops/runner/Dockerfile create mode 100755 examples/crossplane-xr-multiversion/gitops/runner/entrypoint.sh create mode 100644 examples/crossplane-xr-multiversion/gitops/runner/manifests.yaml create mode 100644 examples/crossplane-xr-multiversion/gitops/workflow/convctl.yaml create mode 100644 internal/cli/generate.go create mode 100644 internal/cli/generate_kyverno.go create mode 100644 internal/cli/generate_kyverno_test.go 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..f0e1a81 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,62 @@ 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)). 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 +393,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..4ffbf83 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,8 +34,18 @@ 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; } @@ -36,6 +55,69 @@ forward=() while [[ $# -gt 0 ]]; do case "$1" in --cleanup) CLEANUP_ONLY=1; shift ;; + --demo-mode) + DEMO_MODE="${2:-}" + shift 2 + ;; + --demo-mode=*) + DEMO_MODE="${1#*=}" + shift + ;; + --gitops-engine) + GITOPS_ENGINE="${2:-}" + shift 2 + ;; + --gitops-engine=*) + GITOPS_ENGINE="${1#*=}" + shift + ;; + --github-repo) + 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) + GIT_PREFIX="${2:-}" + shift 2 + ;; + --git-prefix=*) + GIT_PREFIX="${1#*=}" + shift + ;; + --repo-visibility) + REPO_VISIBILITY="${2:-}" + shift 2 + ;; + --repo-visibility=*) + REPO_VISIBILITY="${1#*=}" + shift + ;; + --delete-repo) DELETE_REPO=1; shift ;; + --from-stage) + 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 +126,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,6 +177,8 @@ 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 } @@ -58,6 +186,10 @@ command -v kubectl >/dev/null 2>&1 || die "kubectl not found on PATH" if [[ "${CLEANUP_ONLY}" -eq 1 ]]; then cleanup_demo + if gitops_is_live || [[ "${DELETE_REPO}" -eq 1 ]] || [[ -f "${GITOPS_STATE_FILE}" ]]; then + gitops_cleanup_cluster + gitops_cleanup_repo + fi echo "demo objects removed" exit 0 fi @@ -69,6 +201,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 +280,114 @@ 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() { + local token + token="$(gh auth token)" + git -C "${GITOPS_WORKTREE}" remote set-url origin \ + "https://x-access-token:${token}@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" + 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)" + + 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 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 + sha="$(gitops_git rev-parse HEAD)" + set +e + gitops_wait_pr_checks "${pr}" "${sha}" + local rc=$? + set -e + 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 + sha="$(gitops_git rev-parse HEAD)" + set +e + gitops_wait_pr_checks "${pr}" "${sha}" + local rc=$? + set -e + + 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) +} + +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" -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' && 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..572a0c5 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -169,6 +169,32 @@ 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) can be passed to +// --samples. Other documents are ignored, not errors. +func filterSamplesByGVK(samples []Sample, group, kind string) []Sample { + var out []Sample + for _, s := range samples { + k, _ := s.Object["kind"].(string) + if k != kind { + continue + } + api, _ := s.Object["apiVersion"].(string) + if apiGroup(api) != group { + continue + } + out = append(out, s) + } + return out +} + +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..44ed088 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,13 @@ func runTestXRD(opts TestOptions) (*Report, error) { if err != nil { return nil, err } + group, kind, gvkErr := xrdGroupKind(xrd) + if gvkErr != nil { + return nil, gvkErr + } + samples = filterSamplesByGVK(samples, group, kind) 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 +195,9 @@ func runTestCRD(opts TestOptions) (*Report, error) { if err != nil { return nil, err } + samples = filterSamplesByGVK(samples, crd.Spec.Group, crd.Spec.Names.Kind) 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 +216,14 @@ 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 + targets := configuredVersions(hubVersion, report, served) if len(opts.RestrictVersionPairs) > 0 { - targets = restrictTargets(served, opts.RestrictVersionPairs) + targets = restrictTargets(targets, opts.RestrictVersionPairs) } lossyPaths := buildLossyPathIndex(report) @@ -305,6 +317,21 @@ func testOneSample(opts TestOptions, router *engine.Router, hubVersion string, l sr := SampleResult{File: s.File, AssertedVersion: s.Version} var counts sampleCounts usage := map[string]int{} + if !containsString(targets, 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 +357,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..71b1c2c 100644 --- a/internal/cli/test_test.go +++ b/internal/cli/test_test.go @@ -17,6 +17,7 @@ limitations under the License. package cli import ( + "path/filepath" "strings" "testing" ) @@ -434,3 +435,80 @@ 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_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..17a2774 100644 --- a/internal/webhookserver/server_test.go +++ b/internal/webhookserver/server_test.go @@ -100,6 +100,45 @@ 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) + } + var converted map[string]any + _ = json.Unmarshal(got.Response.ConvertedObjects[0].Raw, &converted) + 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..0bb010e 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"] + 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) { From 1d1cb88cfc5df1c107c5ba995c493c4558d54490 Mon Sep 17 00:00:00 2001 From: vrabbi Date: Sun, 16 Aug 2026 17:46:41 +0300 Subject: [PATCH 2/4] fix: trigger Kyverno admission on existing XRs after migrate Kyverno 1.18.1 never runs MutatingPolicy mutateExisting, so the GitOps demo now writes each XR after the standing migrate policy lands. Also scope the demo-state umask and delete the Flux apps Kustomization on cleanup. --- docs/cli.md | 5 +++-- examples/crossplane-xr-multiversion/demo.sh | 8 ++++++++ examples/crossplane-xr-multiversion/gitops/README.md | 4 +++- examples/crossplane-xr-multiversion/gitops/lib.sh | 8 +++++--- internal/webhookserver/server_test.go | 7 ++++++- pkg/engine/compile_test.go | 2 +- 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f0e1a81..27131b9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -318,8 +318,9 @@ and `compositionRevisionRef`, then sets `compositionSelector.matchLabels` to `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)). UPDATE -matches on `oldObject` so a later pin write does not rematch. +([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 diff --git a/examples/crossplane-xr-multiversion/demo.sh b/examples/crossplane-xr-multiversion/demo.sh index 4ffbf83..0986723 100755 --- a/examples/crossplane-xr-multiversion/demo.sh +++ b/examples/crossplane-xr-multiversion/demo.sh @@ -494,6 +494,10 @@ else pe "cat patches/retarget-v2.json" pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl patch "$xr" -n '"${NS}"' --type=json --patch-file patches/retarget-v2.json; done' fi +if [[ "${DEMO_MODE}" == gitops ]]; then + note "Kyverno 1.18.1 never runs MutatingPolicy mutateExisting (kyverno#16255). Admission needs a write." + pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl annotate "$xr" -n '"${NS}"' demo.example.org/retarget="$(date +%s)" --overwrite; done' +fi pe "kubectl wait --for=condition=Ready --timeout=90s xwidgets.example.org/demo -n ${NS}" pe "kubectl wait --for=condition=Synced --timeout=90s xwidgets.example.org/demo -n ${NS}" pe "kubectl wait --for=condition=Ready --timeout=90s xwidgets.example.org/from-v2 -n ${NS}" @@ -608,6 +612,10 @@ else pe "cat patches/retarget-v3.json" pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl patch "$xr" -n '"${NS}"' --type=json --patch-file patches/retarget-v3.json; done' fi +if [[ "${DEMO_MODE}" == gitops ]]; then + note "Same as stage 3: mutateExisting is a no-op on 1.18.1, so write each XR." + pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl annotate "$xr" -n '"${NS}"' demo.example.org/retarget="$(date +%s)" --overwrite; done' +fi pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl wait --for=condition=Ready --timeout=90s -n '"${NS}"' "$xr"; kubectl wait --for=condition=Synced --timeout=90s -n '"${NS}"' "$xr"; done' note "Storage is now v3. Same migrate-storage step as after the v2 promote." pe "convctl migrate-storage --xrd xwidgets.example.org" diff --git a/examples/crossplane-xr-multiversion/gitops/README.md b/examples/crossplane-xr-multiversion/gitops/README.md index f44c25e..957c345 100644 --- a/examples/crossplane-xr-multiversion/gitops/README.md +++ b/examples/crossplane-xr-multiversion/gitops/README.md @@ -100,7 +100,9 @@ delete+recreate. 2. The per-XRD labeler (already in cluster) sets `xrd-api-version` on the new Composition from `compositeTypeRef`. 3. Update the standing migrate policy (`--from` / `--to` the new hub). Same - `metadata.name`; Kyverno admission retargets live XRs. + `metadata.name`. Kyverno 1.18.1 never runs `mutateExisting` + ([kyverno#16255](https://github.com/kyverno/kyverno/pull/16255)), so + admission only sees a write — re-apply or annotate existing XRs. 4. Crossplane re-selects. Wait `Synced` / `Ready`. [`policies/kyverno-rbac.yaml`](policies/kyverno-rbac.yaml) aggregates view diff --git a/examples/crossplane-xr-multiversion/gitops/lib.sh b/examples/crossplane-xr-multiversion/gitops/lib.sh index c3accd2..c4df66a 100644 --- a/examples/crossplane-xr-multiversion/gitops/lib.sh +++ b/examples/crossplane-xr-multiversion/gitops/lib.sh @@ -111,8 +111,9 @@ gitops_rel_prefix() { # --------------------------------------------------------------------------- gitops_state_write() { - umask 077 - cat > "${GITOPS_STATE_FILE}" < "${GITOPS_STATE_FILE}" </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" -n flux-system --ignore-not-found >/dev/null 2>&1 || true + 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 diff --git a/internal/webhookserver/server_test.go b/internal/webhookserver/server_test.go index 17a2774..8c6f901 100644 --- a/internal/webhookserver/server_test.go +++ b/internal/webhookserver/server_test.go @@ -132,8 +132,13 @@ func TestHandleConvert_PartialObjectStillHasMetadata(t *testing.T) { 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 - _ = json.Unmarshal(got.Response.ConvertedObjects[0].Raw, &converted) + 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) } diff --git a/pkg/engine/compile_test.go b/pkg/engine/compile_test.go index 0bb010e..4968aaf 100644 --- a/pkg/engine/compile_test.go +++ b/pkg/engine/compile_test.go @@ -215,7 +215,7 @@ func TestConvert_AlwaysEmitsMetadata(t *testing.T) { if err != nil { t.Fatalf("convert: %v", err) } - md, ok := out["metadata"] + 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) } From be008956316541e471692b8aa165226376010316 Mon Sep 17 00:00:00 2001 From: vrabbi Date: Sun, 16 Aug 2026 17:55:07 +0300 Subject: [PATCH 3/4] fix: address CodeRabbit findings on demo flags, git auth, and test Reject missing two-token flag values so demo.sh cannot hang, keep the GitHub token out of the worktree remote URL, stop Flux/Argo before wiping objects, and keep --version-pair from failing still-configured samples. Unrelated YAML under --samples is ignored. --- examples/crossplane-xr-multiversion/demo.sh | 31 +++++++--- .../crossplane-xr-multiversion/gitops/lib.sh | 41 +++++++------ .../gitops/workflow/convctl.yaml | 5 +- internal/cli/loader.go | 16 ++--- internal/cli/test.go | 19 ++++-- internal/cli/test_test.go | 61 +++++++++++++++++++ 6 files changed, 133 insertions(+), 40 deletions(-) diff --git a/examples/crossplane-xr-multiversion/demo.sh b/examples/crossplane-xr-multiversion/demo.sh index 0986723..ac0e03c 100755 --- a/examples/crossplane-xr-multiversion/demo.sh +++ b/examples/crossplane-xr-multiversion/demo.sh @@ -49,6 +49,13 @@ 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=() @@ -56,7 +63,8 @@ while [[ $# -gt 0 ]]; do case "$1" in --cleanup) CLEANUP_ONLY=1; shift ;; --demo-mode) - DEMO_MODE="${2:-}" + require_flag_value "$@" + DEMO_MODE="$2" shift 2 ;; --demo-mode=*) @@ -64,7 +72,8 @@ while [[ $# -gt 0 ]]; do shift ;; --gitops-engine) - GITOPS_ENGINE="${2:-}" + require_flag_value "$@" + GITOPS_ENGINE="$2" shift 2 ;; --gitops-engine=*) @@ -72,7 +81,8 @@ while [[ $# -gt 0 ]]; do shift ;; --github-repo) - GITHUB_REPO_FLAG="${2:-}" + require_flag_value "$@" + GITHUB_REPO_FLAG="$2" shift 2 ;; --github-repo=*) @@ -94,7 +104,8 @@ while [[ $# -gt 0 ]]; do shift ;; --git-prefix) - GIT_PREFIX="${2:-}" + require_flag_value "$@" + GIT_PREFIX="$2" shift 2 ;; --git-prefix=*) @@ -102,7 +113,8 @@ while [[ $# -gt 0 ]]; do shift ;; --repo-visibility) - REPO_VISIBILITY="${2:-}" + require_flag_value "$@" + REPO_VISIBILITY="$2" shift 2 ;; --repo-visibility=*) @@ -111,7 +123,8 @@ while [[ $# -gt 0 ]]; do ;; --delete-repo) DELETE_REPO=1; shift ;; --from-stage) - FROM_STAGE="${2:-}" + require_flag_value "$@" + FROM_STAGE="$2" shift 2 ;; --from-stage=*) @@ -185,9 +198,12 @@ cleanup_demo() { command -v kubectl >/dev/null 2>&1 || die "kubectl not found on PATH" if [[ "${CLEANUP_ONLY}" -eq 1 ]]; then - cleanup_demo + # 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" @@ -311,6 +327,7 @@ EOF note "Each command is typed. Press Enter to reveal it, Enter again to run it." note "Wiping leftover objects from a previous run (untyped cleanup)…" +gitops_stop_reconcile cleanup_demo if gitops_is_live; then diff --git a/examples/crossplane-xr-multiversion/gitops/lib.sh b/examples/crossplane-xr-multiversion/gitops/lib.sh index c4df66a..6ef96b3 100644 --- a/examples/crossplane-xr-multiversion/gitops/lib.sh +++ b/examples/crossplane-xr-multiversion/gitops/lib.sh @@ -186,10 +186,11 @@ gitops_resolve_repo() { # --------------------------------------------------------------------------- gitops_set_push_url() { - local token - token="$(gh auth token)" - git -C "${GITOPS_WORKTREE}" remote set-url origin \ - "https://x-access-token:${token}@github.com/${GITHUB_REPO}.git" + # 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() { @@ -356,6 +357,7 @@ gitops_build_convctl_image() { 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" @@ -388,6 +390,7 @@ gitops_install_runner() { 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 @@ -532,21 +535,14 @@ gitops_install_argo() { --set configs.cm."timeout\.reconciliation"=30s \ --wait --timeout 10m - kubectl -n argocd delete secret repo-${GITOPS_APP_NAME} --ignore-not-found - kubectl -n argocd apply -f - </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 diff --git a/examples/crossplane-xr-multiversion/gitops/workflow/convctl.yaml b/examples/crossplane-xr-multiversion/gitops/workflow/convctl.yaml index cb5f7e0..5456e18 100644 --- a/examples/crossplane-xr-multiversion/gitops/workflow/convctl.yaml +++ b/examples/crossplane-xr-multiversion/gitops/workflow/convctl.yaml @@ -63,7 +63,10 @@ jobs: exit "${rc}" - name: Comment convctl output on the PR - if: github.event_name == 'pull_request' && always() + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + always() uses: actions/github-script@v7 with: script: | diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 572a0c5..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 @@ -170,9 +167,11 @@ func LoadSamples(dir string) ([]Sample, error) { } // filterSamplesByGVK keeps objects of the XRD/CRD's group and kind so a -// GitOps apps/ tree (XRs plus kustomization.yaml) can be passed to -// --samples. Other documents are ignored, not errors. -func filterSamplesByGVK(samples []Sample, group, kind string) []Sample { +// 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) @@ -180,12 +179,15 @@ func filterSamplesByGVK(samples []Sample, group, kind string) []Sample { 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 + return out, nil } func apiGroup(apiVersion string) string { diff --git a/internal/cli/test.go b/internal/cli/test.go index 44ed088..197f9d5 100644 --- a/internal/cli/test.go +++ b/internal/cli/test.go @@ -143,7 +143,10 @@ func runTestXRD(opts TestOptions) (*Report, error) { if gvkErr != nil { return nil, gvkErr } - samples = filterSamplesByGVK(samples, group, kind) + samples, err = filterSamplesByGVK(samples, group, kind) + if err != nil { + return nil, err + } if len(samples) == 0 { return nil, fmt.Errorf("no %s.%s objects under %s", kind, group, opts.SamplesDir) } @@ -195,7 +198,10 @@ func runTestCRD(opts TestOptions) (*Report, error) { if err != nil { return nil, err } - samples = filterSamplesByGVK(samples, crd.Spec.Group, crd.Spec.Names.Kind) + 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 %s.%s objects under %s", crd.Spec.Names.Kind, crd.Spec.Group, opts.SamplesDir) } @@ -221,7 +227,8 @@ func runTestCRD(opts TestOptions) (*Report, error) { // 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 := configuredVersions(hubVersion, report, served) + configured := configuredVersions(hubVersion, report, served) + targets := configured if len(opts.RestrictVersionPairs) > 0 { targets = restrictTargets(targets, opts.RestrictVersionPairs) } @@ -259,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 @@ -313,11 +320,11 @@ 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(targets, s.Version) { + if !containsString(configured, s.Version) { pr := PathResult{From: s.Version, To: s.Version, Result: "error"} pr.Issues = append(pr.Issues, Issue{ Field: "(sample)", diff --git a/internal/cli/test_test.go b/internal/cli/test_test.go index 71b1c2c..f8ba75f 100644 --- a/internal/cli/test_test.go +++ b/internal/cli/test_test.go @@ -17,6 +17,7 @@ limitations under the License. package cli import ( + "os" "path/filepath" "strings" "testing" @@ -488,6 +489,66 @@ func TestRunTest_DropSpokeLeavesV1App(t *testing.T) { } } +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{ From 408a13137aa67cf1e38bc4d05b6d48e527e11e75 Mon Sep 17 00:00:00 2001 From: vrabbi Date: Sun, 16 Aug 2026 18:00:16 +0300 Subject: [PATCH 4/4] fix: fail closed on XR admission writes and stop toggling errexit Capture gitops_wait_pr_checks without set +e/-e, and abort the demo if listing or annotating XRs fails so a migrate policy cannot be skipped. --- examples/crossplane-xr-multiversion/demo.sh | 4 ++-- examples/crossplane-xr-multiversion/gitops/lib.sh | 14 ++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/examples/crossplane-xr-multiversion/demo.sh b/examples/crossplane-xr-multiversion/demo.sh index ac0e03c..ffec528 100755 --- a/examples/crossplane-xr-multiversion/demo.sh +++ b/examples/crossplane-xr-multiversion/demo.sh @@ -513,7 +513,7 @@ else fi if [[ "${DEMO_MODE}" == gitops ]]; then note "Kyverno 1.18.1 never runs MutatingPolicy mutateExisting (kyverno#16255). Admission needs a write." - pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl annotate "$xr" -n '"${NS}"' demo.example.org/retarget="$(date +%s)" --overwrite; done' + pe 'xrs=$(kubectl get xwidgets.example.org -n '"${NS}"' -o name) && [[ -n "$xrs" ]] && for xr in $xrs; do kubectl annotate "$xr" -n '"${NS}"' demo.example.org/retarget="$(date +%s)" --overwrite; done' fi pe "kubectl wait --for=condition=Ready --timeout=90s xwidgets.example.org/demo -n ${NS}" pe "kubectl wait --for=condition=Synced --timeout=90s xwidgets.example.org/demo -n ${NS}" @@ -631,7 +631,7 @@ else fi if [[ "${DEMO_MODE}" == gitops ]]; then note "Same as stage 3: mutateExisting is a no-op on 1.18.1, so write each XR." - pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl annotate "$xr" -n '"${NS}"' demo.example.org/retarget="$(date +%s)" --overwrite; done' + pe 'xrs=$(kubectl get xwidgets.example.org -n '"${NS}"' -o name) && [[ -n "$xrs" ]] && for xr in $xrs; do kubectl annotate "$xr" -n '"${NS}"' demo.example.org/retarget="$(date +%s)" --overwrite; done' fi pe 'for xr in $(kubectl get xwidgets.example.org -n '"${NS}"' -o name); do kubectl wait --for=condition=Ready --timeout=90s -n '"${NS}"' "$xr"; kubectl wait --for=condition=Synced --timeout=90s -n '"${NS}"' "$xr"; done' note "Storage is now v3. Same migrate-storage step as after the v2 promote." diff --git a/examples/crossplane-xr-multiversion/gitops/lib.sh b/examples/crossplane-xr-multiversion/gitops/lib.sh index 6ef96b3..b798415 100644 --- a/examples/crossplane-xr-multiversion/gitops/lib.sh +++ b/examples/crossplane-xr-multiversion/gitops/lib.sh @@ -913,12 +913,9 @@ gitops_ship() { if [[ "${fix_open_pr}" -eq 1 ]]; then echo "Pushed fix to ${pr}" gh pr edit "${pr}" --repo "${GITHUB_REPO}" --title "${title}" || true - local sha + local sha rc=0 sha="$(gitops_git rev-parse HEAD)" - set +e - gitops_wait_pr_checks "${pr}" "${sha}" - local rc=$? - set -e + gitops_wait_pr_checks "${pr}" "${sha}" || rc=$? if [[ "${rc}" -ne 0 ]]; then gitops_git checkout "${base}" die "CI failed for fix-up on ${pr}" @@ -939,12 +936,9 @@ gitops_ship() { --body "${body}")" echo "Opened ${pr}" - local sha + local sha rc=0 sha="$(gitops_git rev-parse HEAD)" - set +e - gitops_wait_pr_checks "${pr}" "${sha}" - local rc=$? - set -e + gitops_wait_pr_checks "${pr}" "${sha}" || rc=$? if [[ "${expect_fail}" -eq 1 ]]; then if [[ "${rc}" -eq 0 ]]; then