Files
yucca/tf/README.md
T

596 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# yucca/tf
Terraform/OpenTofu authority for cluster identity, 1P secret items, and the
inventory artifacts Ansible consumes. Multi-partition / multi-region via
terragrunt.
## The partition / region model
Everything is keyed on a single first-class topology:
**partition → region → { exactly one K8s cluster, one-or-more Ceph clusters }**
- **Partition** = `prod` | `staging` | `dev` (formerly `env`).
- **Region** = a site: `htz-fsn1`, `austin`, `local` (formerly `site`/`datacenter`).
Plus a reserved **`global`** pseudo-region for partition-wide stacks (DNS, the
account-wide NetBird layer) — `global` is not a physical site, so its
`role`/FQDN metadata is null.
- The human form is `partition@region` (`prod@htz-fsn1`); the canonical slug is
`partition-region` (`prod-htz-fsn1`), which derives names on every surface.
Every stack sits at **three path segments**:
`deployment/<partition>/<region>/<stack>`. The account-wide prod layer is
`prod/global/netbird` (a stack under the `global` region), so there is no
two-segment special case.
## Layout
```
tf/
├── .env ← op:// references (committed; no literal secrets)
├── op-run.sh ← op-run wrapper used by the mise tf:* tasks
├── shared/
│ └── modules/
│ ├── ceph-cluster/ ← per-cluster ceph orchestration module
│ │ ├── main.tf, variables.tf, outputs.tf, rendering.tf
│ │ ├── wordlist.txt ← 923 words for auto-picked hostnames
│ │ └── templates/
│ │ ├── inventory.ini.tftpl
│ │ ├── inventory-destroy.ini.tftpl
│ │ ├── inventory-provision-debian-live.ini.tftpl
│ │ └── secrets.yml.tpl.tftpl
│ └── talos-baremetal/ ← Talos on bare-metal nodes already in maintenance mode
│ ├── main.tf, variables.tf, outputs.tf
│ └── firewall.tf ← Talos host ingress firewall (default-deny + allow-lists)
└── deployment/
├── terragrunt.hcl ← root: state backend + partition/region/stack from path
├── staging/
│ ├── austin/
│ │ ├── region.hcl ← role + FQDN parts for staging@austin
│ │ ├── ceph/ ← sietch ceph cluster (clusters.auto.tfvars)
│ │ └── talos/ ← bare-metal Talos cluster (3× CP, Cilium CNI)
│ └── global/
│ ├── region.hcl ← role=null (pseudo-region)
│ ├── dns/ ← Cloudflare records (futo.cloud)
│ └── netbird/ ← NetBird Cloud access control (staging)
├── dev/
│ ├── local/
│ │ ├── region.hcl
│ │ └── talos/ ← Talos VMs on the sietch hypervisors
│ └── global/
│ ├── region.hcl
│ └── dns/
└── prod/
├── htz-fsn1/
│ ├── region.hcl ← role + FQDN parts; site_id=40
│ ├── mgmt-hosts.yaml ← mgmt-host roster (region root; fabric + render read it)
│ ├── fabric/ ← Junos switch fabric + NetBox + mgmt reprovision
│ └── netbird/ ← htz-fsn1 site NetBird layer (routed htz-fsn1 network)
└── global/
├── region.hcl
├── terragrunt.hcl ← account-wide prod NetBird (two-segment region-root stack)
└── netbird.tf
```
Add a region: create `deployment/<partition>/<region>/region.hcl` + stacks under
it. Add a stack: a new sibling dir under a region (`<region>/monitoring/` …).
NetBird Cloud access control lives in `staging/global/netbird/`, and for prod is
layered: `prod/global/` (account-wide) above per-region
`prod/<region>/netbird/` (e.g. `prod/htz-fsn1/netbird/`). See "The netbird-env
module" below.
The dns stack manages infrastructure names in the futo.cloud Cloudflare
zone (today: the Sietch RGW S3 endpoint + virtual-hosted wildcard).
Records are declarative in `records.auto.tfvars`; the API token resolves
from `op://yucca_tf_manual/CLOUDFLARE_API_TOKEN` via `tf/.env`.
The talos stack is documented in `ansible/talos/README.md` and
`ansible/talos/docs/runbooks/cluster-bring-up.md` (the TF + Ansible flow
is interleaved — TF renders the inventory Ansible consumes, then
bootstraps the VMs Ansible created).
## Conventions
### Partition, region, and stack are derived from the directory path
`deployment/terragrunt.hcl` parses the child's relative path:
`partition = segs[0]`, `region = segs[1]`, `stack = join(segs[2:])`, and
`slug = "<partition>-<region>"`.
```
deployment/staging/austin/ceph → partition=staging, region=austin, stack=ceph
deployment/staging/global/dns → partition=staging, region=global, stack=dns
deployment/prod/htz-fsn1/fabric → partition=prod, region=htz-fsn1, stack=fabric
deployment/prod/htz-fsn1/netbird → partition=prod, region=htz-fsn1, stack=netbird
deployment/prod/global/netbird → partition=prod, region=global, stack=netbird
```
The state backend key is derived from these:
`yucca/${partition}/${region}/${stack}/terraform.tfstate` in the shared
`yucca-tf-state` S3 bucket. (The legacy `ceph/` project prefix is dropped —
talos/dns/netbird/fabric all share the bucket now.) Every stack is three
segments — the account-wide prod layer is `prod/global/netbird` (not a bare
`prod/global`), so `region.hcl` at `prod/global/` is found by
`find_in_parent_folders` from the stack dir, exactly like `staging/global`. The
`n==2` branch in `terragrunt.hcl` is now dead and can be removed.
### Per-region metadata + role (`region.hcl`)
Each region dir carries a `deployment/<partition>/<region>/region.hcl` holding
`role` (`primary` | `secondary`), `site_id`, `datacenter`, `provider_code`, and
`domain`. The root terragrunt finds it via
`find_in_parent_folders("region.hcl", "")` (with a not-found guard) and merges
its `locals` into every stack's inputs — so every stack in a region inherits the
metadata without per-tfvars duplication. `global` pseudo-regions set `role = null`
(and null FQDN parts). Each stack declares matching `variable` blocks with null
defaults.
`role` encodes the product invariant: when a partition spans multiple regions,
**yucca-api + the database run only in the `primary` region**; secondary regions
run the storage-local subset. It is authoritative here in TF state
(`discovery.role`) and consumed downstream (Flux role components, yuctl).
### The `op run --env-file=tf/.env --` pattern
`tf/.env` holds 1Password `op://` references — **not literal secrets**:
```sh
export OP_SERVICE_ACCOUNT_TOKEN="op://yucca_tf_dev/yucca_futo_1pass_superuser_service_account/password"
```
Wrap every terragrunt invocation with `op run --env-file=tf/.env --` (the
mise `tf:*` tasks do this automatically). The op CLI resolves the `op://`
reference and injects the actual token as `OP_SERVICE_ACCOUNT_TOKEN` into
the child process's environment. The 1P Terraform provider picks it up
from the env var and authenticates.
The same pattern is used in `immich-app/devtools` and is the Futo-wide
convention for TF secret injection.
### Committed `.env` is safe because it's just pointers
Yucca's root `.gitignore` normally excludes `.env` files — we add an
explicit `!tf/.env` exception. This file contains only `op://` URIs; no
secret ever transits the repo. It's a committed manifest of "which 1P
items this TF depends on."
### Stack override via `TF_STACK_DIR`
The default `mise run tf:*` tasks target `tf/deployment/staging/austin/ceph`.
Point them at another stack via the `TF_STACK_DIR` env var:
```bash
TF_STACK_DIR=tf/deployment/staging/austin/ceph mise run tf:plan
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:apply
```
## Running TF
### One-shot (preferred for now)
```bash
mise run tf:init # first time in a stack
mise run tf:plan # dry run
mise run tf:apply # render artifacts + (future) create 1P items
```
These wrap: `op run --env-file=tf/.env -- terragrunt --working-dir <stack> <cmd>`.
### In CI (staging stacks)
`.github/workflows/infra.yml` runs the staging stacks from GitHub Actions:
- **Plan** on every PR touching `tf/**`; **apply** on merge to `main`, gated
behind the `staging-infra` Environment (required reviewers).
- Applies `staging/austin/talos` (cluster + Flux + secrets) then `staging/global/dns`.
- The Talos stack reaches the `10.10.10.0/24` nodes by joining the **tailnet**
(`tailscale/github-action`, `--accept-routes`) — the cluster firewall already
trusts the Tailscale CIDRs. The DNS stack is pure Cloudflare API, no tailnet.
- Secrets come from the same `op run --env-file=tf/.env` path; CI just supplies
the per-env 1P service-account token (the rest resolves from 1P). The token is
injected as `OP_SERVICE_ACCOUNT_TOKEN` from the environment-specific secret —
`OP_TF_YUCCA_STAGING_ENV` here (dev/prod workflows use `OP_TF_YUCCA_DEV_ENV` /
`OP_TF_YUCCA_PROD_ENV`) — replacing a shared superuser SA with a scoped one.
Prerequisites (out-of-band): repo secret `OP_TF_YUCCA_STAGING_ENV` — a **staging**
1P service account mirroring the dev/prod ones, i.e. granted `shared_tf`,
`shared_tf_staging`, `yucca_tf` (read) + `yucca_tf_staging` (read/write for the
JWT-keypair item). Note: a copy of the *dev* SA token won't work — it can't read
`yucca_tf_staging`. Also: `TS_OAUTH_CLIENT_ID`, `TS_OAUTH_SECRET`; a Tailscale
subnet router advertising `10.10.10.0/24` with `tag:project-yucca` approved for
it; and the `staging-infra` Environment with required reviewers.
### State backend
Remote: shared `yucca-tf-state` S3 bucket at OVH Paris
(`https://s3.eu-west-par.io.cloud.ovh.net/`). Key path:
`yucca/${partition}/${region}/${stack}/terraform.tfstate` (region-root stacks:
`yucca/${partition}/${region}/...`). All yucca stacks live under the `yucca/`
prefix in the shared bucket.
Credentials are AWS-compatible env vars (`AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY`), injected via `op run --env-file=tf/.env` from
the `TF_STATE_S3_*` items in the `yucca_tf` vault. OVH-specific config
(skip AWS validation, path-style URLs) is in
`deployment/terragrunt.hcl`.
**State locking is not enabled.** OVH has no DynamoDB equivalent;
OpenTofu's `use_lockfile = true` option would handle single-bucket locking
but expects the lockfile object to already exist — `terragrunt init`
against a fresh backend fails with 404 before it can create one. Enable
it once the concern is concurrent applies (multiple operators working the
same stack simultaneously). Single operator today → low risk.
## Discovery outputs contract
Every stack emits a single non-sensitive top-level **`discovery`** output (plus
`discovery_schema_version`). It is the machine-readable description of the
topology that `yuctl` consumes — it reads the state object straight from S3 and
parses `.outputs.discovery.value`, so no checkout, init, or provider is needed.
**Secrets are always `op://` references, never values.** The in-stack sensitive
`kubeconfig`/`talosconfig` outputs stay for in-stack use; discovery only carries
the *reference* (`op://<vault>/<title>/password`).
Common **envelope** (every stack):
```
{
schema_version, partition, region, slug, role, stack, stack_type,
region_meta: { site_id, datacenter, provider_code, domain }
}
```
Per `stack_type` **payload**:
| stack_type | stacks | payload key + fields |
|---|---|---|
| `region-k8s` | `*/talos` | `kubernetes: { cluster_name, api_endpoint, operator_endpoint, cp_node_ips, kubeconfig_ref, talosconfig_ref }` |
| `ceph` | `*/ceph` | `ceph_clusters: { <name> => { cluster_name, fqdn, rgw_s3_endpoint, health_cred_ref, s3_admin_cred_refs, secret_item_titles, bootstrap_host } }` |
| `dns` | `*/dns` | `dns: { provider, zone, record_fqdns, api_token_ref }` |
| `netbird` | `*/netbird`, `prod/global` | `netbird: { name_prefix, vault, group_ids, policy_ids, network_ids, setup_key_item_titles }` |
| `fabric` | `prod/htz-fsn1/fabric` | `fabric: { site_id, kube_cidr, mgmt_cidr, cluster_cidrs }` |
The talos stacks persist their kube/talosconfig into 1Password
(`onepassword_item`, mirroring the JWT-keypair / netbird-setup-key pattern) so
the `*_ref` fields resolve — staging writes `YUCCA_STAGING_{KUBE,TALOS}CONFIG`;
dev writes `YUCCA_DEV_<CLUSTER>_{KUBE,TALOS}CONFIG`.
## Migrating an existing stack to the new layout
The dir moves + terragrunt rewrite change the **state key** (`ceph/<env>/...` →
`yucca/<partition>/<region>/...`), so a live stack's remote state must be
re-pointed — reversibly, no destroy/recreate. Per stack (single-operator window;
no state locking):
1. Pre-flight `terragrunt plan` on the OLD layout → confirm **no-op**; back up
the old state object.
2. Land the terragrunt.hcl rewrite + dir moves + renames (no apply yet).
3. **Server-side S3 copy** old key → new `yucca/...` key (old object stays as a
rollback anchor).
4. Delete the stale generated `backend.tf`; `terragrunt init` in the new dir
(decline the state-copy prompt; `-reconfigure` if needed).
5. `terragrunt plan` → **must be no-op** (the gate; a non-empty plan = a
rename/source-path mismatch, not a state problem — STOP + roll back).
6. `terragrunt output discovery` → confirm the contract resolves.
7. After **all** stacks are green, `aws s3 rm` the legacy `ceph/*` objects.
Order: staging first, then prod (`global` → region → fabric — `prod/global`
before `prod/htz-fsn1/netbird`, which has a real terragrunt `dependency` on it).
`dev` is dir-move only (no remote state). The NetBird plans must show **no
resource renames** — the rendered names are byte-identical across the rename.
## The ceph-cluster module
Declarative input in `clusters.auto.tfvars`:
```hcl
clusters = {
sietch = {
domain = "staging.austin.int.futo.cloud"
partition = "staging"
region = "austin"
provider_code = "int"
role_in_hostname = "ceph"
ansible_ssh_user = "ansible-iac"
ansible_ssh_key = "~/.ssh/id_ed25519_sietch"
vault = "yucca_tf_staging"
provision_profile = "debian-live" # null for Hetzner-installimage clusters
hosts = [
{ name = "laurel", bond_ip = "10.10.10.90", bootstrap = true },
{ name = "lawson", bond_ip = "10.10.10.91" },
{ name = "samara", bond_ip = "10.10.10.92" },
]
}
}
```
On apply, the module:
1. Picks wordlist names for `hosts[].name == null` (stable across applies;
seeded per-cluster; operator-declared names excluded from the pool to
prevent collisions).
2. Renders `inventory.ini` (normal ops), `inventory-destroy.ini` (explicit
destroy flag), `secrets.yml.tpl` (op:// references to
`yucca_tf_staging/<CLUSTER>_CEPH_*_PASSWORD/password`). Optionally renders
`inventory-provision.ini` when `provision_profile != null`.
3. **(Not yet TF-managed)** `onepassword_item` resources for cluster
secrets are dormant — items are created via `op` CLI today and read
by Ansible at play time. See `ansible/ceph/docs/secrets.md` for the
re-enable plan.
## The talos-baremetal module (staging/austin/talos)
Brings up Talos on **bare-metal nodes already running in maintenance mode** at
known addresses — no Ansible, no hypervisors, no VLANs (the earlier VM-oriented
`talos-cluster` module was removed unused). It dials each node's maintenance IP, applies machine
config (which installs to disk + reboots), bootstraps one CP, then emits
kube/talosconfig and gates on cluster health.
Declarative input in `deployment/staging/austin/talos/clusters.auto.tfvars`:
```hcl
clusters = {
yucca-staging = {
talos_version = "1.13.4"
kubernetes_version = "v1.36.1"
install_disk = "/dev/sda" # WIPED — the 240GB DELLBOSS; NVMe left raw
cluster_vip = "10.10.10.15" # L2 VIP, etcd-elected across CPs
gateway = "10.10.10.1"
subnet_cidr = "10.10.10.0/24"
cni = "cilium" # cni:none in Talos + Cilium via Helm
disable_kube_proxy = true # Cilium kube-proxy replacement (KubePrism)
cilium_version = "1.19.5"
hubble = true
bond = { interfaces = ["eno1np0", "eno2np1"], mode = "active-backup" } # flip to 802.3ad after the switch is LACP'd
nodes = [
{ name = "staging-cp1", address = "10.10.10.47" },
{ name = "staging-cp2", address = "10.10.10.242" },
{ name = "staging-cp3", address = "10.10.10.117" },
]
}
}
```
Notes:
- **Static IP = maintenance IP.** Each node's `address` is pinned as the static
IP on `bond0`, so TF stays reachable across the install reboot.
- **bond** comes up `active-backup` (no switch config needed). Migrate to
`802.3ad` later, node-by-node, after converting the switch ports to LACP
port-channels — LACP needs both ends configured at once, so a big-bang flip
drops connectivity until both sides agree.
- **Ingress firewall** (`firewall.tf`): default-deny + per-service allow-lists
scoped to the subnet (+ pod CIDR on kubelet). apid + apiserver also trust the
Tailscale ranges (`trust_tailscale`). ⚠️ The host running `tf apply` must have
a source IP inside an allowed range or apid (50000) is blocked and bootstrap
hangs — add operator/jump subnets to `trusted_cidrs`.
- **CNI is installed in the same apply.** With `cni:none` the nodes are NotReady
until Cilium lands, so the module's health gate runs `skip_kubernetes_checks`;
`helm.tf` installs Cilium, then a second (full) health gate enforces Ready.
- **One cluster per stack.** The helm provider binds to a single cluster
(`one(...)`); add more clusters in their own stack.
Run it (see "Running TF" below — needs 1Password unlocked + an on-LAN apply host):
```bash
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:init
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:plan
TF_STACK_DIR=tf/deployment/staging/austin/talos mise run tf:apply # WIPES /dev/sda, installs Talos
```
## The netbird-env module (NetBird Cloud access control)
Manages one layer's [NetBird](https://netbird.io) Cloud footprint: **groups**,
**access policies**, **device auth (setup) keys**, and routed **networks**. One
NetBird Cloud account (`api.netbird.io`) backs everything — the module namespaces
every object `<name_prefix>_<key>` (all underscores) so all envs/sites coexist.
### Group model
Per env (and per prod site), the baseline groups are:
| group | who | rendered (staging / prod htz-fsn1) |
|---|---|---|
| `ci` | ephemeral CI runners | `yucca-staging-ci` / `yucca-prod-htz-fsn1-ci` |
| `mgmt` | management nodes (configured via Ansible; also the route peers) | `yucca-staging-mgmt` / … |
| `talos` | Talos cluster nodes | `yucca-staging-talos` / … |
| `k8s_operator` | in-cluster kubernetes operator | `yucca-staging-k8s-operator` / … |
Logical keys (the tfvars map keys, e.g. `ci`) stay lowercase; **rendered NetBird
names are UPPER_SNAKE** (uppercased, hyphens → underscores). CI is **per-env**
(`ci`, reaching only that env's groups) — no cross-env CI plane. `k8s` is split
into `talos` (the nodes) and `k8s_operator` (the operator identity) so they can
carry different policies.
### Stacks & layering
| stack | partition / scope | state key |
|---|---|---|
| `deployment/staging/global/netbird` | staging (global region) | `yucca/staging/global/netbird/…` |
| `deployment/prod/global/netbird` | prod, **account-wide** (cross-region) | `yucca/prod/global/netbird/…` |
| `deployment/prod/htz-fsn1/netbird` | prod, **region** htz-fsn1 | `yucca/prod/htz-fsn1/netbird/…` |
Staging is single-layer. **Prod is layered**: a `global` layer (reserved for
account-wide / cross-region groups + policies) above per-region layers. The
global layer is empty today — each region owns its own resource group and the
`yucca → resources` policy is module-generated per layer (see below). Region
groups are region-scoped (`yucca-prod-<region>-<role>`) so a network router's
peers are unambiguously *that region's* mgmt nodes. A region layer can still
consume a global group via a terragrunt `dependency` on `prod/global` → the
module's `external_groups` input, but none do today. The root terragrunt derives `stack` from the full sub-path,
so `prod/htz-fsn1/netbird` gets its own state key. (Now that the fabric stack
lives in its own `prod/htz-fsn1/fabric/` sub-stack, the region root carries no
terragrunt.hcl, so `prod/htz-fsn1/netbird` uses a normal
`find_in_parent_folders` include — the old direct-root-include workaround is
gone.) Rendered NetBird object names and 1P item titles are unchanged by the
rename (`YUCCA_STAGING_*`, `NETBIRD_YUCCA_PROD_HTZ_FSN1_*`): the `env`→`partition`
/ `site`→`region` swap keeps the same string values.
### The `yucca` → yucca-tags access model
`yucca` members reach everything tagged as a **yucca tag** (NetBird object names are
lowercase-kebab — e.g. `yucca-prod-htz-fsn1-mgmt`; the 1Password setup-key item
titles stay UPPER_SNAKE, since CI/ansible/talos read them by `op://` string):
- **`yucca`** — the existing **users** group (people). External (looked up by its
actual name `yucca`); never managed here.
- **yucca tags** — any group flagged **`resource = true`** in a layer's `groups`.
This marks the group **yucca-reachable**; it applies to **peer/node groups**
(so a yucca member can SSH `yucca-prod-htz-fsn1-mgmt`, the mgmt nodes) as well
as routed-subnet tags (`yucca-prod-htz-fsn1-resources`, which the site's
`netbird_network_resource`s are tagged into). Today every group a layer owns is
flagged.
For each layer that owns ≥1 yucca tag, the `netbird-env` module **auto-generates**
a `<prefix>-yucca-to-resources` policy (`bidirectional = false`) whose
destinations are *all* of that layer's flagged groups — so flagging a group grants
`yucca` users access to it (its peers and any tagged resources) with no policy to
edit. The source is `var.yucca_users_group` (default `yucca`, looked up by name;
set null to opt a layer out). `bidirectional = false` means yucca users only
*initiate* — this policy never makes a tagged group a source. The union of these
per-layer policies is the account-wide "yucca reaches every yucca tag we create"
guarantee.
> The former single shared **`yucca_resource`** tag in `prod/global` (one
> account-wide `yucca → yucca_resource` policy, consumed by sites via a terragrunt
> dependency) was **retired** in favour of this per-layer model — no cross-stack
> group reference, and the destination list is derived, not hand-maintained.
Staging additionally grants its `ci` group access to the existing **Liberty
Park** infra groups (where the staging nodes live today) — those are external
groups resolved by name in `staging/global/netbird/main.tf`.
### Declarative input (`netbird.auto.tfvars`)
Groups, setup keys, policies and networks reference groups by **logical key**,
never opaque NetBird IDs:
```hcl
# resource = true ⇒ yucca-reachable ("yucca tag"); here every group is flagged,
# so yucca users reach all of them (SSH the nodes + the routed subnets)
groups = { ci = { resource = true }, mgmt = { resource = true },
talos = { resource = true }, k8s_operator = { resource = true },
resources = { resource = true } }
setup_keys = {
ci = { type = "reusable", ephemeral = true, auto_groups = ["ci"] }
mgmt = { type = "reusable", auto_groups = ["mgmt"] }
talos = { type = "reusable", auto_groups = ["talos"] }
k8s_operator = { type = "reusable", auto_groups = ["k8s_operator"] }
}
policies = {
ci-to-all = { # CI reaches every node group in this env
rules = [{ name = "ci-to-all", protocol = "all"
sources = ["ci"], destinations = ["mgmt", "talos", "k8s_operator"] }]
}
}
```
NetBird is **default-deny** — a peer gets only the access its groups' policies
grant; an empty `policies` map means total isolation. The `yucca →` resource
policy is *not* declared here: the module generates it from every group flagged
`resource = true` (see the access model above).
### Networks (prod htz-fsn1) — CIDRs propagated, not hardcoded
The htz-fsn1 site layer exposes a NetBird **Network** named `htz-fsn1`: the
`mgmt` group are the routing peers, and each routed subnet is a
`netbird_network_resource`. The **CIDRs are derived from the same
`fabric-addressing` module the fabric stack uses** (re-instantiated in the
layer's `addressing.tf` — a pure, stateless module, so no duplication and no
cross-stack coupling). Every resource is tagged into the site's own `resources`
group, so access is the module-generated `yucca-prod-htz-fsn1-yucca-to-resources`
policy. The only per-site input is the site id (the CIDRs flow from it):
```hcl
site_id = 40 # mirrors prod/htz-fsn1; feeds fabric-addressing → the routed CIDRs
# mgmt 10.40.5.0/24 · api 10.40.10.0/24
# cls1_public 10.40.20.0/23 · cls1_private 10.40.22.0/23
```
**Setup-key plaintext → 1Password.** Each setup key's secret `key` is written to
the per-env vault (`yucca_tf_<env>`) as item
`NETBIRD_<UPPERCASED_NAMESPACED_NAME>_SETUP_KEY` (`onepassword_item`, same "TF
mints secrets into 1P" pattern as the JWT keypair). The namespaced title keeps
multiple prod sites writing to the one `yucca_tf_prod` vault from colliding.
**Auth.** Two providers, both fed by `op run --env-file=tf/.env[.prod]`:
- `netbird` — admin PAT from `NB_PAT` (`op://shared_tf/NETBIRD_TF_PAT`, shared
across all envs; `management_url` defaults to NetBird Cloud).
- `onepassword` — `OP_SERVICE_ACCOUNT_TOKEN` (same session), writes the keys.
Run it (pure cloud API — no tailnet, no node contact):
```bash
TF_STACK_DIR=tf/deployment/staging/global/netbird mise run tf:init # then tf:plan / tf:apply
# prod — global layer first, then each region layer (uses the prod env file + SA):
OP_ENV_FILE=tf/.env.prod TF_STACK_DIR=tf/deployment/prod/global/netbird mise run tf:apply
OP_ENV_FILE=tf/.env.prod TF_STACK_DIR=tf/deployment/prod/htz-fsn1/netbird mise run tf:apply
```
CI (`.github/workflows/infra.yml`) applies `staging/global/netbird` in the staging
matrix, and the prod layers (`prod/global` then `prod/htz-fsn1/netbird`) as gated
`prod-infra` jobs on the prod 1P SA / `tf/.env.prod`. Prod CI needs the
`OP_TF_YUCCA_PROD_ENV[_WRITE]` repo secrets + a `prod-infra` Environment — see
the workflow header.
### CI connects over NetBird
CI reaches the staging `10.10.10.0/24` nodes over the NetBird overlay (this
replaced the Tailscale subnet-router path). The `.github/actions/netbird-connect`
composite action installs the client and runs `netbird up` with the **`ci` setup
key** read from 1P (`op://yucca_tf_staging/NETBIRD_YUCCA_STAGING_CI_SETUP_KEY`);
the runner joins as a `ci` peer and the existing staging route advertises the LAN.
The apply job applies `staging/global/netbird` **first** (minting that key) before
connecting, so a fresh bootstrap is self-contained. The prod **fabric** workflow
(`fabric.yml`) still uses Tailscale — `10.40.5.0/24` isn't on NetBird yet.
## Where secrets actually live
- **`yucca_tf_dev`** (team-shared): live values consumed by Ansible at
play time. Password items per cluster (ops, dashboard, grafana, S3
svc-user access + secret), SSH Key items per cluster (ansible-iac
keypairs), and DR-capture items per cluster (RGW TLS cert + key,
client.admin keyring — populated by `mise run capture`).
- **`yucca_tf_dev_manual`** (team-shared): placeholders for
human-fillable secrets (API tokens, OAuth client secrets). Not yet used
by ceph-cluster.
Service accounts themselves are in `yucca_tf_dev` as two items:
| SA | Purpose | Consumed by |
|---|---|---|
| `yucca_futo_1pass_superuser_service_account` | Read + write all `yucca_tf_*` vaults | TF (via `tf/.env`) |
| `yucca_futo_1pass_service_account` | Read-only on `yucca_tf` and `yucca_tf_dev` | Ansible runtime / CI |
Both are shared with other Futo consumers (o11y, base Yucca infra).
Rotation affects all of them — see `ansible/ceph/docs/runbooks/rotate-sa-token.md`
for the coordination procedure.
## Adding a new cluster
1. Add an entry to `clusters.auto.tfvars`.
2. Create the 1P items in `yucca_tf_dev`:
- Password items: `<CLUSTER>_CEPH_{OPS,DASHBOARD,GRAFANA}_PASSWORD`, plus
`<CLUSTER>_CEPH_S3_SVC_YUCCA_RESTIC_{ACCESS,SECRET}_KEY`. Use
`op item create --generate-password` for each.
- SSH Key item: `<CLUSTER>_CEPH_ANSIBLE_IAC_SSH_KEY` via
`op item create --category "SSH Key" --ssh-generate-key=ed25519`.
3. `mise run tf:apply` — renders inventory + secrets template.
4. Create per-node `host_vars/*.yml` files in the new inventory dir (hardware
topology; not TF-rendered yet).
5. On operator workstation: `scripts/install-ssh-keys.sh <cluster>` to pull
the private key from 1P.
6. After first successful deploy: `mise run capture` to snapshot the RGW
TLS material + admin keyring to 1P for DR.
7. See [`ansible/ceph/docs/adding-a-cluster.md`](../ansible/ceph/docs/adding-a-cluster.md)
for the full walk-through.
## Related
- TF-first + op inject model: `ansible/ceph/docs/secrets.md`
- Immich devtools (upstream pattern): <https://github.com/immich-app/devtools/tree/main/tf>