Files
factory/vibe/guidebooks/factory-provisioning/opentofu/ci-apply-flow.md
Gabriel Radureau dbe32161dc docs(vibe): add factory-provisioning guidebook (Ansible + OpenTofu)
Deep, code-grounded tree-docs guidebook under vibe/guidebooks/factory-provisioning/,
explored from the actual playbooks/roles and tofu code:

- Hub: the two provisioning engines (operator-run Ansible vs CI-applied OpenTofu),
  a green-field bring-up flow, master index, maintenance rule.
- ansible/ sub-tree: ordered pages 01-system .. 06-recover, an inventory & variables
  concept page, and a Tier-1/Tier-2 roles reference (hashicorp_vault, step_ca,
  crowdsec, pihole, deploy_docker_compose + the gitea_* family and helpers).
- opentofu/ sub-tree: factory-iac (Cloudflare/OVH/GCP/Gitea/Vault edge +
  cloudflare_token module), postgres-iac (per-app DB/role/pgbouncer lookup),
  ci-apply-flow (Gitea OIDC-JWT -> Vault -> auto-approve apply).

Cross-linked bidirectionally with the lab-ecosystem guidebook and the safe-env
ADR/PRD (the sandbox rehearses exactly these engines). 14 mermaid diagrams
MCP-validated; zero dead links. Authored by the Lab Cartographer cohort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:11:51 +02:00

8.1 KiB

vibe > Guidebooks > Factory provisioning > OpenTofu > CI apply flow

CI apply flow

Note

Status: active · Last Updated: 2026-06-23 Upstream: .gitea/workflows/iac.yaml, .gitea/workflows/postgres.yaml Downstream: factory iac, postgres iac Related: Secrets & Vault · ADR-0001 · Safe prod-like environment · QA strategy

Two Gitea Actions workflows turn every commit that touches the OpenTofu code into a live apply. IAC (.gitea/workflows/iac.yaml) drives the factory infrastructure under iac/; Postgres (.gitea/workflows/postgres.yaml) drives the database stack under postgres/iac/. They share the same two-job shape: a short OIDC-auth job feeds a Vault JWT to a tofu job that reads secrets and runs terraform apply.

Caution

auto_approve: true means every merge to main applies immediately — there is no plan-gate. The dflook/terraform-apply@v1 step skips the interactive approval, so any change that lands on main (or any matched push) rewrites real cloud and homelab state without a human reviewing the plan. Mitigations are entirely upstream of CI: (1) mandatory code review on the PR before merge, and (2) least-privilege Vault policies on the gitea_cicd role so a runaway apply can only touch the resources its token is scoped to. See ADR-0001: the sandbox lane runs the same tofu but plan-only against a sandbox/ state prefix and a throwaway DNS zone, so contributors can validate changes without an auto-apply.

Triggers

Both workflows fire on the same three events; only the watched path globs differ.

Event IAC (factory) Postgres
push iac/*.tf, iac/*.tfvars, iac/**/*.tf, iac/**/*.tfvars postgres/**/*.tf, postgres/**/*.tfvars
pull_request same globs (YAML anchor *tofuPaths) same globs (YAML anchor *postgresTofuPaths)
workflow_dispatch manual, no inputs manual, no inputs

Important

concurrency is keyed on ${{ github.ref }}-${{ github.workflow }} with cancel-in-progress: true, so a newer push to the same branch cancels an in-flight run. A pull_request event triggers the workflow — but the apply still runs, so the safety contract is "review before merge", not "CI only plans on PRs".

Job 1 — gitea_vault_auth

Mints a Gitea OIDC token that Vault will trust. The whole job is one step:

echo -n "${{ secrets.vault_oauth__sh_b64 }}" | base64 -d | bash
Field Value
Runner ubuntu-latest
Secret consumed vault_oauth__sh_b64 — a base64-encoded shell script
Step id gitea_vault_jwt
Output gitea_vault_jwtsteps.gitea_vault_jwt.outputs.id_token

The decoded script asks Gitea for an OIDC id_token and emits it as a step output. The tofu job declares needs: [gitea_vault_auth] so it receives needs.gitea_vault_auth.outputs.gitea_vault_jwt.

Job 2 — tofu

Field IAC Postgres
Job name Tofu Tofu - Postgres
needs gitea_vault_auth gitea_vault_auth
OPENTOFU_VERSION 1.8.2 1.8.2
TERRAFORM_VAULT_AUTH_JWT needs.gitea_vault_auth.outputs.gitea_vault_jwt same
VAULT_CACERT ${{ github.workspace }}/homelab.pem same
Apply path iac postgres/iac

