feat(netbird): wire this sweetie up (#209)

* feat(netbird): wire this sweetie up

* remove dev

* fix

* more features
This commit is contained in:
Antoine Lecompte
2026-06-26 18:03:02 +00:00
committed by GitHub
parent fc59e9b074
commit 031974a32b
29 changed files with 1372 additions and 33 deletions
@@ -0,0 +1,63 @@
name: Connect to NetBird
description: >-
Install the NetBird client and join the NetBird Cloud overlay using a setup key
read from 1Password. Requires the 1Password CLI on PATH and
OP_SERVICE_ACCOUNT_TOKEN in the environment (the infra jobs provide both). The
setup key must already exist in 1Password — it is minted by the matching
netbird stack (deployment/<env>/netbird or prod/<site>/netbird), so apply that
stack before this action runs on a fresh bootstrap.
inputs:
setup-key-ref:
description: 1Password op:// reference to the NetBird setup key (the plaintext key).
required: true
management-url:
description: NetBird management URL.
required: false
default: https://api.netbird.io
hostname:
description: Optional peer hostname to register the runner as.
required: false
default: ""
runs:
using: composite
steps:
- name: Install NetBird client
shell: bash
run: |
set -euo pipefail
curl -fsSL https://pkgs.netbird.io/install.sh | sh
netbird version
- name: Connect to NetBird
shell: bash
env:
OP_SETUP_KEY_REF: ${{ inputs.setup-key-ref }}
NB_MANAGEMENT_URL: ${{ inputs.management-url }}
NB_HOSTNAME: ${{ inputs.hostname }}
run: |
set -euo pipefail
key="$(op read "$OP_SETUP_KEY_REF")"
echo "::add-mask::$key"
args=(--setup-key "$key" --management-url "$NB_MANAGEMENT_URL")
if [ -n "$NB_HOSTNAME" ]; then
args+=(--hostname "$NB_HOSTNAME")
fi
sudo netbird up "${args[@]}"
- name: Wait for NetBird connection
shell: bash
run: |
set -euo pipefail
for _ in $(seq 1 30); do
if sudo netbird status 2>/dev/null | grep -qE "Management:[[:space:]]+Connected"; then
sudo netbird status --detail || true
echo "NetBird connected."
exit 0
fi
sleep 2
done
echo "NetBird did not reach the Connected state in time." >&2
sudo netbird status --detail || true
exit 1
+127 -30
View File
@@ -5,6 +5,10 @@ name: Infra (Terraform)
# - tf/deployment/staging/ceph (ceph cluster 1P password items; no node contact)
# - tf/deployment/staging/talos (Talos config, Cilium, Flux bootstrap, secrets)
# - tf/deployment/staging/dns (Cloudflare records for the ingress hosts)
# - tf/deployment/staging/netbird (NetBird Cloud groups/policies/setup keys)
# - tf/deployment/prod/global + prod/htz-fsn1/netbird (prod NetBird, layered:
# a global layer + per-site layers — runs as its own gated jobs at the bottom
# of this file, on the prod 1P SA / tf/.env.prod)
# - ansible/ceph (deploy pipeline) (cephadm convergence: OSDs, RGW realm +
# S3/metrics-worker users, monitoring, tuning, hardening) — the TF stacks
# only MINT the RGW keys into 1P; this step is what actually creates the
@@ -13,16 +17,17 @@ name: Infra (Terraform)
# Plan runs on PRs touching tf/** or ansible/ceph/**; apply runs on merge to main
# behind the `staging-infra` Environment gate (required reviewers). Both the Talos
# stack and the Ansible deploy talk to the nodes on the 10.10.10.0/24 management
# VLAN, which GitHub-hosted runners can't reach — so the runner joins the tailnet
# (the cluster firewall trusts the Tailscale CIDRs) and accepts the subnet route
# advertising that VLAN.
# VLAN, which GitHub-hosted runners can't reach — so the runner joins the NetBird
# overlay as a `ci` peer (via the netbird-connect action + the minted CI setup
# key) and the existing staging route advertises that VLAN. (The prod fabric
# workflow still uses Tailscale; 10.40.5.0/24 isn't on NetBird yet.)
#
# Prerequisites (provisioned out-of-band):
# - Repo secrets: OP_TF_YUCCA_STAGING_ENV (the staging-scoped 1P service-account
# token — resolves tf/.env; dev/prod use OP_TF_YUCCA_DEV_ENV / OP_TF_YUCCA_PROD_ENV),
# TS_OAUTH_CLIENT_ID + TS_OAUTH_SECRET (Tailscale OAuth client, tagged tag:project-yucca).
# - A Tailscale subnet router advertising 10.10.10.0/24, and tag:project-yucca approved for
# those routes.
# token — resolves tf/.env; dev/prod use OP_TF_YUCCA_DEV_ENV / OP_TF_YUCCA_PROD_ENV).
# - The staging NetBird stack applied at least once, so the CI setup key item
# (op://yucca_tf_staging/NETBIRD_YUCCA_STAGING_CI_SETUP_KEY) exists, plus a
# NetBird route advertising 10.10.10.0/24 that the `ci` group may reach.
# - GitHub Environment `staging-infra` with required reviewers (the apply gate).
on:
@@ -49,13 +54,13 @@ env:
jobs:
plan:
name: Plan ${{ matrix.stack }}
# Skip on fork PRs (no access to secrets / tailnet).
# Skip on fork PRs (no access to secrets / the NetBird overlay).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
stack: [talos, dns, ceph]
stack: [talos, dns, ceph, netbird]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -68,17 +73,18 @@ jobs:
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
# The Talos stack refreshes state against the nodes; the DNS and ceph
# stacks are pure Cloudflare/1Password API and need no tailnet.
- name: Connect to Tailscale
# The Talos stack refreshes state against the nodes; the DNS, ceph and
# netbird stacks are pure Cloudflare/1Password/NetBird-API and need no
# overlay. CI reaches the 10.10.10.0/24 nodes over NetBird: the runner joins
# as a `ci` peer via the minted setup key, and the existing staging route
# advertises the LAN. The key must already exist in 1P (minted by a prior
# netbird apply) — true for every PR after the first bootstrap apply.
- name: Connect to NetBird
if: matrix.stack == 'talos'
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
uses: ./.github/actions/netbird-connect
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:project-yucca
# NB: the action already passes `--accept-routes` to `tailscale up`;
# passing it again here errors with "flag provided multiple times".
setup-key-ref: op://yucca_tf_staging/NETBIRD_YUCCA_STAGING_CI_SETUP_KEY/password
hostname: gha-staging-plan-${{ github.run_id }}
- name: Terragrunt plan
run: >-
@@ -109,16 +115,25 @@ jobs:
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
- name: Connect to Tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:project-yucca
# NB: the action already passes `--accept-routes` to `tailscale up`;
# passing it again here errors with "flag provided multiple times".
# NetBird stack FIRST — pure api.netbird.io (no overlay needed), and it
# mints the CI setup key into 1P that the connect step below reads. On a
# fresh bootstrap this is what makes the key exist before anything joins.
- name: Apply staging/netbird
run: >-
tf/op-run.sh terragrunt
--working-dir tf/deployment/staging/netbird
--non-interactive apply -auto-approve
# Ceph 1P password items first (no node contact), then the Talos cluster
# Join the NetBird overlay as a `ci` peer so the node-touching stacks and
# the Ansible deploy below reach 10.10.10.0/24 (the staging route advertises
# the LAN). Replaces the old Tailscale subnet-router path.
- name: Connect to NetBird
uses: ./.github/actions/netbird-connect
with:
setup-key-ref: op://yucca_tf_staging/NETBIRD_YUCCA_STAGING_CI_SETUP_KEY/password
hostname: gha-staging-apply-${{ github.run_id }}
# Ceph 1P password items (no node contact), then the Talos cluster
# (provisions secrets/CNI/Flux), then DNS.
- name: Apply staging/ceph
run: >-
@@ -142,8 +157,8 @@ jobs:
# The TF stacks above only minted the RGW keys into 1P + the cluster
# Secret; this is what creates the matching RGW users on the bare-metal
# cluster. Runs after the TF apply so the inventory (rendered from the
# ceph stack's `render` output) and the keys exist. Reuses the tailnet +
# 1Password session already established in this job.
# ceph stack's `render` output) and the keys exist. Reuses the NetBird
# overlay + 1Password session already established in this job.
- name: Render Ansible inventory from the ceph TF state
run: ansible/ceph/scripts/render-inventories.sh staging
@@ -165,8 +180,90 @@ jobs:
- name: Deploy Ceph (full pipeline — baseline → tune → deploy → harden)
working-directory: ansible/ceph
# CI runner has no known_hosts for the bare-metal nodes; first contact is
# over the tailnet, so disable strict host-key checking for this run.
# over the NetBird overlay, so disable strict host-key checking for this run.
env:
ANSIBLE_HOST_KEY_CHECKING: "false"
CEPH_ENV: inventories/sietch-ceph.staging.austin.int/inventory.ini
run: mise run deploy
# ── prod NetBird (global + site layers) ──────────────────────────────────────
# Prod is its own env (separate 1P SA + env file), so it can't ride the
# staging matrix above (that job's OP_SERVICE_ACCOUNT_TOKEN is the staging SA).
# NetBird is pure api.netbird.io — no nodes, no tailnet — so these are the only
# prod TF stacks CI touches today. Layered: prod/global (account-wide groups +
# operator setup keys) then prod/<site>/netbird (site groups/keys/policies that
# reference the global groups). Both select tf/.env.prod via OP_ENV_FILE.
#
# Additional prerequisites (out-of-band) beyond the staging ones above:
# - Repo secrets OP_TF_YUCCA_PROD_ENV (read) / OP_TF_YUCCA_PROD_ENV_WRITE
# (apply) — a prod-scoped 1P service account granted shared_tf (read, for
# NETBIRD_TF_PAT) + yucca_tf_prod (read/write, for the minted setup keys).
# - GitHub Environment `prod-infra` with required reviewers (the apply gate).
netbird-prod-plan:
name: Plan prod/netbird
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
OP_ENV_FILE: tf/.env.prod
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_YUCCA_PROD_ENV }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up mise (opentofu + terragrunt)
uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
# Global layer first, then the site layer (which reads the global layer's
# group_ids via a terragrunt dependency — mock_outputs cover the PR plan
# before global is ever applied).
- name: Terragrunt plan prod/global
run: >-
tf/op-run.sh terragrunt
--working-dir tf/deployment/prod/global
--non-interactive plan
- name: Terragrunt plan prod/htz-fsn1/netbird
run: >-
tf/op-run.sh terragrunt
--working-dir tf/deployment/prod/htz-fsn1/netbird
--non-interactive plan
netbird-prod-apply:
name: Apply prod/netbird (gated)
needs: netbird-prod-plan
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment: prod-infra
env:
OP_ENV_FILE: tf/.env.prod
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_YUCCA_PROD_ENV_WRITE }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up mise (opentofu + terragrunt)
uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
# Global layer must apply before the site layer (the site reads global's
# group_ids output via its terragrunt dependency).
- name: Apply prod/global
run: >-
tf/op-run.sh terragrunt
--working-dir tf/deployment/prod/global
--non-interactive apply -auto-approve
- name: Apply prod/htz-fsn1/netbird
run: >-
tf/op-run.sh terragrunt
--working-dir tf/deployment/prod/htz-fsn1/netbird
--non-interactive apply -auto-approve
+8
View File
@@ -23,6 +23,14 @@ export TF_VAR_flux_github_app_id="op://shared_tf/GITHUB_APP_IMMICH_PUSH_O_MATIC/
export TF_VAR_flux_github_app_installation_id="op://shared_tf/GITHUB_APP_IMMICH_PUSH_O_MATIC/installation_id"
export TF_VAR_flux_github_app_private_key="op://shared_tf/GITHUB_APP_IMMICH_PUSH_O_MATIC/pkcs1"
# ─── NetBird admin PAT (deployment/<env>/netbird) — SHARED, in shared_tf ─────
# The netbird provider reads NB_PAT directly. One PAT (one NetBird Cloud account)
# backs every env/site; the netbird-env module namespaces objects per layer
# ("yucca_<env>_…" / "yucca_prod_<site>_…"). shared_tf is readable by every env
# SA, so this line serves the staging stack (prod uses tf/.env.prod).
# management_url defaults to https://api.netbird.io.
export NB_PAT="op://shared_tf/NETBIRD_TF_PAT/password"
# ─── staging/talos secrets (secrets.tf) — env-specific, in yucca_tf_staging ──
export TF_VAR_yucca_oidc_client_id="op://yucca_tf_staging/CUSTOMER_ZITADEL_OAUTH_CLIENT_ID/password"
export TF_VAR_yucca_oidc_client_secret="op://yucca_tf_staging/CUSTOMER_ZITADEL_OAUTH_CLIENT_SECRET/password"
+5
View File
@@ -7,6 +7,11 @@
export AWS_ACCESS_KEY_ID=op://yucca_tf/TF_STATE_S3_ACCESS_KEY/password
export AWS_SECRET_ACCESS_KEY=op://yucca_tf/TF_STATE_S3_SECRET_KEY/password
# ── NetBird admin PAT (deployment/prod/netbird) ──────────────────────────────
# Same shared PAT as tf/.env (one NetBird Cloud account; objects namespaced
# "yucca-prod-…"). The netbird provider reads NB_PAT directly.
export NB_PAT=op://shared_tf/NETBIRD_TF_PAT/password
# ── NetBox API token ────────────────────────────────────────────────────────
# TODO: create this item in yucca_tf_prod (PASSWORD category) and confirm the path.
export TF_VAR_netbox_token=op://yucca_tf/NETBOX_API_TOKEN/password
+145 -1
View File
@@ -51,7 +51,10 @@ tf/
Future envs land as siblings: `deployment/staging/ceph/`, `deployment/prod/ceph/`.
Additional stacks land as siblings within an env — `dev/talos/` and
`dev/dns/` are two; `dev/monitoring/` could be next.
`dev/dns/` are two; `dev/monitoring/` could be next. NetBird Cloud access
control lives in `staging/netbird/` (flat), and for prod is layered:
`prod/global/` (account-wide) above per-site `prod/<site>/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).
@@ -270,6 +273,147 @@ TF_STACK_DIR=tf/deployment/staging/talos mise run tf:plan
TF_STACK_DIR=tf/deployment/staging/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` / … |
CI is **per-env** (`yucca_<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 | env / scope | state key |
|---|---|---|
| `deployment/staging/netbird` | staging (flat) | `ceph/staging/netbird/…` |
| `deployment/prod/global` | prod, **account-wide** (cross-site) | `ceph/prod/global/…` |
| `deployment/prod/htz-fsn1/netbird` | prod, **site** htz-fsn1 | `ceph/prod/htz-fsn1/netbird/…` |
Staging is single-layer. **Prod is layered**: a `global` layer (account-wide
groups + policies) above per-site layers. The global layer owns the
**`yucca_resource`** tag group and the account-wide **`yucca → yucca_resource`**
policy (see below). Site groups are site-scoped (`yucca_prod_<site>_<role>`) so a
network router's peers are unambiguously *that site's* mgmt nodes. A site layer
consumes a global group via a terragrunt `dependency` on `prod/global` → the
module's `external_groups` input (htz-fsn1 does this for `yucca_resource`). The
root terragrunt derives `stack` from the full sub-path, so `prod/htz-fsn1/netbird`
gets its own state key without colliding with the `prod/htz-fsn1` fabric stack.
### The `yucca` / `yucca_resource` access model
Two pre-existing-or-managed groups drive account-wide access to routed subnets:
- **`yucca`** — the existing **users** group (people). External (looked up by
name); never managed here.
- **`yucca_resource`** — the shared **resource tag**, managed in `prod/global`.
Every routed `netbird_network_resource` (across sites) is tagged into it.
One global policy in `prod/global` — `yucca → yucca_resource`, `bidirectional =
false` — lets users reach every tagged resource. Because `yucca_resource` is only
ever a policy *destination*, the tagged resources can't reach each other (or call
back to users). Site layers don't reference `yucca` at all; they just tag their
resources into `yucca_resource` (pulled from `prod/global` via the dependency),
so the link is the shared tag, not a cross-stack group reference.
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/netbird/main.tf`.
### Declarative input (`netbird.auto.tfvars`)
Groups, setup keys, policies and networks reference groups by **logical key**,
never opaque NetBird IDs:
```hcl
groups = { ci = {}, mgmt = {}, talos = {}, k8s_operator = {} }
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.
### 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 `yucca_resource`, so access
is the one global `yucca → yucca_resource` 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/netbird mise run tf:init # then tf:plan / tf:apply
# prod — global layer first, then each site layer (uses the prod env file + SA):
OP_ENV_FILE=tf/.env.prod TF_STACK_DIR=tf/deployment/prod/global 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/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/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
+48
View File
@@ -0,0 +1,48 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/1password/onepassword" {
version = "2.2.1"
constraints = "~> 2.1"
hashes = [
"h1:aH5pZUimlQdiGnDLHRC49W4xnkx52wfd8XRLt69+764=",
"zh:025709a6b5f1b3685d277f2c48f7cb8b53d14b3699c1123d7e9a2135c099c533",
"zh:037fc89d150063a8aacdcab08ba26038b489fe2468d509b842d298ea59096ca6",
"zh:233777182b25faf1658e8ce171b684460983bb41cff79fb243662f3f9dc5ca6c",
"zh:2fb5ca2fc8c37b1d1c54da646ed13bf40897941fe92eece784fba496f677b533",
"zh:4b25b5ce1f694ec265e65234fc85d6bdf3810297ffeaced54ad46a1ba28142de",
"zh:5509d1e4fb7b45c63124ec66fd1f9d6757daa8bf1f7bdd724d5adb2965b61436",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:a23ba946c629ec912b2fcbf606a2eb8853626ec0e0bee749f2d39146a872c082",
"zh:a3d3024485426237d7b4a4350b12dda4d29d88f3942246a9370be35ec2a51e9e",
"zh:a6ef65544ab8fc26d468b38636407a3d2d902e35c51b648729bf97c31d1937f9",
"zh:afbe9480a0da0ad8dc514b277f1e4be36b8931f045021d05c21665ef1ac0b7c8",
"zh:b2e96e69fa9ff7e179dccdef5b785cd020eb46bb2b3d1d507d009d71be6b0c26",
"zh:ceefaede9e8a3104463523ba267e3e985b27a706f7628a9ddd37330c2ca59d4d",
"zh:ea77786bd6809ff4f8043b84a0212fec4de18b7d51bc420417ba10999ca99887",
"zh:f7d8160c3669c8ab76a2da14ea740d91a08ca23d1fb657669e52a840b2b113d9",
]
}
provider "registry.opentofu.org/netbirdio/netbird" {
version = "0.0.9"
constraints = "~> 0.0.9"
hashes = [
"h1:H8GG0MZqAAXqnrMCw0Q3vcE3gGFEqYmDF9YY2RnD18Y=",
"zh:1f34fba3ecfe0efa36d4b4bddf5714c69124e705d1e371e65abd291887d43385",
"zh:203f671af4d1f5376f4e20fb8d82cc8dc6c4fd136d9abffe8eb7b7f34e27197b",
"zh:21cb344302bdbadbc2779768116c7351d915bb7678a4847e9e2c8623d0032c48",
"zh:2368868e0f86b458f83b6598317ff5fde0d4079500d4567e99f46bf80c63f07a",
"zh:3237a426818eb3906153b1cf7fb6dfe9d52128972e7cab17c324d88788f86863",
"zh:427f8e8ba190d69cfd493b1598b4bf7940fe9a521a333df3540d2e0ea07543cf",
"zh:689cb219f3936500f5a3147299961f8740e505612b5182d93840cb8152d89b4f",
"zh:6e594e69d9e107c45ed4677a9bff68de2dd4232900b636327b24c819bee72c9e",
"zh:715634d6d0b052ac3f34aeb4c2df42960cdf44dd5c45d060867db78716aebb48",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8cbbdde5a0b59f0bb427b403da8993b215d1a6231e0d6b9b4faa89db76b10705",
"zh:af92281c3e14c6af53fb93cee584c5118c5c7bee0acb938b5a367a219d9bc2e2",
"zh:bcdeef5fadf092e33228f28f013af7d8d9553b9d2fb2cecd1894351d5ad37a7a",
"zh:d6c31cb12f18b25c9663c14e737554e06144d4e270e607337bac7963ca3b9542",
"zh:e5873e8e0b29c8cbfd98f04759579da3c0529c8b38608c0f1ea92eb566c8ddc1",
]
}
@@ -0,0 +1,38 @@
# Global prod NetBird layer (objects auto-prefixed "yucca_prod_"). Reserved for
# ACCOUNT-WIDE / cross-site prod resources — groups or policies that span every
# prod site.
#
# Empty today: with per-env CI and site-scoped mgmt/talos/k8s_operator groups,
# all current prod objects live in the site layers (prod/<site>/netbird). This
# stack exists as the layer those site layers build on (a cross-site policy, or a
# shared group consumed via the site layer's `external_groups`, would land here).
# The shared resource tag. Site layers tag every routed network resource into
# this group (via a terragrunt dependency on this stack), so one account-wide
# policy governs access to all of them. Explicit name → not prefixed "yucca_prod_".
groups = {
yucca_resource = { name = "yucca_resource" }
}
setup_keys = {}
policies = {
# Account-wide: members of the existing "yucca" users group reach every NetBird
# resource tagged into "yucca_resource". One global policy covers all such
# resources across every env/site. `yucca` is an external group resolved by name
# in netbird.tf; `yucca_resource` is the group created above.
#
# bidirectional = false → only yucca users INITIATE to resources. yucca_resource
# is never a source, so the tagged resources can't reach each other (or back to
# users) — just be reached.
yucca-to-resources = {
description = "yucca users → all yucca_resource-tagged resources (account-wide)."
rules = [{
name = "yucca-to-resources"
protocol = "all"
bidirectional = false
sources = ["yucca"]
destinations = ["yucca_resource"]
}]
}
}
+46
View File
@@ -0,0 +1,46 @@
# ─── Global prod NetBird layer ───────────────────────────────────────────────
# Account-wide groups, cross-site policies, and operator setup keys shared by
# every prod site. Site layers (prod/<site>/netbird) build on this — they pull
# this stack's `group_ids` output via a terragrunt dependency and grant the
# global `admins` group access to their site-local servers.
#
# One NetBird Cloud account backs all envs; objects here are namespaced
# "yucca-prod-*". Auth (both injected by `op run --env-file=tf/.env.prod`):
# • netbird — admin PAT from NB_PAT (op://shared_tf/NETBIRD_TF_PAT).
# • onepassword — OP_SERVICE_ACCOUNT_TOKEN; writes setup keys to yucca_tf_prod.
provider "netbird" {}
provider "onepassword" {}
# The existing account-wide "yucca" users group. Account-global access policies
# reference it by the logical key `yucca` (handed to the module as an external
# group). Any NetBird resource tagged into this group (see the site layers'
# network resources) is then reachable by yucca users via the yucca→yucca policy.
data "netbird_group" "yucca" {
name = "yucca"
}
module "netbird" {
source = "../../../shared/modules/netbird-env"
env = var.env
name_prefix = "yucca_${var.env}" # yucca_prod
vault = "yucca_tf_${var.env}" # yucca_tf_prod
groups = var.groups
setup_keys = var.setup_keys
policies = var.policies
external_groups = {
yucca = data.netbird_group.yucca.id
}
}
output "group_ids" {
description = "Logical group key → NetBird group ID. Consumed by the prod site layers via terragrunt dependency."
value = module.netbird.group_ids
}
output "setup_key_items" {
description = "Setup-key plaintext lives in these 1Password items (yucca_tf_prod)."
value = module.netbird.setup_key_items
}
+7
View File
@@ -0,0 +1,7 @@
include "root" {
path = find_in_parent_folders("terragrunt.hcl")
}
# Global prod layer — account-wide resources shared across every prod site.
# Today: NetBird (netbird.tf). State key: ceph/prod/global/terraform.tfstate.
# netbird.auto.tfvars is loaded automatically; `env` is injected by the root.
+43
View File
@@ -0,0 +1,43 @@
variable "env" {
description = "Environment slug, injected by terragrunt from the path (prod)."
type = string
}
variable "groups" {
type = map(object({
name = optional(string)
}))
default = {}
}
variable "setup_keys" {
type = map(object({
type = optional(string, "reusable")
expiry_seconds = optional(number, 0)
usage_limit = optional(number, 0)
ephemeral = optional(bool, false)
revoked = optional(bool, false)
allow_extra_dns_labels = optional(bool, false)
auto_groups = optional(list(string), [])
}))
default = {}
}
variable "policies" {
type = map(object({
description = optional(string)
enabled = optional(bool, true)
rules = list(object({
name = string
action = optional(string, "accept")
protocol = optional(string, "all")
bidirectional = optional(bool, true)
enabled = optional(bool, true)
description = optional(string)
sources = list(string)
destinations = list(string)
ports = optional(list(string))
}))
}))
default = {}
}
+13
View File
@@ -0,0 +1,13 @@
terraform {
required_version = "~> 1.11"
required_providers {
netbird = {
source = "netbirdio/netbird"
version = "~> 0.0.9"
}
onepassword = {
source = "1Password/onepassword"
version = "~> 2.1"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/1password/onepassword" {
version = "2.2.1"
constraints = "~> 2.1"
hashes = [
"h1:aH5pZUimlQdiGnDLHRC49W4xnkx52wfd8XRLt69+764=",
"zh:025709a6b5f1b3685d277f2c48f7cb8b53d14b3699c1123d7e9a2135c099c533",
"zh:037fc89d150063a8aacdcab08ba26038b489fe2468d509b842d298ea59096ca6",
"zh:233777182b25faf1658e8ce171b684460983bb41cff79fb243662f3f9dc5ca6c",
"zh:2fb5ca2fc8c37b1d1c54da646ed13bf40897941fe92eece784fba496f677b533",
"zh:4b25b5ce1f694ec265e65234fc85d6bdf3810297ffeaced54ad46a1ba28142de",
"zh:5509d1e4fb7b45c63124ec66fd1f9d6757daa8bf1f7bdd724d5adb2965b61436",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:a23ba946c629ec912b2fcbf606a2eb8853626ec0e0bee749f2d39146a872c082",
"zh:a3d3024485426237d7b4a4350b12dda4d29d88f3942246a9370be35ec2a51e9e",
"zh:a6ef65544ab8fc26d468b38636407a3d2d902e35c51b648729bf97c31d1937f9",
"zh:afbe9480a0da0ad8dc514b277f1e4be36b8931f045021d05c21665ef1ac0b7c8",
"zh:b2e96e69fa9ff7e179dccdef5b785cd020eb46bb2b3d1d507d009d71be6b0c26",
"zh:ceefaede9e8a3104463523ba267e3e985b27a706f7628a9ddd37330c2ca59d4d",
"zh:ea77786bd6809ff4f8043b84a0212fec4de18b7d51bc420417ba10999ca99887",
"zh:f7d8160c3669c8ab76a2da14ea740d91a08ca23d1fb657669e52a840b2b113d9",
]
}
provider "registry.opentofu.org/netbirdio/netbird" {
version = "0.0.9"
constraints = "~> 0.0.9"
hashes = [
"h1:H8GG0MZqAAXqnrMCw0Q3vcE3gGFEqYmDF9YY2RnD18Y=",
"zh:1f34fba3ecfe0efa36d4b4bddf5714c69124e705d1e371e65abd291887d43385",
"zh:203f671af4d1f5376f4e20fb8d82cc8dc6c4fd136d9abffe8eb7b7f34e27197b",
"zh:21cb344302bdbadbc2779768116c7351d915bb7678a4847e9e2c8623d0032c48",
"zh:2368868e0f86b458f83b6598317ff5fde0d4079500d4567e99f46bf80c63f07a",
"zh:3237a426818eb3906153b1cf7fb6dfe9d52128972e7cab17c324d88788f86863",
"zh:427f8e8ba190d69cfd493b1598b4bf7940fe9a521a333df3540d2e0ea07543cf",
"zh:689cb219f3936500f5a3147299961f8740e505612b5182d93840cb8152d89b4f",
"zh:6e594e69d9e107c45ed4677a9bff68de2dd4232900b636327b24c819bee72c9e",
"zh:715634d6d0b052ac3f34aeb4c2df42960cdf44dd5c45d060867db78716aebb48",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8cbbdde5a0b59f0bb427b403da8993b215d1a6231e0d6b9b4faa89db76b10705",
"zh:af92281c3e14c6af53fb93cee584c5118c5c7bee0acb938b5a367a219d9bc2e2",
"zh:bcdeef5fadf092e33228f28f013af7d8d9553b9d2fb2cecd1894351d5ad37a7a",
"zh:d6c31cb12f18b25c9663c14e737554e06144d4e270e607337bac7963ca3b9542",
"zh:e5873e8e0b29c8cbfd98f04759579da3c0529c8b38608c0f1ea92eb566c8ddc1",
]
}
@@ -0,0 +1,16 @@
# Site/cluster IP plan — derived from the SAME fabric-addressing module the
# htz-fsn1 fabric stack uses (tf/deployment/prod/htz-fsn1/addressing.tf), the
# single source of truth for the site's CIDRs. Nothing is hardcoded here: the
# HTZ-FSN1 routed network's resource addresses come from these outputs. site_id
# and the cluster ordinals mirror the fabric stack (a pure, stateless module, so
# re-instantiating it is free and deterministic — no cross-stack state coupling).
module "addr_site" {
source = "../../../../shared/modules/fabric-addressing"
site_id = var.site_id
}
module "addr_cls1" {
source = "../../../../shared/modules/fabric-addressing"
site_id = var.site_id
cluster_id = 1
}
@@ -0,0 +1,39 @@
# htz-fsn1 site NetBird objects (auto-prefixed "yucca_prod_htz_fsn1_").
# Setup-key plaintext → the yucca_tf_prod vault. NetBird is default-deny: a peer
# gets only the access its groups' policies grant.
groups = {
ci = {} # ephemeral CI runners → yucca_prod_htz_fsn1_ci
mgmt = {} # management nodes (configured via ansible); also the route peers
talos = {} # Talos cluster nodes → yucca_prod_htz_fsn1_talos
k8s_operator = {} # in-cluster kubernetes operator → yucca_prod_htz_fsn1_k8s_operator
}
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 reaches everything at this site (deploy/manage access to every node group).
ci-to-all = {
description = "CI → all htz-fsn1 node groups."
rules = [{
name = "ci-to-all"
protocol = "all"
sources = ["ci"]
destinations = ["mgmt", "talos", "k8s_operator"]
}]
}
}
# Site identifier (mirrors prod/htz-fsn1's site_id). Feeds the fabric-addressing
# plan in addressing.tf; the routed-network CIDRs derive from it.
#
# The "HTZ-FSN1" Network (built in netbird.tf) routes the site subnets — CIDRs
# propagated from the fabric-addressing plan — and tags every resource into the
# shared "yucca_resource" group, so access is governed by the account-wide
# yucca→yucca_resource policy (prod/global). Nothing to declare here per subnet.
site_id = 40
@@ -0,0 +1,71 @@
# ─── htz-fsn1 site NetBird layer ─────────────────────────────────────────────
# Site-local groups, setup keys, policies, and the routed "HTZ-FSN1" network for
# the FSN1 site. Objects are namespaced "yucca_prod_htz_fsn1_*".
#
# Auth (both injected by `op run --env-file=tf/.env.prod`):
# • netbird — admin PAT from NB_PAT (op://shared_tf/NETBIRD_TF_PAT).
# • onepassword — OP_SERVICE_ACCOUNT_TOKEN; writes setup keys to yucca_tf_prod.
provider "netbird" {}
provider "onepassword" {}
locals {
# Routed subnets for the HTZ-FSN1 Network. The mgmt nodes (router peers) expose
# these to the overlay. ADDRESSES ARE PROPAGATED from the fabric-addressing plan
# (addressing.tf) — not hardcoded — so the cluster definition stays the single
# source of truth. Every resource is tagged into the shared "yucca_resource"
# group (from the global layer, via var.external_groups), so the account-wide
# yucca→yucca_resource policy (prod/global) governs access — and resources
# never appear as a policy source, so they can't reach each other.
routed = {
mgmt = { address = module.addr_site.mgmt_cidr, description = "OOB / vme management network" }
api = { address = module.addr_site.api_cidr, description = "Site-global API network" }
cls1_public = { address = module.addr_cls1.public_cidr, description = "cls1 public cluster network" }
cls1_private = { address = module.addr_cls1.private_cidr, description = "cls1 private cluster network" }
}
netbird_networks = {
"HTZ-FSN1" = {
description = "htz-fsn1 site networks, routed via the mgmt nodes."
router = { peer_groups = ["mgmt"], masquerade = true }
resources = {
for name, r in local.routed : name => {
address = r.address
description = r.description
groups = ["yucca_resource"]
}
}
}
}
}
module "netbird" {
source = "../../../../shared/modules/netbird-env"
env = var.env
name_prefix = "yucca_${var.env}_${var.site}" # yucca_prod_htz_fsn1 (slug normalized in the module)
vault = "yucca_tf_${var.env}" # yucca_tf_prod
groups = var.groups
setup_keys = var.setup_keys
policies = var.policies
networks = local.netbird_networks
# yucca_resource comes from the global layer via the terragrunt dependency
# (see terragrunt.hcl → external_groups input).
external_groups = var.external_groups
}
output "group_ids" {
description = "Logical group key → NetBird group ID (site-local groups)."
value = module.netbird.group_ids
}
output "setup_key_items" {
description = "Setup-key plaintext lives in these 1Password items (yucca_tf_prod)."
value = module.netbird.setup_key_items
}
output "network_ids" {
description = "Logical network key → NetBird network ID (e.g. HTZ-FSN1)."
value = module.netbird.network_ids
}
@@ -0,0 +1,34 @@
# Include the deployment root DIRECTLY. This unit nests under prod/htz-fsn1/,
# which already has its own terragrunt.hcl (the fabric stack) — so the usual
# `find_in_parent_folders("terragrunt.hcl")` would resolve to THAT (the nearest
# ancestor), and since it also includes the root, terragrunt would see a
# two-level include chain ("only one level of includes is allowed"). Point at the
# root explicitly to skip the intervening fabric config.
include "root" {
path = "${get_repo_root()}/tf/deployment/terragrunt.hcl"
}
# Site NetBird layer for htz-fsn1 — its own state, decoupled from the htz-fsn1
# fabric stack (state key: ceph/prod/htz-fsn1/netbird/terraform.tfstate, via the
# root's full-sub-path stack derivation).
#
# netbird.auto.tfvars is loaded automatically; `env` is injected by the root.
# Depends on the global layer for the shared "yucca_resource" tag — this site's
# routed network resources are tagged into it so the account-wide yucca→
# yucca_resource policy (prod/global) governs their access. prod/global must
# apply before this stack; mock_outputs cover validate/plan before that.
dependency "global" {
config_path = "../../global"
mock_outputs = {
group_ids = { yucca_resource = "mock-yucca-resource-group-id" }
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
external_groups = {
yucca_resource = dependency.global.outputs.group_ids.yucca_resource
}
}
@@ -0,0 +1,61 @@
variable "env" {
description = "Environment slug, injected by terragrunt from the path (prod)."
type = string
}
variable "site" {
description = "Site slug; namespaces this layer's NetBird objects as yucca-<env>-<site>-*."
type = string
default = "htz-fsn1"
}
variable "external_groups" {
description = "Groups owned by the global prod layer, injected by the terragrunt dependency (logical key → NetBird group ID). Today: { admins = <global admins id> }."
type = map(string)
default = {}
}
variable "groups" {
type = map(object({
name = optional(string)
}))
default = {}
}
variable "setup_keys" {
type = map(object({
type = optional(string, "reusable")
expiry_seconds = optional(number, 0)
usage_limit = optional(number, 0)
ephemeral = optional(bool, false)
revoked = optional(bool, false)
allow_extra_dns_labels = optional(bool, false)
auto_groups = optional(list(string), [])
}))
default = {}
}
variable "policies" {
type = map(object({
description = optional(string)
enabled = optional(bool, true)
rules = list(object({
name = string
action = optional(string, "accept")
protocol = optional(string, "all")
bidirectional = optional(bool, true)
enabled = optional(bool, true)
description = optional(string)
sources = list(string)
destinations = list(string)
ports = optional(list(string))
}))
}))
default = {}
}
variable "site_id" {
description = "Site identifier feeding the fabric-addressing plan (htz-fsn1 = 40). Mirrors prod/htz-fsn1's site_id; the routed-network CIDRs derive from it, so nothing is hardcoded."
type = number
default = 40
}
@@ -0,0 +1,13 @@
terraform {
required_version = "~> 1.11"
required_providers {
netbird = {
source = "netbirdio/netbird"
version = "~> 0.0.9"
}
onepassword = {
source = "1Password/onepassword"
version = "~> 2.1"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/1password/onepassword" {
version = "2.2.1"
constraints = "~> 2.1"
hashes = [
"h1:aH5pZUimlQdiGnDLHRC49W4xnkx52wfd8XRLt69+764=",
"zh:025709a6b5f1b3685d277f2c48f7cb8b53d14b3699c1123d7e9a2135c099c533",
"zh:037fc89d150063a8aacdcab08ba26038b489fe2468d509b842d298ea59096ca6",
"zh:233777182b25faf1658e8ce171b684460983bb41cff79fb243662f3f9dc5ca6c",
"zh:2fb5ca2fc8c37b1d1c54da646ed13bf40897941fe92eece784fba496f677b533",
"zh:4b25b5ce1f694ec265e65234fc85d6bdf3810297ffeaced54ad46a1ba28142de",
"zh:5509d1e4fb7b45c63124ec66fd1f9d6757daa8bf1f7bdd724d5adb2965b61436",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:a23ba946c629ec912b2fcbf606a2eb8853626ec0e0bee749f2d39146a872c082",
"zh:a3d3024485426237d7b4a4350b12dda4d29d88f3942246a9370be35ec2a51e9e",
"zh:a6ef65544ab8fc26d468b38636407a3d2d902e35c51b648729bf97c31d1937f9",
"zh:afbe9480a0da0ad8dc514b277f1e4be36b8931f045021d05c21665ef1ac0b7c8",
"zh:b2e96e69fa9ff7e179dccdef5b785cd020eb46bb2b3d1d507d009d71be6b0c26",
"zh:ceefaede9e8a3104463523ba267e3e985b27a706f7628a9ddd37330c2ca59d4d",
"zh:ea77786bd6809ff4f8043b84a0212fec4de18b7d51bc420417ba10999ca99887",
"zh:f7d8160c3669c8ab76a2da14ea740d91a08ca23d1fb657669e52a840b2b113d9",
]
}
provider "registry.opentofu.org/netbirdio/netbird" {
version = "0.0.9"
constraints = "~> 0.0.9"
hashes = [
"h1:H8GG0MZqAAXqnrMCw0Q3vcE3gGFEqYmDF9YY2RnD18Y=",
"zh:1f34fba3ecfe0efa36d4b4bddf5714c69124e705d1e371e65abd291887d43385",
"zh:203f671af4d1f5376f4e20fb8d82cc8dc6c4fd136d9abffe8eb7b7f34e27197b",
"zh:21cb344302bdbadbc2779768116c7351d915bb7678a4847e9e2c8623d0032c48",
"zh:2368868e0f86b458f83b6598317ff5fde0d4079500d4567e99f46bf80c63f07a",
"zh:3237a426818eb3906153b1cf7fb6dfe9d52128972e7cab17c324d88788f86863",
"zh:427f8e8ba190d69cfd493b1598b4bf7940fe9a521a333df3540d2e0ea07543cf",
"zh:689cb219f3936500f5a3147299961f8740e505612b5182d93840cb8152d89b4f",
"zh:6e594e69d9e107c45ed4677a9bff68de2dd4232900b636327b24c819bee72c9e",
"zh:715634d6d0b052ac3f34aeb4c2df42960cdf44dd5c45d060867db78716aebb48",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8cbbdde5a0b59f0bb427b403da8993b215d1a6231e0d6b9b4faa89db76b10705",
"zh:af92281c3e14c6af53fb93cee584c5118c5c7bee0acb938b5a367a219d9bc2e2",
"zh:bcdeef5fadf092e33228f28f013af7d8d9553b9d2fb2cecd1894351d5ad37a7a",
"zh:d6c31cb12f18b25c9663c14e737554e06144d4e270e607337bac7963ca3b9542",
"zh:e5873e8e0b29c8cbfd98f04759579da3c0529c8b38608c0f1ea92eb566c8ddc1",
]
}
+61
View File
@@ -0,0 +1,61 @@
# Per-environment NetBird (Cloud) groups, access policies, and device auth
# (setup) keys. One NetBird Cloud account backs every env; the module namespaces
# every object as "yucca_<env>_<name>" so they coexist.
#
# Auth (both injected by `op run --env-file=tf/.env` via the mise tf:* tasks):
# • netbird — admin PAT from NB_PAT (op://shared_tf/NETBIRD_TF_PAT).
# management_url defaults to https://api.netbird.io (Cloud).
# • onepassword — OP_SERVICE_ACCOUNT_TOKEN (same op run session); writes the
# minted setup keys into the yucca_tf_<env> vault.
provider "netbird" {}
provider "onepassword" {}
# Existing NetBird groups owned outside this stack (the staging nodes live in the
# "Liberty Park" infra groups today). Looked up by name and handed to the module
# as external_groups so policies can reference them by logical key without
# managing them. The "yucca" users group is the global access group (see
# prod/global for the account-wide yucca→yucca policy).
data "netbird_group" "lp_compute" {
name = "Liberty Park Compute"
}
data "netbird_group" "lp_server_monitoring" {
name = "Liberty Park Server Monitoring"
}
data "netbird_group" "lp_servers" {
name = "Liberty Park Servers"
}
data "netbird_group" "lp_services" {
name = "Liberty Park Services"
}
module "netbird" {
source = "../../../shared/modules/netbird-env"
env = var.env
name_prefix = "yucca_${var.env}"
vault = "yucca_tf_${var.env}"
groups = var.groups
setup_keys = var.setup_keys
policies = var.policies
external_groups = {
lp_compute = data.netbird_group.lp_compute.id
lp_server_monitoring = data.netbird_group.lp_server_monitoring.id
lp_servers = data.netbird_group.lp_servers.id
lp_services = data.netbird_group.lp_services.id
}
}
output "group_ids" {
description = "Logical group key → NetBird group ID."
value = module.netbird.group_ids
}
output "setup_key_items" {
description = "Setup-key plaintext lives in these 1Password items (yucca_tf_<env>)."
value = module.netbird.setup_key_items
}
@@ -0,0 +1,46 @@
# NetBird Cloud objects for yucca-staging. Names are auto-prefixed "yucca_staging_"
# (all underscores); setup-key plaintext is written to the yucca_tf_staging vault.
#
# Groups start empty — a peer joins a group by registering with a setup key whose
# auto_groups include it. NetBird is default-deny: a peer gets only the access its
# groups' policies grant.
groups = {
ci = {} # ephemeral CI runners → yucca_staging_ci
mgmt = {} # management nodes (configured via ansible) → yucca_staging_mgmt
talos = {} # Talos cluster nodes → yucca_staging_talos
k8s_operator = {} # in-cluster kubernetes operator → yucca_staging_k8s_operator
}
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 reaches everything in this env (deploy/manage access to every node group).
ci-to-all = {
description = "CI → all staging node groups."
rules = [{
name = "ci-to-all"
protocol = "all"
sources = ["ci"]
destinations = ["mgmt", "talos", "k8s_operator"]
}]
}
# CI reaches the existing Liberty Park infra groups where the staging nodes
# live today (the targets CI talks to over the overlay). lp_* are external
# groups resolved by name in main.tf.
ci-to-liberty-park = {
description = "Staging CI → Liberty Park infra groups."
rules = [{
name = "ci-to-liberty-park"
protocol = "all"
sources = ["ci"]
destinations = ["lp_compute", "lp_server_monitoring", "lp_servers", "lp_services"]
}]
}
}
@@ -0,0 +1,6 @@
include "root" {
path = find_in_parent_folders("terragrunt.hcl")
}
# netbird.auto.tfvars is loaded automatically by OpenTofu in this directory.
# `env` is injected by the root config (parsed from the path: deployment/<env>/netbird).
@@ -0,0 +1,46 @@
# Passthrough variables — shapes mirror the netbird-env module so the
# declarative netbird.auto.tfvars validates here before reaching the module.
variable "env" {
description = "Environment slug, injected by terragrunt from the path (deployment/<env>/netbird)."
type = string
}
variable "groups" {
type = map(object({
name = optional(string)
}))
default = {}
}
variable "setup_keys" {
type = map(object({
type = optional(string, "reusable")
expiry_seconds = optional(number, 0)
usage_limit = optional(number, 0)
ephemeral = optional(bool, false)
revoked = optional(bool, false)
allow_extra_dns_labels = optional(bool, false)
auto_groups = optional(list(string), [])
}))
default = {}
}
variable "policies" {
type = map(object({
description = optional(string)
enabled = optional(bool, true)
rules = list(object({
name = string
action = optional(string, "accept")
protocol = optional(string, "all")
bidirectional = optional(bool, true)
enabled = optional(bool, true)
description = optional(string)
sources = list(string)
destinations = list(string)
ports = optional(list(string))
}))
}))
default = {}
}
+13
View File
@@ -0,0 +1,13 @@
terraform {
required_version = "~> 1.11"
required_providers {
netbird = {
source = "netbirdio/netbird"
version = "~> 0.0.9"
}
onepassword = {
source = "1Password/onepassword"
version = "~> 2.1"
}
}
}
+10 -2
View File
@@ -15,11 +15,19 @@
locals {
# Parse env and stack from the directory structure.
# e.g., tf/deployment/dev/ceph → env=dev, stack=ceph
# tf/deployment/dev/ceph → env=dev, stack=ceph
# tf/deployment/prod/htz-fsn1 → env=prod, stack=htz-fsn1
# tf/deployment/prod/htz-fsn1/netbird → env=prod, stack=htz-fsn1/netbird
# `stack` is EVERY segment after env, joined — so a site can nest sub-stacks
# (e.g. prod/<site>/netbird) with their own state key, distinct from the site's
# top-level stack. Single-segment stacks are unchanged (slice of [1:1] = the
# one element), so existing state keys are preserved.
relative_path = path_relative_to_include()
path_segments = split("/", local.relative_path)
env = length(local.path_segments) > 0 ? local.path_segments[0] : "unknown"
stack = length(local.path_segments) > 1 ? local.path_segments[1] : "unknown"
stack = length(local.path_segments) > 1 ? join("/", slice(
local.path_segments, 1, length(local.path_segments)
)) : "unknown"
}
remote_state {
+151
View File
@@ -0,0 +1,151 @@
# Per-environment NetBird (Cloud) objects. One NetBird Cloud account backs every
# env/site; objects are namespaced "<name_prefix>_<key>" (all underscores) so they
# coexist. Groups are created first; setup keys, policies, and networks resolve
# their logical group keys to NetBird-assigned group IDs.
#
# The netbird + onepassword providers are configured by the calling stack (this
# module only declares the dependency in versions.tf).
locals {
# Names are normalized to underscores (no hyphens) per the repo convention,
# e.g. name_prefix "yucca_prod_htz-fsn1" + key "mgmt" → "yucca_prod_htz_fsn1_mgmt".
# An explicit `name` override on a group is respected verbatim.
group_names = {
for k, g in var.groups : k => coalesce(g.name, replace("${var.name_prefix}_${k}", "-", "_"))
}
# Logical key → NetBird group ID, covering both the groups this layer owns and
# any external_groups handed in from another layer (e.g. prod global → site).
group_ids = merge(
{ for k, g in netbird_group.this : k => g.id },
var.external_groups,
)
# Flatten networks → resources as "<network_key>/<resource_key>" so each routed
# subnet/host is its own netbird_network_resource instance.
network_resources = merge([
for nk, n in var.networks : {
for rk, r in n.resources : "${nk}/${rk}" => {
network_key = nk
address = r.address
description = r.description
groups = r.groups
enabled = r.enabled
name = coalesce(r.name, replace("${nk}_${rk}", "-", "_"))
}
}
]...)
}
resource "netbird_group" "this" {
for_each = var.groups
name = local.group_names[each.key]
}
# Device auth keys. `key` (plaintext) is sensitive and lands in 1Password below.
resource "netbird_setup_key" "this" {
for_each = var.setup_keys
name = replace("${var.name_prefix}_${each.key}", "-", "_")
type = each.value.type
expiry_seconds = each.value.expiry_seconds
usage_limit = each.value.usage_limit
ephemeral = each.value.ephemeral
revoked = each.value.revoked
allow_extra_dns_labels = each.value.allow_extra_dns_labels
auto_groups = [for g in each.value.auto_groups : local.group_ids[g]]
}
resource "netbird_policy" "this" {
for_each = var.policies
name = replace("${var.name_prefix}_${each.key}", "-", "_")
description = each.value.description
enabled = each.value.enabled
dynamic "rule" {
for_each = each.value.rules
content {
name = rule.value.name
action = rule.value.action
protocol = rule.value.protocol
bidirectional = rule.value.bidirectional
enabled = rule.value.enabled
description = rule.value.description
sources = [for g in rule.value.sources : local.group_ids[g]]
destinations = [for g in rule.value.destinations : local.group_ids[g]]
ports = rule.value.ports
}
}
}
# ─── Networks (routed access into a site's underlying subnets) ───────────
# A Network groups one or more resources (subnets/hosts) reachable through a set
# of routing peers (router.peer_groups — e.g. a site's mgmt nodes). resources[*]
# .groups controls which peers may reach that subnet. The Network's display name
# is the map key (or `name` override) verbatim — NOT underscore-normalized — so
# human labels like "HTZ-FSN1" survive.
resource "netbird_network" "this" {
for_each = var.networks
name = coalesce(each.value.name, each.key)
description = each.value.description
}
resource "netbird_network_router" "this" {
for_each = var.networks
network_id = netbird_network.this[each.key].id
peer_groups = [for g in each.value.router.peer_groups : local.group_ids[g]]
masquerade = each.value.router.masquerade
metric = each.value.router.metric
enabled = each.value.router.enabled
}
resource "netbird_network_resource" "this" {
for_each = local.network_resources
network_id = netbird_network.this[each.value.network_key].id
name = each.value.name
address = each.value.address
description = each.value.description
groups = [for g in each.value.groups : local.group_ids[g]]
enabled = each.value.enabled
}
# ─── Setup keys → 1Password (source of truth for the plaintext key) ──────
# Operators retrieve a key with `op read`/the desktop app instead of digging
# through TF state. Per-env vault: dev → yucca_tf_dev, etc.
data "onepassword_vault" "env" {
name = var.vault
}
resource "onepassword_item" "setup_key" {
for_each = var.setup_keys
vault = data.onepassword_vault.env.uuid
# Title derived from the namespaced setup-key name so multiple sites writing to
# the SAME vault (prod: global + every site → yucca_tf_prod) never collide, e.g.
# "yucca_prod_htz_fsn1_mgmt" → NETBIRD_YUCCA_PROD_HTZ_FSN1_MGMT_SETUP_KEY.
title = "NETBIRD_${upper(netbird_setup_key.this[each.key].name)}_SETUP_KEY"
category = "password"
password = netbird_setup_key.this[each.key].key
section {
label = "netbird"
field {
label = "setup_key_id"
type = "STRING"
value = netbird_setup_key.this[each.key].id
}
field {
label = "name"
type = "STRING"
value = netbird_setup_key.this[each.key].name
}
field {
label = "type"
type = "STRING"
value = each.value.type
}
}
}
+19
View File
@@ -0,0 +1,19 @@
output "group_ids" {
description = "Logical group key → NetBird group ID."
value = { for k, g in netbird_group.this : k => g.id }
}
output "policy_ids" {
description = "Logical policy key → NetBird policy ID."
value = { for k, p in netbird_policy.this : k => p.id }
}
output "setup_key_items" {
description = "Logical setup-key key → 1Password item title (in var.vault) holding the plaintext key. The key itself is never output."
value = { for k, i in onepassword_item.setup_key : k => i.title }
}
output "network_ids" {
description = "Logical network key → NetBird network ID."
value = { for k, n in netbird_network.this : k => n.id }
}
+129
View File
@@ -0,0 +1,129 @@
# Declarative inputs for one environment's NetBird footprint. Policies and
# setup keys reference groups by their LOGICAL key (the map key here), which the
# module resolves to the NetBird-assigned group ID — so the tfvars never carry
# opaque IDs.
variable "env" {
description = "Environment slug (dev|staging|prod). Labels the minted 1Password setup-key items."
type = string
}
variable "name_prefix" {
description = "Prefix for every NetBird object name so all envs/sites coexist in one NetBird Cloud account (e.g. \"yucca_staging\" → group \"yucca_staging_mgmt\"; \"yucca_prod_htz-fsn1\" → \"yucca_prod_htz_fsn1_mgmt\"). Hyphens in the prefix are normalized to underscores."
type = string
}
variable "vault" {
description = "1Password vault that minted setup keys are written into (per env, e.g. \"yucca_tf_staging\")."
type = string
}
variable "groups" {
description = "NetBird groups keyed by logical name. Groups start empty — membership comes from setup keys' auto_groups as peers register. Set `name` only to override the derived \"<name_prefix>_<key>\"."
type = map(object({
name = optional(string)
}))
default = {}
}
variable "external_groups" {
description = "Groups owned by another layer/stack, exposed here as logical key → NetBird group ID so policies/setup keys/networks can reference them without re-managing them. Intended for cross-layer references (e.g. a prod site layer consuming a group from prod/global via a terragrunt dependency). Empty by default; keys must not collide with var.groups."
type = map(string)
default = {}
validation {
condition = length(setintersection(keys(var.groups), keys(var.external_groups))) == 0
error_message = "external_groups keys must not overlap var.groups keys (a logical key resolves to exactly one group)."
}
}
variable "setup_keys" {
description = "Device auth (setup) keys keyed by logical name. The plaintext key is written to 1Password (var.vault) as NETBIRD_<UPPERCASED_NAMESPACED_NAME>_SETUP_KEY and never surfaced in plan output."
type = map(object({
type = optional(string, "reusable") # "one-off" | "reusable"
expiry_seconds = optional(number, 0) # 0 = no expiry
usage_limit = optional(number, 0) # 0 = unlimited (reusable only)
ephemeral = optional(bool, false) # peer auto-removed after ~10m idle
revoked = optional(bool, false)
allow_extra_dns_labels = optional(bool, false)
auto_groups = optional(list(string), []) # logical group keys to auto-assign on join
}))
default = {}
validation {
condition = alltrue([for k, s in var.setup_keys : contains(["one-off", "reusable"], s.type)])
error_message = "setup_keys[*].type must be \"one-off\" or \"reusable\"."
}
validation {
condition = alltrue(flatten([
for k, s in var.setup_keys : [
for g in s.auto_groups : contains(concat(keys(var.groups), keys(var.external_groups)), g)
]
]))
error_message = "setup_keys[*].auto_groups must reference keys present in var.groups or var.external_groups."
}
}
variable "policies" {
description = "Access policies keyed by logical name. NetBird is default-deny; a rule's sources/destinations are logical group keys (resolved to NetBird group IDs)."
type = map(object({
description = optional(string)
enabled = optional(bool, true)
rules = list(object({
name = string
action = optional(string, "accept") # "accept" | "drop"
protocol = optional(string, "all") # tcp|udp|icmp|all
bidirectional = optional(bool, true)
enabled = optional(bool, true)
description = optional(string)
sources = list(string) # logical group keys
destinations = list(string) # logical group keys
ports = optional(list(string)) # e.g. ["22","443"]; tcp/udp only
}))
}))
default = {}
validation {
condition = alltrue(flatten([
for k, p in var.policies : [
for r in p.rules : [
for g in concat(r.sources, r.destinations) : contains(concat(keys(var.groups), keys(var.external_groups)), g)
]
]
]))
error_message = "policies[*].rules[*].sources/destinations must reference keys present in var.groups or var.external_groups."
}
}
variable "networks" {
description = "NetBird Networks keyed by logical name (the key is the Network's display name unless `name` is set — kept verbatim, not underscore-normalized). A Network routes its `resources` (subnets/hosts) through the peers in `router.peer_groups`; each resource's `groups` controls who may reach it. All group references are logical keys (var.groups or var.external_groups)."
type = map(object({
name = optional(string)
description = optional(string)
router = object({
peer_groups = list(string) # logical group keys acting as routing peers
masquerade = optional(bool, true) # SNAT overlay traffic to the route prefix
metric = optional(number, 9999) # lower = higher priority
enabled = optional(bool, true)
})
resources = optional(map(object({
name = optional(string) # default "<network_key>_<resource_key>" (normalized)
address = string # CIDR (10.40.20.0/23), host (1.1.1.1), or domain
description = optional(string)
groups = list(string) # logical group keys allowed to reach this resource
enabled = optional(bool, true)
})), {})
}))
default = {}
validation {
condition = alltrue(flatten([
for nk, n in var.networks : [
[for g in n.router.peer_groups : contains(concat(keys(var.groups), keys(var.external_groups)), g)],
[for rk, r in n.resources : [for g in r.groups : contains(concat(keys(var.groups), keys(var.external_groups)), g)]],
]
]))
error_message = "networks[*].router.peer_groups and networks[*].resources[*].groups must reference keys present in var.groups or var.external_groups."
}
}
+18
View File
@@ -0,0 +1,18 @@
terraform {
required_version = "~> 1.11"
required_providers {
# NetBird Cloud (api.netbird.io). The PAT is supplied to the *stack's*
# provider block from NB_PAT (op://shared_tf/NETBIRD_TF_PAT); this module
# only declares the dependency.
netbird = {
source = "netbirdio/netbird"
version = "~> 0.0.9"
}
# Writes minted setup keys into the per-env yucca_tf_<env> vault as the
# source-of-truth record. Auth via OP_SERVICE_ACCOUNT_TOKEN (op run).
onepassword = {
source = "1Password/onepassword"
version = "~> 2.1"
}
}
}