CI workflow (.github/workflows/pool-domain-manager-build.yaml) mirrors
the marketplace-api / catalyst-api shape:
- Triggers on push to core/pool-domain-manager/** + workflow_dispatch
- Runs unit tests (reserved + dynadot — the integration suite needs a
real Postgres which the workflow does not provide; full integration
runs in test-bootstrap-api.yaml against an ephemeral CNPG)
- Builds and pushes ghcr.io/openova-io/openova/pool-domain-manager:<sha>
- Cosign-signs the image via Sigstore keyless OIDC (id-token: write)
- Emits an SBOM attestation tied to the image digest
- Manifest deployment is intentionally NOT in this workflow — PDM
manifests live in the openova-private repo per the issue body, so
the Flux Kustomization there picks up the new SHA via a follow-up
private-repo commit (Phase 6 of #163)
Crossplane composition (platform/crossplane/compositions/xrd-pool-
allocation.yaml + composition-pool-allocation.yaml) wraps PDM as a
declarative Crossplane Resource:
apiVersion: compose.openova.io/v1alpha1
kind: XDynadotPoolAllocation
spec:
parameters:
poolDomain: omani.works
subdomain: omantel
sovereignFQDN: omantel.omani.works
loadBalancerIP: 1.2.3.4
createdBy: crossplane
The Composition uses provider-http (crossplane-contrib/provider-http) to
render the XR into a Reserve → Commit sequence of HTTP calls against
PDM's in-cluster service URL. Per docs/INVIOLABLE-PRINCIPLES.md #3 we use
provider-http rather than bespoke Go to keep the day-2 lifecycle
declarative. Operators who want to pre-allocate a name (e.g. reserve
'omantel.omani.works' for a Sovereign that hasn't been provisioned yet)
commit YAML to Git and Flux+Crossplane converge.
Refs: #163
|
||
|---|---|---|
| .. | ||
| composition-firewall.yaml | ||
| composition-loadbalancer.yaml | ||
| composition-network.yaml | ||
| composition-pool-allocation.yaml | ||
| composition-server.yaml | ||
| README.md | ||
| xrd-firewall.yaml | ||
| xrd-loadbalancer.yaml | ||
| xrd-network.yaml | ||
| xrd-pool-allocation.yaml | ||
| xrd-server.yaml | ||
Catalyst Crossplane Compositions — canonical Hetzner XRDs
XRD API group: compose.openova.io/v1alpha1
(per docs/BLUEPRINT-AUTHORING.md §8 + VALIDATION-LOG.md Pass 42/48; never catalyst.openova.io — that is the Catalyst CRD group, not the Crossplane composite group.)
This directory contains the four canonical Hetzner-backed XRDs + their default Compositions that Catalyst uses to manage day-2 cloud infrastructure on a franchised Sovereign. After Phase 0 (infra/hetzner/main.tf) hands off to Phase 1, all further Hetzner resources — additional regions, attached volumes, additional firewalls, additional load balancers — go through these XRDs and are reconciled by Crossplane.
Per docs/INVIOLABLE-PRINCIPLES.md principle #3:
Crossplane is the ONLY IaC after Phase 1 hand-off. Not direct provider SDKs. Not Terraform. Not the catalyst-api Go service calling cloud APIs.
XRDs in this directory
| XRD | Wraps |
|---|---|
XHetznerNetwork |
hcloud_network + hcloud_network_subnet (provider-hcloud Network + NetworkSubnet) |
XHetznerFirewall |
hcloud_firewall (provider-hcloud Firewall) |
XHetznerServer |
hcloud_server (provider-hcloud Server) |
XHetznerLoadBalancer |
hcloud_load_balancer + targets + services (provider-hcloud LoadBalancer + LoadBalancerTarget + LoadBalancerService) |
Each xrd-*.yaml declares the OpenAPIv3 schema; each matching composition-*.yaml references the upstream provider-hcloud managed resources.
Why these four
These mirror the four resource families OpenTofu provisions in infra/hetzner/main.tf Phase 0. After Phase 1 hand-off, Crossplane adopts the OpenTofu-created resources by external-name (the Hetzner numeric resource ID), and any further changes — adding a worker, opening a port, adding a region — are made by submitting an XR (claim) of the appropriate type instead of editing OpenTofu state.
Provider configuration
The provider itself (provider-hcloud) and its ProviderConfig are installed by platform/crossplane/chart/templates/provider-hcloud.yaml, which is reconciled by Flux from the cluster directory. The Hetzner API token is mounted from a K8s Secret named hcloud-credentials in the crossplane-system namespace — that secret is created by the OpenTofu module's hand-off step.
Adoption pattern
When OpenTofu creates a resource in Phase 0, the resource gets a label like:
catalyst.openova.io/sovereign: omantel.omani.works
catalyst.openova.io/role: control-plane
Phase 1 ingests these into Crossplane by creating an XR with metadata.annotations[crossplane.io/external-name] set to the Hetzner numeric ID. Crossplane then takes over the lifecycle — kubectl delete xhetznerserver/cp1 after Phase 1 will deprovision the underlying Hetzner server, just like tofu destroy would have done in Phase 0. (See clusters/<sovereign-fqdn>/infrastructure/adoption-claims.yaml for the bootstrap claim manifests.)
Authoring conventions
- Every XRD's
groupiscompose.openova.ioandversions[0].nameisv1alpha1. - Every XR's plural is
<kind-lowercase>s(e.g.xhetznerservers). - Every XRD declares a
claimNamesblock so users can submit namespaced claims (HetznerServer) instead of cluster-scoped XRs (XHetznerServer). defaultCompositionRefpoints at the matchingcomposition-*.yamlshipped here.- Per principle #4 (no hardcoding): every cloud-specific value (region, server type, image) is a schema field, never a constant in the Composition.
Adding a new XRD
- Drop
xrd-<resource>.yamlandcomposition-<resource>.yamlin this directory. - Reference the matching upstream provider-hcloud kind under
spec.resources[].base. - Add the file to
kustomization.yaml. - Bump
Chart.yamlversion ofbp-crossplane.
The CI (.github/workflows/blueprint-release.yaml) re-publishes bp-crossplane to GHCR on the next push, and Flux reconciles the new XRDs into every Sovereign on its next pull.