mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(netbird): wire this sweetie up (#209)
* feat(netbird): wire this sweetie up * remove dev * fix * more features
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 = {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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 = {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user