Skip to content

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
wnqueiroz:mainfrom
koorikla:feature-crossplane-v2-and-backstage-plugins
Open

feat: migrate to Crossplane v2 and upgrade Backstage to 1.54.4 with Crossplane/Kyverno/Argo CD plugins#27
koorikla wants to merge 9 commits into
wnqueiroz:mainfrom
koorikla:feature-crossplane-v2-and-backstage-plugins

Conversation

@koorikla

Copy link
Copy Markdown

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, claimNames is 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 around XQueueClaim, 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

  • XRD is now apiextensions.crossplane.io/v2 with scope: Namespaced; a team creates an XQueue directly in its namespace.
  • crossplane/claims/crossplane/xrs/, each XR carrying metadata.namespace.
  • crossplane/namespaces/ creates the team-a/team-b tenant namespaces in an earlier sync wave — Argo CD's CreateNamespace=true only 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:v2 ships every kind twice — cluster-scoped under sqs.aws.upbound.io and namespaced under sqs.aws.m.upbound.io — so the composition uses the namespaced Queue, which Crossplane places in the XR's own namespace. Those reference a ClusterProviderConfig (group aws.m.upbound.io) so one LocalStack config is shared by every tenant namespace.

go-templating replaces patch-and-transform, with function-auto-ready added — 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.ts queries the xqueuesclaim plural in a hardcoded namespace, which returns 403 after the migration — the aggregated crossplane-view role only grants access to kinds that exist. @terasky/backstage-plugin-kubernetes-ingestor handles v2 natively (it branches on the XRD's spec.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 with argocd/app-name, which is exactly what it resolves.

Bug fixes found while getting make up to complete

These are independent of the migration and each one broke a clean run:

  • .bootstrap/argocd/up.sh — the port-forward guard used lsof to test whether something held port 8080. The rollout restart immediately above kills the existing forward, but the dying process still owns the socket, so the guard skipped starting a replacement and the following argocd login failed against a forward that was already gone. Now probes the port and waits until it actually accepts connections.
  • .bootstrap/backstage/up.shexport $(cat .env | xargs) splits a trailing # comment into bare words that export rejects. 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. Also echo "$TOKEN" | base64 bakes 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 reporting ready=false, including the kyverno-migrate-resources Job pod, which stays Succeeded/ready=false forever. 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.yamllocalstack/localstack:latest now resolves to the 2026.x unified image, which exits 55 (License activation failed) without a LOCALSTACK_AUTH_TOKEN. Pinned to 4.14.0, the last community release that starts unlicensed. Pinned via helm.parameters because valueFiles doesn't resolve against the repo for a chart-repo source — it reads the chart's own values.yaml, so argocd/apps/localstack/values.yaml was being silently ignored. Worth knowing: argocd/apps/kyverno/values.yaml is ignored for the same reason.
  • Two permanently OutOfSync Applicationsbackstage-app failed every sync with illegal base64 data at input byte 0, because the source path contains backstage-secrets with GITHUB_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-app reported 11 CRDs OutOfSync forever because the chart renders metadata.labels: {} and Kubernetes drops the empty map — added an ignoreDifferences for it.

Verification

Everything was exercised on a local kind cluster via make up, which now exits 0:

  • Crossplane v2.4.0; XRD apiextensions.crossplane.io/v2, scope=Namespaced
  • provider-aws-sqs + upbound-provider-family-aws and both functions Healthy
  • Both XQueues SYNCED=True READY=True, each composing a namespaced Queue in its own namespace, backed by real LocalStack queues at the right regions (team-asqs.eu-north-1…, team-bsqs.us-east-2…)
  • Kyverno denies an XQueue with maxMessageSize: 999999 at admission — a bound only the policy enforces, not the XRD schema
  • Backstage, Argo CD, Crossview and LocalStack all serving; the ingestor registers the XRs and generates the template and API entity per XRD

The composition was also checked with crossplane render on both branches of every conditional, including the case where all optional fields are omitted.

Notes

  • location moved from oneOf: [{pattern: ^EU$}, {pattern: ^US$}] to enum: [EU, US]. Same accepted values, but neither oneOf branch carried a type, 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.
  • Upper bounds on maxMessageSize/visibilityTimeoutSeconds are 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.enabled must stay true: setting it false doesn't filter workloads, it short-circuits the whole provider and removes what it already tracked. System namespaces are excluded via excludedNamespaces instead.
  • This targets LocalStack as the repo already does; I haven't tested against real AWS.

koorikla and others added 9 commits August 26, 2026 02:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant