Skip to content

Feature crossplane v2 and backstage plugins - #1

Merged
koorikla merged 6 commits into
mainfrom
feature-crossplane-v2-and-backstage-plugins
Aug 26, 2026
Merged

koorikla merged 6 commits into
mainfrom
feature-crossplane-v2-and-backstage-plugins

Conversation

@koorikla

Copy link
Copy Markdown
Owner

No description provided.

koorikla and others added 6 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)
@koorikla
koorikla merged commit a77364b into main Aug 26, 2026
1 check passed
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