feat: migrate to Crossplane v2 and upgrade Backstage to 1.54.4 with Crossplane/Kyverno/Argo CD plugins - #27
Open
koorikla wants to merge 9 commits into
Conversation
Crossplane 1.19.0 -> 2.4.0. v2 removes claims: an apiextensions.crossplane.io/v2 XRD sets `spec.scope: Namespaced` and the XR itself becomes the user-facing API, so a team creates an XQueue directly in its own namespace instead of an XQueueClaim proxying a cluster-scoped composite. The knock-on effect is that composed resources must be namespaced too, since a namespaced XR cannot compose a cluster-scoped managed resource. provider-aws-sqs v2 ships every kind twice -- cluster scoped under sqs.aws.upbound.io and namespaced under sqs.aws.m.upbound.io -- and the composition now uses the latter. Those namespaced MRs reference a ClusterProviderConfig (group aws.m.upbound.io), which lets one LocalStack config be shared by every tenant namespace rather than copied into each. API and layout: - XRD -> apiextensions.crossplane.io/v2, scope Namespaced, claimNames dropped, printer columns added for location/synced/ready - crossplane/claims -> crossplane/xrs, each XR carrying metadata.namespace - crossplane/namespaces creates the team-a/team-b tenant namespaces in an earlier sync wave, because Argo CD's CreateNamespace only covers an Application's own destination namespace - Composition stays apiextensions.crossplane.io/v1 -- only the XRD API changed feat(crossplane): render compositions with function-go-templating Replaces function-patch-and-transform: the field mapping is now a Go template rather than a patch list. patch-and-transform derived XR readiness implicitly, so function-auto-ready is added to propagate Ready from the Queue up to the XQueue -- which is what the Argo CD health check for *.upbound.io keys off. chore(crossplane): drop provider-kubernetes Unused by any composition. Removes the provider, its ProviderConfig, its DeploymentRuntimeConfig and the cluster-admin ClusterRoleBinding it needed, and takes it out of the bootstrap's provider health check -- replaced there by provider-family-aws, which is pulled in as a dependency of provider-aws-sqs and supplies the ClusterProviderConfig CRD. Downstream updates: Kyverno rules now match XQueue instead of XQueueClaim; the Backstage template gained a namespace field and writes to crossplane/xrs; CI validates crossplane/xrs; CLAUDE.md documents the v2 model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 6cfab8c704ab964328c99481bb7dbc8ac91e776c)
Three bootstrap checks tested a proxy for the condition they cared about rather than the condition itself, and each one failed `make up` on a fresh cluster. argocd: the port-forward guard used `lsof` to ask whether anything held port 8080. The `rollout restart` immediately above kills the existing port-forward along with its pod, but the dying process still owns the socket for a moment -- so the guard saw the port as busy, skipped starting a replacement, and the `argocd login` that follows failed against a forward that was already gone. It now probes the port with curl, replaces the forward only when the probe fails, polls until it actually accepts connections instead of `sleep 5`, and fails loudly if it never comes up. backstage: `export $(cat .env | xargs)` splits a trailing `# comment` -- which the repo's own .env.example encourages -- into bare words that export rejects. Because the secret apply was chained onto it with `&&`, that failure silently skipped creating backstage-secrets instead of stopping the script, leaving Backstage running with no GitHub token. Now sourced under `set -a` so shell comment and quoting rules apply, with an explicit check that GITHUB_TOKEN is set. Also fixes the adjacent encoding: `echo` appended a newline into the base64, handing Backstage a token ending in \n, and GNU base64 line-wraps inputs over 76 chars -- so tokens were corrupted on Linux even though macOS does not wrap. kyverno: the readiness loop counted every container in the namespace reporting ready=false. Kyverno ships a `kyverno-migrate-resources` Job whose pod stays Succeeded with ready=false forever, so once that job completed the count could never reach zero and the wait timed out while Kyverno was perfectly healthy -- which meant the policies were never applied. Restricted to Running pods. fix(kyverno): grant RBAC to read XQueues Applying the policy warned that kyverno-reports-controller lacks get/list/watch on platform.hooli.tech/XQueue. Admission validation works regardless, but the policy sets `background: true` and background scanning has to read existing XQueues. Aggregates the rule into the reports and background controller roles. fix(xrd): drop duplicated printer columns Crossplane already appends SYNCED, READY, COMPOSITION and AGE, so declaring them again rendered each twice in `kubectl get xqueue`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit bceda9a5f0a0fb9a2583cbd848db05c5144a753b)
backstage-app: the source path `.bootstrap/backstage/manifests` contained
backstage-secrets with `GITHUB_TOKEN: <placeholder>`. That is not valid base64,
so every sync failed outright:
error when patching ... "" is invalid: patch: Invalid value: "":
error decoding from json: illegal base64 data at input byte 0
Argo CD was repeatedly trying to overwrite the real token -- rendered from .env
by up.sh -- with a value the API server rejects. The existing ignoreDifferences
entries could not have helped: a rule with no jsonPointers, jqPathExpressions or
managedFieldsManagers selects no fields, and the failure was the apply itself
rather than a diff. Both dead entries are removed.
A secret holding a real credential does not belong in a GitOps source path at
all, so the template moves one level up to
.bootstrap/backstage/backstage-secrets.template.yaml, outside the Application's
path. postgres-secrets stays under manifests/ -- its values are static dummies,
and Argo CD syncs it cleanly. This does not risk a prune of the live secret:
resource tracking is annotation-based and the live object carries no Argo CD
tracking annotation, precisely because every previous apply failed.
kyverno-app: the chart renders `metadata.labels: {}` on its 11 policy CRDs.
Kubernetes drops an empty map on write, so the live objects have no labels key
and Argo CD reported them OutOfSync forever -- while `argocd app diff` showed
the difference was exactly `> labels: {}` and every sync reported Succeeded.
Added an ignoreDifferences rule for CustomResourceDefinition .metadata.labels.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 8ea8672f784119c9e5fb5676f9209062749d749c)
…no plugins Backstage 1.47.3 -> 1.54.4 (@backstage/cli 0.35.3 -> 0.36.5, backend-defaults 0.15.1 -> 0.17.7), which is also the floor kubernetes-ingestor@4 requires. Replaces the hand-written CrossplaneEntityProvider with @terasky/backstage-plugin-kubernetes-ingestor. That provider was written against the v1 claim API and had to be corrected by hand for v2 -- it queried the `xqueuesclaim` plural in a hardcoded crossplane-system namespace, which returned 403 after the migration because the aggregated crossplane-view role only grants access to kinds that exist. The plugin understands v2 natively: it branches on the XRD's `spec.scope`, ingesting the XR kind directly for a Namespaced XRD rather than going through the claim v2 no longer has. It additionally generates a scaffolder template and an API entity per XRD, so the platform API is discoverable without hand-maintaining either. Frontend gains four entity tabs, all gated on `isCrossplaneAvailable` so they stay hidden for non-Crossplane entities: Crossplane Resources, Resource Graph, Kyverno Policy Reports and Kubernetes. These use the *Selector* components rather than the V2-specific ones, so a LegacyCluster XRD would still render correctly if one is ever added. kubernetesIngestor is configured with components.enabled: false. Left at its default the plugin ingests every workload in the cluster, which here would mean Argo CD's and Kyverno's own Deployments appearing as catalog components. Only Crossplane resources are ingested. `Template` had to be added to catalog.rules since the ingestor emits template entities. catalog.orphanProviderStrategy: delete cleans up the entities the removed provider left behind; without it the catalog warns about the orphaned 'crossplane-entity-provider' on every startup and its entities never expire. fix(argocd): report ClusterProviderConfig as healthy The Crossplane health check in argocd-cm only special-cased kind "ProviderConfig". Crossplane v2 renamed the cluster scoped config to ClusterProviderConfig (group aws.m.upbound.io), so crossplane-providers-config sat at Progressing forever even though the live object reported status.users: 2. Added the new kind to the has_no_status list and generalised the second branch to test membership of that list instead of a hardcoded string, plus ClusterProviderConfigUsage in resource.exclusions. chore: gitignore .claude/settings.local.json Personal Claude Code permissions; this repo is public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit b89595464eb5417e26e1fdf109e988a4b44aa698)
The scaffolder template kubernetes-ingestor generates from this XRD failed on
the location field:
Unsupported field schema for field root_location: Unknown field type undefined
{ "pattern": "^EU$" }
location was written as `oneOf: [{pattern: ^EU$}, {pattern: ^US$}]`. Neither
branch carries a `type`, so react-jsonschema-form cannot pick a widget and gives
up on the field. `enum: [EU, US]` accepts exactly the same two values and renders
as a dropdown.
The hand-written template hid this by hardcoding `enum: [EU, US]` in its own
parameter schema rather than deriving it from the XRD, so the defect only became
visible once the form was generated from the schema itself.
Upper bounds on maxMessageSize and visibilityTimeoutSeconds are still absent from
the schema on purpose: validate-xqueue-fields enforces those, and that split is
what demonstrates admission policy catching what the CRD schema does not.
Verified with `crossplane render`: the composition still maps EU -> eu-north-1
and produces a namespaced Queue in team-a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d4db4ed80fa6405aed5c6da36ea847de155ad76e)
…gestion
Four changes, three of them fixes for things the earlier plugin work got wrong.
feat: Argo CD plugin (@roadiehq/backstage-plugin-argo-cd{,-backend})
kubernetes-ingestor already annotates ingested entities with `argocd/app-name`,
which is exactly the annotation this plugin resolves, so Crossplane resources
surface their Argo CD application with no extra annotation work.
Argo CD serves a self-signed certificate. Rather than setting
NODE_TLS_REJECT_UNAUTHORIZED=0 -- which would disable certificate verification
for every outbound request Backstage makes, including the GitHub API -- the
certificate's SANs were checked and do cover
argocd-server.argocd-system.svc.cluster.local, so only the issuer is untrusted.
up.sh now copies Argo CD's tls.crt into backstage-system as a ConfigMap and the
deployment points NODE_EXTRA_CA_CERTS at it, trusting exactly that certificate.
The volume is optional so Backstage still starts before the bootstrap has run.
Credentials come from ARGOCD_PASSWORD in backstage-secrets rather than being
written into config that lives in a public repo.
feat: API entities as OpenAPI instead of raw CRD YAML
ingestAPIsAsCRDs defaulted to true, so each XRD became an API entity of type
"crd" whose definition was the CRD dump. Set to false, they are type "openapi";
EntityApiDefinitionCard is already wired into the API entity page and renders
that as browsable Swagger UI, making each XRD real documentation of the platform
API.
fix: re-enable component ingestion
components.enabled was set to false to keep Argo CD's and Kyverno's own
Deployments out of the catalog, on the assumption it filtered workloads. It does
not -- it short-circuits the whole KubernetesEntityProvider and removes
everything it had already tracked:
if (!componentsEnabled) { /* remove all tracked entities */ return }
so Crossplane XRs stopped being ingested too, leaving only the XRD-derived
Templates and APIs. Restored to true and narrowed with excludedNamespaces, which
is the filter that actually applies per resource. Tenant namespaces pass, so XRs
and the Deployment/Service an XMicroservice composes are ingested.
fix: raise the database connection acquire timeout
Each plugin opens its own pool at startup. On a contended host that exceeded
knex's 60s default and the plugin died with "KnexTimeoutError: Timeout acquiring
a connection", while the backend kept running with that plugin's routes
unmounted -- 404s from /api/catalog when it hit catalog, 401s from every
authenticated call when it hit auth, with the frontend still serving 200 so
nothing looked broken. Raises only the acquire ceiling; an unreachable database
still fails on connect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1792a03f0d5e75e3b1dc6b02f335152c05a19509)
Both were open questions on the roadmap and are answered by this branch, so tick them off rather than leaving them as things to evaluate. The ingestor entry records two behaviours that cost real time to work out and are not covered by the plugin's documentation: `components.enabled: false` does not filter workloads but disables the entity provider entirely and drops what it had tracked, and the API entities it generates hardcode a system name that has to exist in the catalog. Also notes that the ingestion loop the item mentions did not reproduce on v4.0.0, without claiming it was fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
localstack/localstack:latest now resolves to the 2026.x unified image, which exits 55 with "License activation failed! No credentials were found in the environment" unless LOCALSTACK_AUTH_TOKEN is set. The deployment crash-loops and `make up` fails waiting for it. 4.14.0 is the last community release that starts without a licence. Pinned through helm.parameters rather than argocd/apps/localstack/values.yaml: for a chart-repo source `valueFiles` resolves against the chart, not this repository, so that values file is silently ignored today -- as is argocd/apps/kyverno/values.yaml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make up` is documented as idempotent, but the second run fails immediately: UPGRADE FAILED: conflict occurred while applying object argocd-system/argocd-cm: conflict with "kubectl-patch" using v1: .data.resource.exclusions This script patches argocd-cm with kubectl shortly after installing the chart. Helm 4 applies server-side, so that patch's field manager takes ownership of .data.resource.exclusions and Helm refuses to overwrite it on the next run. --force-conflicts lets Helm reclaim the field; the kubectl patch runs again a few lines later and sets it back, so the end state is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi! Thanks for this project — it's a genuinely useful reference stack. I ran it locally, hit a few things, and ended up doing the Crossplane v2 migration. Opening this in case it's useful; happy to split it up, drop parts, or rework anything.
There were no open issues covering this, so nothing to link.
Why
Crossplane v2 removes claims. On
apiextensions.crossplane.io/v2,claimNamesis explicitly deprecated — "Claims aren't supported in apiextensions.crossplane.io/v2" — and a namespaced XR becomes the user-facing API instead. The stack currently models everything aroundXQueueClaim, so moving to v2 changes the API surface rather than just a version number.What changed
Crossplane 1.19.0 → 2.4.0, claims → namespaced XRs
apiextensions.crossplane.io/v2withscope: Namespaced; a team creates anXQueuedirectly in its namespace.crossplane/claims/→crossplane/xrs/, each XR carryingmetadata.namespace.crossplane/namespaces/creates theteam-a/team-btenant namespaces in an earlier sync wave — Argo CD'sCreateNamespace=trueonly covers an Application's own destination namespace, not where tenant XRs land.A knock-on effect: a namespaced XR can't compose a cluster-scoped managed resource.
provider-aws-sqs:v2ships every kind twice — cluster-scoped undersqs.aws.upbound.ioand namespaced undersqs.aws.m.upbound.io— so the composition uses the namespaced Queue, which Crossplane places in the XR's own namespace. Those reference aClusterProviderConfig(groupaws.m.upbound.io) so one LocalStack config is shared by every tenant namespace.go-templating replaces patch-and-transform, with
function-auto-readyadded — patch-and-transform derived XR readiness implicitly, and a go-templating-only pipeline needs it explicitly.provider-kubernetes dropped — unused by any composition. Its ProviderConfig, DeploymentRuntimeConfig and cluster-admin binding go with it.
Backstage 1.47.3 → 1.54.4 and the hand-written entity provider replaced.
packages/backend/src/crossplane-entity-provider.tsqueries thexqueuesclaimplural in a hardcoded namespace, which returns 403 after the migration — the aggregatedcrossplane-viewrole only grants access to kinds that exist.@terasky/backstage-plugin-kubernetes-ingestorhandles v2 natively (it branches on the XRD'sspec.scope) and additionally generates a scaffolder template and an API entity per XRD, so the platform API is discoverable without hand-maintaining either. Added alongside it: crossplane-resources-frontend, kyverno-policy-reports, the two permissions backends, and RoadieHQ's Argo CD plugin — the ingestor already annotates entities withargocd/app-name, which is exactly what it resolves.Bug fixes found while getting
make upto completeThese are independent of the migration and each one broke a clean run:
.bootstrap/argocd/up.sh— the port-forward guard usedlsofto test whether something held port 8080. Therollout restartimmediately above kills the existing forward, but the dying process still owns the socket, so the guard skipped starting a replacement and the followingargocd loginfailed against a forward that was already gone. Now probes the port and waits until it actually accepts connections..bootstrap/backstage/up.sh—export $(cat .env | xargs)splits a trailing# commentinto bare words thatexportrejects. Because the secret apply was chained with&&, that failure skipped creating the secret instead of stopping the script, leaving Backstage running with no GitHub token and nothing reporting it. Alsoecho "$TOKEN" | base64bakes a trailing newline into the secret, and GNU base64 line-wraps past 76 chars, so longer tokens were corrupted on Linux..bootstrap/kyverno/up.sh— the readiness loop counted every container reportingready=false, including thekyverno-migrate-resourcesJob pod, which staysSucceeded/ready=falseforever. Once that job had run the wait could never pass, so it timed out with Kyverno perfectly healthy — and the policies were never applied.argocd/apps/localstack/app.yaml—localstack/localstack:latestnow resolves to the 2026.x unified image, which exits 55 (License activation failed) without aLOCALSTACK_AUTH_TOKEN. Pinned to4.14.0, the last community release that starts unlicensed. Pinned viahelm.parametersbecausevalueFilesdoesn't resolve against the repo for a chart-repo source — it reads the chart's ownvalues.yaml, soargocd/apps/localstack/values.yamlwas being silently ignored. Worth knowing:argocd/apps/kyverno/values.yamlis ignored for the same reason.backstage-appfailed every sync withillegal base64 data at input byte 0, because the source path containsbackstage-secretswithGITHUB_TOKEN: <placeholder>; Argo CD was repeatedly trying to overwrite the real token with a value the API server rejects. The secret template moved outside the Application's path.kyverno-appreported 11 CRDs OutOfSync forever because the chart rendersmetadata.labels: {}and Kubernetes drops the empty map — added anignoreDifferencesfor it.Verification
Everything was exercised on a local kind cluster via
make up, which now exits 0:v2.4.0; XRDapiextensions.crossplane.io/v2,scope=Namespacedprovider-aws-sqs+upbound-provider-family-awsand both functions HealthySYNCED=True READY=True, each composing a namespaced Queue in its own namespace, backed by real LocalStack queues at the right regions (team-a→sqs.eu-north-1…,team-b→sqs.us-east-2…)maxMessageSize: 999999at admission — a bound only the policy enforces, not the XRD schemaThe composition was also checked with
crossplane renderon both branches of every conditional, including the case where all optional fields are omitted.Notes
locationmoved fromoneOf: [{pattern: ^EU$}, {pattern: ^US$}]toenum: [EU, US]. Same accepted values, but neitheroneOfbranch carried atype, so the scaffolder form generated from the schema failed with "Unsupported field schema for field root_location". The hand-written template hid this by hardcoding the enum in its own parameter schema.maxMessageSize/visibilityTimeoutSecondsare still deliberately absent from the XRD — Kyverno enforces those, and that split is what demonstrates admission policy catching what the CRD schema doesn't.kubernetesIngestor.components.enabledmust staytrue: setting it false doesn't filter workloads, it short-circuits the whole provider and removes what it already tracked. System namespaces are excluded viaexcludedNamespacesinstead.