- Rust 84.3%
- PLpgSQL 5.9%
- Nix 5%
- Just 3.2%
- Go Template 1.6%
|
Some checks failed
Rust CI / build (push) Failing after 9m57s
The chart covers what an installation actually needs: the API deployment, its Service, an optional Gateway API route, an optional CloudNativePG cluster, and the secrets that join them. Chart: - CNPG is off by default. Most installations already have a database, and a chart that creates a stateful cluster on install would also propose deleting it on uninstall. When it is on, the Cluster carries helm.sh/resource-policy: keep, because byod's Postgres is the point of truth for every tenant, zone and record. - The DSN is composed by the secrets operator inside the cluster, so no password reaches the release's values. postgres.dsn and aead.key exist for development and say in NOTES what they cost. - Migrations run in an initContainer. Every replica races it and sqlx serialises them on an advisory lock, so the losers find nothing to do. - Only /v1 and /openapi.json are routed. Health answers on a second port and stays unreachable from outside; /readyz reaches the database. - Liveness touches nothing and readiness does. The other way round turns a brief database outage into a crash loop across every replica at once. Checks: - chart-command-check asserts every container states a command. The image sets no Entrypoint on purpose, so one that states only args runs nothing, and that surfaces at rollout rather than at render. - chart-lint renders with every optional template switched on and fails on an object without a namespace, because a renderer that applies the manifest itself supplies none and the object lands wherever kubectl points. - check-chart-version ties appVersion to the release tag. appVersion is the default for image.tag, so a mismatch installs a pod that cannot pull. CI: - A `chart` devshell with helm and yq alone, so a chart-only change waits on no Rust toolchain. The release job pushes the chart after the image, since the chart names an image tag and would otherwise briefly point at something unpublished. - The workflow gains the sqlx-check and openapi-check steps that `just ci` already ran, and release now runs the integration suite: a release should not be verified less thoroughly than a pull request. - openapi.json joins the package fileset. A test include_str!s it to assert the served document matches the committed one, so it is a build input, not only an artifact. |
||
|---|---|---|
| .forgejo/workflows | ||
| .sqlx | ||
| chart/byod-operator | ||
| crates | ||
| migrations | ||
| nix | ||
| .envrc | ||
| .gitignore | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| openapi.json | ||
| README.md | ||
| rust-toolchain.toml | ||
byod-operator
Multi-tenant DNS and certificate management for domains customers delegate to you.
A customer points a domain at a DNS provider, byod manages its records, obtains a certificate, and optionally serves it from the cluster. API-first: everything is drivable with curl before any frontend exists.
Status: scaffold. The workspace, flake and schema are in place. Nothing is implemented yet.
Shape
┌──────────────────────────────────────────┐
curl / web ──►│ byod-server (Rust, axum) │
│ REST API, tenants, zones, rrsets │
│ templates, operations, audit │
└───────────────┬──────────────────────────┘
│ writes intent
┌───────▼────────┐
│ Postgres │ ◄── the point of truth
│ (CloudNativePG)│
└───┬────────┬───┘
reads intent ────────┘ └──────── reads zone → tenant → token
writes ownership │
│ │
┌────────────▼──────────────┐ ┌───────────────▼───────────────┐
│ external-dns-pg (Go) │ │ byod-webhook (Rust) │
│ upstream external-dns │ webhook │ external-dns provider API │
│ + Postgres source ├───────────►│ cert-manager DNS01 solver │
│ + Postgres registry │ │ → Cloudflare / deSEC/ Hetzner│
└───────────────────────────┘ └───────────────────────────────┘
a separate repository
Nothing per-domain lives in the Kubernetes API server except what has to terminate traffic: a
Certificate, a TLS Secret, an HTTPRoute, and a Gateway listener. Tenants, zones and records
are rows.
Why Postgres rather than custom resources
The conventional shape is one DNSEndpoint and one Certificate per domain. At a few thousand
domains that is fine. At a hundred thousand it is over a gigabyte in etcd, an informer cache of the
same size in every controller, and external-dns re-listing all of it every sixty seconds.
Ownership has the same problem in a different place. external-dns records "I own this" as a TXT record in the customer's zone, which costs a second provider write per record and breaks outright at a zone apex, where the default naming puts the ownership record outside the zone.
So both move into a database: desired state is a SELECT, and ownership is a row. That is what
external-dns-pgregistry provides, and it is otherwise unmodified
upstream external-dns.
Crates
byod-core |
domain types and the ownership algebra. No I/O, so the parts worth getting right are testable as pure functions |
byod-store |
Postgres. The only crate that writes SQL, and the owner of migrations/ |
byod-dns |
Cloudflare, deSEC and Hetzner clients behind one trait |
byod-k8s |
the Kubernetes resources that are read and applied |
byod-templates |
declarative bundles describing what provisioning a domain produces |
byod-api |
the axum router, auth, and the OpenAPI document |
byod-server |
binary: the API and the reconcile loops |
byod-webhook |
binary: external-dns provider API and cert-manager DNS01 solver |
Per-tenant credentials, and why there are two webhooks in one binary
Each tenant brings their own provider token, so rate limits are per customer rather than one shared budget, and one leaked token exposes one customer.
That decides more than it looks. external-dns configures its provider once at startup with one credential, and cert-manager's DNS01 solvers reference a fixed Secret. Neither can resolve a token per zone. So byod serves both boundaries itself, over one set of provider clients, resolving zone to tenant to token on every call.
A side effect worth having: because the provider multiplexes internally, one external-dns instance covers all three providers rather than one instance per provider.
The database
migrations/ is the source of truth, and 0001_tenancy_and_dns_contract.sql also creates the three
relations external-dns-pgregistry reads: dns_zones, dns_desired_endpoints and dns_ownership.
That repository never runs DDL, so those column lists are a cross-repository interface — its
testdata/contract.sql states the requirement and this migration satisfies it.
dns_ownership is written only by that binary. byod reads it to answer "what do we own", and
must never write it: a claim inserted behind its back names a record it will then refuse to touch.
Development
$ nix develop # or `direnv allow`
$ just ci # fmt, clippy, unit tests, build, docs
$ just test-integration # also the tests that start a throwaway Postgres
$ just sqlx-prepare # after changing a query, so builds need no database
$ just images # OCI images, streamed to stdout
Queries are checked at compile time against committed sqlx metadata, so nix build needs no
database. A changed query without just sqlx-prepare compiles in the devshell and fails in the
sandbox, which the pre-push hook catches first.
License
MIT or Apache-2.0, at your option.