Step order inside the job:

  1. read vault secret — the shared *vault_step anchor (see below).
  2. actions/checkout@v4 — pull the repo into the workspace.
  3. prepare vault self signed certecho -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT, writing the homelab CA to homelab.pem so the runner trusts https://vault.arcodange.lab.
  4. terraform applydflook/terraform-apply@v1 with the path above and auto_approve: true.

Vault secret reads (*vault_step)

The read vault secret step uses arcodange-org/vault-action, authenticating with method: jwt, path: gitea_jwt, role: gitea_cicd, url: https://vault.arcodange.lab, caCertificate: ${{ secrets.HOMELAB_CA_CERT }}, and jwtGiteaOIDC set to the auth job's output. The secrets it exports into the job env differ per workflow:

Workflow Vault path Selector Exported as
IAC kvv1/google/credentials credentials GOOGLE_CREDENTIALS
IAC kvv1/admin/gitea token GITEA_TOKEN
IAC kvv1/admin/cloudflare iam_token CLOUDFLARE_API_TOKEN
IAC kvv1/admin/ovh/app * (all keys) OVH_*
Postgres kvv1/google/credentials credentials GOOGLE_BACKEND_CREDENTIALS
Postgres kvv1/postgres/credentials * (all keys) TF_VAR_postgres_*

GOOGLE_CREDENTIALS / GOOGLE_BACKEND_CREDENTIALS authenticate the GCS state backend; the TF_VAR_postgres_* fan-out feeds the Postgres module's input variables directly. See Secrets & Vault for how the gitea_cicd role and KV v1 mounts are provisioned.

End-to-end flow

%%{init: {'theme': 'base'}}%%
flowchart TD
  push["push / PR / workflow_dispatch<br>on iac/** or postgres/** .tf .tfvars"] --> auth["job: gitea_vault_auth<br>base64 -d | bash -&gt; Gitea OIDC id_token"]
  auth -->|"gitea_vault_jwt output"| tofu["job: tofu<br>OPENTOFU_VERSION 1.8.2"]
  tofu --> readvault["read vault secret<br>vault-action jwt role gitea_cicd"]
  readvault -->|"GOOGLE_CREDENTIALS, TF_VAR_postgres_*, ..."| init["tofu init<br>GCS backend, state prefix"]
  init --> apply["dflook/terraform-apply@v1<br>auto_approve: true"]
  apply --> state["state updated in GCS<br>real cloud + homelab mutated"]

  classDef trigger fill:#1f3a5f,stroke:#7fb0ff,color:#eaf2ff;
  classDef job fill:#3a2f5f,stroke:#b39dff,color:#f3eeff;
  classDef secret fill:#5f3a2f,stroke:#ffb38a,color:#fff1e8;
  classDef danger fill:#5f1f2f,stroke:#ff8a9d,color:#ffe8ec;
  class push trigger;
  class auth,tofu,init job;
  class readvault secret;
  class apply,state danger;
  1. A push, pull_request, or workflow_dispatch event matching the iac/** or postgres/** path globs starts the workflow.
  2. Job gitea_vault_auth runs base64 -d | bash on the vault_oauth__sh_b64 secret to obtain a Gitea OIDC id_token, published as the gitea_vault_jwt output.
  3. Job tofu (gated by needs: gitea_vault_auth) starts on ubuntu-latest with OPENTOFU_VERSION 1.8.2 and TERRAFORM_VAULT_AUTH_JWT set to that output.
  4. The read vault secret step exchanges the JWT (role gitea_cicd, path gitea_jwt) for the workflow's secrets and exports them as env vars (GOOGLE_CREDENTIALS / GOOGLE_BACKEND_CREDENTIALS, GITEA_TOKEN, CLOUDFLARE_API_TOKEN, OVH_*, or TF_VAR_postgres_*).
  5. tofu init configures the GCS backend, binding the working dir to its state prefix using the Google credentials just read.
  6. dflook/terraform-apply@v1 runs against iac (or postgres/iac) with auto_approve: true — no plan-gate.
  7. The state in GCS is updated and the real cloud + homelab resources are mutated to match the committed code.
  • factory iac — what the iac/ stack provisions (the IAC workflow's target).
  • postgres iac — the postgres/iac/ database stack (the Postgres workflow's target).
  • Secrets & Vault — the gitea_cicd role, OIDC trust, and KV mounts behind every secret read here.
  • ADR-0001 · Safe prod-like environment — the sandbox lane runs the same tofu plan-only against a sandbox/ state prefix and a throwaway zone.