# 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///`. 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///region.hcl` + stacks under it. Add a stack: a new sibling dir under a region (`/monitoring/` …). NetBird Cloud access control lives in `staging/global/netbird/`, and for prod is layered: `prod/global/` (account-wide) above per-region `prod//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 = "-"`. ``` 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///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 `. ### 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:////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>