diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64e6c83..c4463b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,6 @@ jobs: with: fetch-depth: 0 - # - name: Install lychee - # run: | - # curl -sSf '' | sh - # apt install gcc pkg-config libc6-dev libssl-dev - - uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true @@ -32,8 +27,13 @@ jobs: - name: Install dependencies run: uv sync --frozen - # - name: Run pre-commit - # run: uv run pre-commit run --all-files --show-diff-on-failure + - name: Install lychee + uses: taiki-e/install-action@v2 + with: + tool: lychee + + - name: Run pre-commit + run: uv run pre-commit run --all-files --show-diff-on-failure - - name: Build site - run: uv run zensical build + - name: Build site (strict) + run: uv run zensical build --strict diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae143ed..af72706 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,9 +39,8 @@ jobs: - name: Install dependencies run: uv sync --frozen --no-dev - - name: Build site - # See ci.yml for why --strict is disabled. - run: uv run zensical build + - name: Build site (strict) + run: uv run zensical build --strict - uses: actions/configure-pages@v6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6005da7..7bbd1c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,6 +31,11 @@ repos: stages: [commit-msg] args: [ops] + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint + - repo: local hooks: - id: zensical-build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f49aac1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing + +This is primarily a personal stash of boilerplate I keep rewriting, published +in the small chance someone else finds it useful. + +PRs are welcome but not actively solicited. If you spot a bug or want to add a +snippet, feel free to open an issue or PR just keep in mind I'll prioritize +based on whether I'd use it myself. + +## Local setup + +See [README.md](README.md) it covers `uv`, `make install`, and the local +dev loop. + +## Conventions + +- **Conventional Commits** for commit messages (enforced via `pre-commit` on + the `commit-msg` hook). Allowed types are configured in + [`.pre-commit-config.yaml`](.pre-commit-config.yaml). +- **Run `make pre-commit`** before pushing. CI runs the same hooks plus a + strict Zensical build. +- **Snippets should be self-contained** copy a single block and it works. + Cite the upstream docs at the bottom of every page. +- **Validate variables**. New Terraform variable blocks should follow the + pattern in [`docs/terraform/aws/variables.md`](docs/terraform/aws/variables.md): + type, description, sensible default (or `nullable = true`), and a + `validation` block with a complete-sentence `error_message`. + +## Reporting issues + +Open a GitHub issue with: + +- The page URL or path +- What's wrong (typo, broken link, outdated example, missing context) +- Optionally a suggested fix + +## License + +By contributing you agree your changes will be released under the project's +[GPL-3.0 license](LICENSE). diff --git a/README.md b/README.md index 7517cf3..b096065 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ useful: | `lychee.toml` | [lychee](https://lychee.cli.rs/) link-checker config (used by pre-commit and CI). | | `.editorconfig` | Whitespace defaults across editors. | | `Makefile` | Convenience targets — run `make help`. | -| `terragrunt/` | Working Terragrunt example referenced by the docs *(WIP — being modernized)*. | +| `terragrunt/` | Legacy Terragrunt example. Modern patterns live in [`docs/terraform/terragrunt/`](docs/terraform/terragrunt/). | | `.github/workflows/` | `ci.yml` (PR checks), `deploy.yml` (Pages deploy on push to `main`), `links.yml` (lychee on PR + weekly cron). | ## Local development @@ -79,3 +79,12 @@ This site originally ran on [Material for MkDocs](https://squidfunk.github.io/mk it now runs on [Zensical](https://zensical.org/), the team's new SSG. Zensical is currently alpha, so the dependency is pinned to an exact version (see [`pyproject.toml`](pyproject.toml)) and bumped deliberately. + +## Contributing + +This is primarily a personal stash, but PRs and issues are welcome. See +[CONTRIBUTING.md](CONTRIBUTING.md) for the local setup and conventions. + +## License + +Released under the [GPL-3.0 license](LICENSE). diff --git a/docs/api/error-responses.md b/docs/api/error-responses.md index a505c3c..0db3e56 100644 --- a/docs/api/error-responses.md +++ b/docs/api/error-responses.md @@ -1,5 +1,6 @@ --- title: RFC 7807 problem+json +description: RFC 7807 problem-detail error responses for HTTP APIs. status: stub tags: - api diff --git a/docs/api/fastapi-skeleton.md b/docs/api/fastapi-skeleton.md index 04a53e0..5002e23 100644 --- a/docs/api/fastapi-skeleton.md +++ b/docs/api/fastapi-skeleton.md @@ -1,5 +1,6 @@ --- title: FastAPI service skeleton +description: Opinionated FastAPI project layout with settings, routers, and dependencies. status: stub tags: - api diff --git a/docs/api/go-chi-skeleton.md b/docs/api/go-chi-skeleton.md index eb10302..11422ba 100644 --- a/docs/api/go-chi-skeleton.md +++ b/docs/api/go-chi-skeleton.md @@ -1,5 +1,6 @@ --- title: Go chi service skeleton +description: Minimal Go service skeleton built on chi with structured logging and graceful shutdown. status: stub tags: - api diff --git a/docs/api/index.md b/docs/api/index.md index 0ec4206..cbda6d3 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,5 +1,6 @@ --- title: API / Backend +description: Service skeletons, error responses, pagination, and OpenAPI starters. status: stub tags: - api diff --git a/docs/api/openapi.md b/docs/api/openapi.md index de35b4c..26eaee5 100644 --- a/docs/api/openapi.md +++ b/docs/api/openapi.md @@ -1,5 +1,6 @@ --- title: OpenAPI starter + lint +description: OpenAPI 3 starter spec plus linting with spectral or vacuum. status: stub tags: - api diff --git a/docs/api/pagination.md b/docs/api/pagination.md index 28f6c95..95cd111 100644 --- a/docs/api/pagination.md +++ b/docs/api/pagination.md @@ -1,5 +1,6 @@ --- title: Pagination — cursor vs offset +description: Cursor and offset pagination patterns for REST APIs. status: stub tags: - api diff --git a/docs/containers/compose-dev.md b/docs/containers/compose-dev.md index 9062520..412f6b1 100644 --- a/docs/containers/compose-dev.md +++ b/docs/containers/compose-dev.md @@ -1,5 +1,6 @@ --- title: Compose dev stacks +description: Reusable docker compose stacks for local development (Postgres, Redis, observability). status: stub tags: - docker diff --git a/docs/containers/distroless.md b/docs/containers/distroless.md index 397eff1..9022ce7 100644 --- a/docs/containers/distroless.md +++ b/docs/containers/distroless.md @@ -1,5 +1,6 @@ --- title: Distroless / chainguard images +description: Distroless and Chainguard base images for minimal, hardened runtime containers. status: stub tags: - docker diff --git a/docs/containers/dockerfile-go.md b/docs/containers/dockerfile-go.md index 334053f..a7e82fe 100644 --- a/docs/containers/dockerfile-go.md +++ b/docs/containers/dockerfile-go.md @@ -1,5 +1,6 @@ --- title: Go — static binary, distroless +description: Multi-stage Dockerfile producing a static Go binary on a distroless runtime. status: stub tags: - docker diff --git a/docs/containers/dockerfile-node.md b/docs/containers/dockerfile-node.md index 55ffd66..c8922cf 100644 --- a/docs/containers/dockerfile-node.md +++ b/docs/containers/dockerfile-node.md @@ -1,5 +1,6 @@ --- title: Node — multi-stage, pnpm +description: Multi-stage Dockerfile for Node services with pnpm and a slim runtime stage. status: stub tags: - docker diff --git a/docs/containers/dockerfile-python.md b/docs/containers/dockerfile-python.md index fa98867..779b357 100644 --- a/docs/containers/dockerfile-python.md +++ b/docs/containers/dockerfile-python.md @@ -1,5 +1,6 @@ --- title: Python — multi-stage with uv +description: Multi-stage Dockerfile for Python services using uv and a slim runtime stage. status: stub tags: - docker diff --git a/docs/containers/index.md b/docs/containers/index.md index e258c5b..8a33034 100644 --- a/docs/containers/index.md +++ b/docs/containers/index.md @@ -1,5 +1,6 @@ --- title: Containers +description: Multi-stage Dockerfiles, distroless images, and Compose dev stacks. status: stub tags: - docker diff --git a/docs/data/airflow-dag.md b/docs/data/airflow-dag.md index bacb5d3..c837335 100644 --- a/docs/data/airflow-dag.md +++ b/docs/data/airflow-dag.md @@ -1,5 +1,6 @@ --- title: Airflow TaskFlow DAG skeleton +description: Airflow DAG skeleton with TaskFlow API, retries, and sensible defaults. status: stub tags: - data diff --git a/docs/data/alembic-skeleton.md b/docs/data/alembic-skeleton.md index be1e7a1..a3c79f7 100644 --- a/docs/data/alembic-skeleton.md +++ b/docs/data/alembic-skeleton.md @@ -1,5 +1,6 @@ --- title: Alembic migration skeleton +description: SQLAlchemy + Alembic project layout with autogenerate, env.py, and per-env URLs. status: stub tags: - data diff --git a/docs/data/dbt-skeleton.md b/docs/data/dbt-skeleton.md index f1ad631..df13186 100644 --- a/docs/data/dbt-skeleton.md +++ b/docs/data/dbt-skeleton.md @@ -1,5 +1,6 @@ --- title: dbt project skeleton +description: Opinionated dbt project skeleton — sources, staging, marts, and tests. status: stub tags: - data diff --git a/docs/data/index.md b/docs/data/index.md index b28ee1b..bdd9ce8 100644 --- a/docs/data/index.md +++ b/docs/data/index.md @@ -1,5 +1,6 @@ --- title: Data +description: Postgres conventions, Alembic migrations, dbt projects, and Airflow DAGs. status: stub tags: - data diff --git a/docs/data/postgres-conventions.md b/docs/data/postgres-conventions.md index f977716..5241f12 100644 --- a/docs/data/postgres-conventions.md +++ b/docs/data/postgres-conventions.md @@ -1,5 +1,6 @@ --- title: Postgres schema conventions +description: Postgres naming, schema, and constraint conventions for long-lived databases. status: stub tags: - data diff --git a/docs/data/postgres-indexes.md b/docs/data/postgres-indexes.md index 23eb3d7..4593d5f 100644 --- a/docs/data/postgres-indexes.md +++ b/docs/data/postgres-indexes.md @@ -1,5 +1,6 @@ --- title: Postgres indexing & partitioning +description: When and how to add indexes — B-tree, partial, GIN, and covering indexes. status: stub tags: - data diff --git a/docs/examples/code-blocks.md b/docs/examples/code-blocks.md deleted file mode 100644 index 5e7deed..0000000 --- a/docs/examples/code-blocks.md +++ /dev/null @@ -1,358 +0,0 @@ ---- -title: Code block features -description: Live demo of every code-block feature available on this site — syntax highlighting, titles, line numbers, line highlighting, annotations, diffs, tabs. -tags: - - examples ---- - -# Code block features - -A live tour of every code-block feature the site supports. Use this page as a -reference when authoring boilerplate. Delete it whenever you don't want it -anymore — it lives at `docs/examples/code-blocks.md` and is referenced -in `nav:` only. - ---- - -## 1. Plain syntax highlighting - -A normal fenced block with a language hint: - -```hcl -variable "environment" { - type = string - - validation { - condition = contains(["dev", "stg", "prod"], var.environment) - error_message = "environment must be one of: dev, stg, prod." - } -} -``` - -````markdown -```hcl -variable "environment" { - type = string - ... -} -``` -```` - -Pygments handles every common language. Some samples: - -```python -from pydantic import BaseModel, Field - -class User(BaseModel): - id: int - email: str = Field(pattern=r"^[^@]+@[^@]+$") -``` - -```go -func Healthz(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"ok"}`)) -} -``` - -```sql -SELECT id, email, created_at -FROM users -WHERE deleted_at IS NULL -ORDER BY created_at DESC -LIMIT 50; -``` - -```dockerfile -FROM python:3.12-slim AS runtime -COPY --from=builder /app/.venv /app/.venv -USER 1000:1000 -ENTRYPOINT ["/app/.venv/bin/uvicorn", "app:app"] -``` - ---- - -## 2. Title (filename) header - -Add `title="..."` to put a header bar above the block: - -```hcl title="variables.tf" -variable "project" { - description = "Short project identifier." - type = string -} -``` - -````markdown -```hcl title="variables.tf" -variable "project" { … } -``` -```` - ---- - -## 3. Line numbers - -Add `linenums="1"` (the number is the starting line): - -```python title="app/main.py" linenums="1" -from fastapi import FastAPI - -app = FastAPI() - - -@app.get("/healthz") -def healthz() -> dict[str, str]: - return {"status": "ok"} -``` - -Click any line number to copy a permalink to that exact line. - ---- - -## 4. Highlighted lines - -Add `hl_lines="3 5-7"` to subtly highlight a single line and a range: - -```hcl title="main.tf" linenums="1" hl_lines="3 5-7" -resource "aws_s3_bucket" "logs" { - bucket = "${var.project}-logs" - force_destroy = false - - lifecycle { - prevent_destroy = true - } -} -``` - -````markdown -```hcl title="main.tf" linenums="1" hl_lines="3 5-7" -… -``` -```` - ---- - -## 5. Code annotations (numbered popovers) - -The single most useful feature for boilerplate. Two things are required: - -1. The fence must use the **brace header** form with `.annotate` added: - ` ``` { .hcl .annotate title="vpc.tf" linenums="1" } ` -2. Inside the code, drop `# (1)!` (or `// (1)!`, `-- (1)!`, - ``, etc.) on whichever line you want to annotate, then add a - numbered list immediately after the closing fence. - -Each list item becomes a popover anchored to that line. - -``` { .hcl .annotate title="vpc.tf" linenums="1" hl_lines="6" } -variable "vpc_cidr" { - description = "IPv4 CIDR for the VPC." - type = string - default = "10.0.0.0/16" - - validation { # (1)! - condition = can(cidrnetmask(var.vpc_cidr)) # (2)! - error_message = "vpc_cidr must be a valid IPv4 CIDR block." - } -} -``` - -1. Custom validation runs at `terraform plan` time. Multiple `validation` - blocks per variable are allowed and evaluated independently. -2. The `can()` wrapper turns a parse exception into `false`, which is what - `condition` expects. Without it, an invalid CIDR would crash the plan - instead of producing a clean error message. - -Annotation popovers support **full Markdown** — bold, links, lists, even -nested code: - -``` { .python .annotate title="settings.py" linenums="1" } -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict( # (1)! - env_file=".env", - env_prefix="APP_", - case_sensitive=False, - ) - - database_url: str # (2)! - log_level: str = "INFO" -``` - -1. `model_config` replaces the old `class Config:` style in pydantic v2. - See the [pydantic-settings docs](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) - for every option. - -2. No default → required. Set it via the environment: - - ```bash - export APP_DATABASE_URL=postgresql://localhost/app - ``` - - Or in `.env`: - - ```dotenv - APP_DATABASE_URL=postgresql://localhost/app - ``` - ---- - -## 6. Diff highlighting - -```diff title="patch.diff" -- resource "aws_s3_bucket" "logs" { -- bucket = "${var.project}-logs" -- } -+ resource "aws_s3_bucket" "logs" { -+ bucket = "${var.project}-${var.environment}-logs" -+ force_destroy = var.environment != "prod" -+ } -``` - -````markdown -```diff title="patch.diff" -- old line -+ new line -``` -```` - ---- - -## 7. Tabbed alternatives — same problem, different stacks - -Powered by `pymdownx.tabbed`. Great for "do X in Python / Go / Node" pages. - -=== "Python" - - ```python title="logger.py" - import logging - import structlog - - structlog.configure( - processors=[ - structlog.processors.add_log_level, - structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.JSONRenderer(), - ], - ) - log = structlog.get_logger() - log.info("user.login", user_id=42) - ``` - -=== "Go" - - ```go title="logger.go" - import ( - "log/slog" - "os" - ) - - var log = slog.New(slog.NewJSONHandler(os.Stdout, nil)) - - func main() { - log.Info("user.login", "user_id", 42) - } - ``` - -=== "Node" - - ```ts title="logger.ts" - import pino from "pino"; - - const log = pino(); - log.info({ user_id: 42 }, "user.login"); - ``` - -````markdown -=== "Python" - - ```python - … - ``` - -=== "Go" - - ```go - … - ``` -```` - ---- - -## 8. Inline code & keyboard shortcuts - -Inline code: `terraform apply -auto-approve` is `monospace inline`. - -Keyboard chords via `pymdownx.keys`: press ++ctrl+c++ to copy, ++cmd+shift+p++ -on macOS for the command palette. - ---- - -## 9. Admonitions / callouts - -These pair well with code blocks for context: - -!!! tip "Use `can()` for validations" - Wrap any function that might raise with `can()` so a malformed input - becomes a clean validation error instead of a stack trace. - -!!! warning "Don't commit `*.tfvars`" - They almost always contain environment-specific secrets or account IDs. - -!!! danger "`terraform destroy` in prod" - No undo. Always run `terraform plan -destroy` first and review the output. - -??? note "Collapsible — click me" - Use `???` instead of `!!!` to make a collapsible block. Useful for long - appendices that aren't needed by default. - - ```hcl - # any markdown / code works inside - ``` - ---- - -## 10. Combining everything - -``` { .hcl .annotate title="modules/s3-bucket/main.tf" linenums="1" hl_lines="9-12" } -resource "aws_s3_bucket" "this" { - bucket = local.bucket_name # (1)! - - tags = merge(var.tags, { - Name = local.bucket_name - }) -} - -resource "aws_s3_bucket_public_access_block" "this" { # (2)! - bucket = aws_s3_bucket.this.id - block_public_acls = true - block_public_policy = true - ignore_public_acls = true - restrict_public_buckets = true -} - -resource "aws_s3_bucket_server_side_encryption_configuration" "this" { # (3)! - bucket = aws_s3_bucket.this.id - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256" - kms_master_key_id = var.kms_key_arn - } - } -} -``` - -1. Compose the bucket name from `local`s rather than inlining the format - string everywhere. Keep the naming logic in one place. - -2. **Always** attach a public-access block. AWS makes this opt-in per bucket, - and the default leaves you exposed if a misconfigured policy slips - through. - -3. Server-side encryption is now on by default for new buckets, but pinning - the algorithm explicitly makes intent clear and lets you use a customer - KMS key when `var.kms_key_arn` is set. diff --git a/docs/github-actions/container-build.md b/docs/github-actions/container-build.md index 405339a..2401053 100644 --- a/docs/github-actions/container-build.md +++ b/docs/github-actions/container-build.md @@ -1,5 +1,6 @@ --- title: Container build & push +description: Build and push container images with cache, multi-arch, and SBOM via GitHub Actions. status: stub tags: - github-actions diff --git a/docs/github-actions/index.md b/docs/github-actions/index.md index 6486e26..82fbc5b 100644 --- a/docs/github-actions/index.md +++ b/docs/github-actions/index.md @@ -1,5 +1,6 @@ --- title: GitHub Actions +description: Reusable workflows, OIDC, container builds, and release automation for GitHub Actions. status: stub tags: - github-actions diff --git a/docs/github-actions/oidc-aws.md b/docs/github-actions/oidc-aws.md index 56bef88..9b66178 100644 --- a/docs/github-actions/oidc-aws.md +++ b/docs/github-actions/oidc-aws.md @@ -1,5 +1,6 @@ --- title: OIDC → AWS (no static keys) +description: Authenticate GitHub Actions to AWS via OIDC — no long-lived access keys. status: stub tags: - github-actions diff --git a/docs/github-actions/oidc-gcp-azure.md b/docs/github-actions/oidc-gcp-azure.md index b94ee86..d05822c 100644 --- a/docs/github-actions/oidc-gcp-azure.md +++ b/docs/github-actions/oidc-gcp-azure.md @@ -1,5 +1,6 @@ --- title: OIDC → GCP / Azure +description: Authenticate GitHub Actions to GCP and Azure via OIDC / Workload Identity Federation. status: stub tags: - github-actions diff --git a/docs/github-actions/path-filtered-matrix.md b/docs/github-actions/path-filtered-matrix.md index 8ce2e8c..1e812b6 100644 --- a/docs/github-actions/path-filtered-matrix.md +++ b/docs/github-actions/path-filtered-matrix.md @@ -1,5 +1,6 @@ --- title: Path-filtered matrix builds +description: Build a dynamic matrix of jobs from changed paths using dorny/paths-filter. status: stub tags: - github-actions diff --git a/docs/github-actions/release.md b/docs/github-actions/release.md index 8dfc691..bac4713 100644 --- a/docs/github-actions/release.md +++ b/docs/github-actions/release.md @@ -1,5 +1,6 @@ --- title: Release automation +description: Automated releases with release-please, changelogs, and tag-driven publishes. status: stub tags: - github-actions diff --git a/docs/github-actions/reusable-workflows.md b/docs/github-actions/reusable-workflows.md index 384b391..5b7b38b 100644 --- a/docs/github-actions/reusable-workflows.md +++ b/docs/github-actions/reusable-workflows.md @@ -1,5 +1,6 @@ --- title: Reusable workflows +description: Reusable workflow patterns — inputs, secrets, permissions, and composite actions. status: stub tags: - github-actions diff --git a/docs/hygiene/editorconfig.md b/docs/hygiene/editorconfig.md index b0c7040..2ff362a 100644 --- a/docs/hygiene/editorconfig.md +++ b/docs/hygiene/editorconfig.md @@ -1,5 +1,6 @@ --- title: .editorconfig +description: Sensible .editorconfig defaults for consistent whitespace across editors. status: stub tags: - hygiene diff --git a/docs/hygiene/gitignore.md b/docs/hygiene/gitignore.md index 54c7a03..667f5e4 100644 --- a/docs/hygiene/gitignore.md +++ b/docs/hygiene/gitignore.md @@ -1,5 +1,6 @@ --- title: .gitignore by stack +description: Stack-specific .gitignore starters for Python, Node, Go, Terraform, and more. status: stub tags: - hygiene diff --git a/docs/hygiene/index.md b/docs/hygiene/index.md index 4b35dba..24ecd71 100644 --- a/docs/hygiene/index.md +++ b/docs/hygiene/index.md @@ -1,5 +1,6 @@ --- title: Repo hygiene +description: Repo hygiene snippets — .gitignore, .editorconfig, pre-commit, and Makefiles. status: stub tags: - hygiene diff --git a/docs/hygiene/makefile.md b/docs/hygiene/makefile.md index d7a9e8a..1a16901 100644 --- a/docs/hygiene/makefile.md +++ b/docs/hygiene/makefile.md @@ -1,5 +1,6 @@ --- title: Makefile patterns +description: Makefile patterns — self-documenting help target, .PHONY hygiene, and uv integration. status: stub tags: - hygiene diff --git a/docs/hygiene/pre-commit.md b/docs/hygiene/pre-commit.md index ecf3b10..dfa5208 100644 --- a/docs/hygiene/pre-commit.md +++ b/docs/hygiene/pre-commit.md @@ -1,18 +1,185 @@ --- title: pre-commit configs -status: stub +description: Starter .pre-commit-config.yaml with general, language-specific, and project-local hooks. tags: - hygiene + - pre-commit --- # pre-commit configs -!!! note "Stub page" - Starter .pre-commit-config.yaml by stack. +[pre-commit](https://pre-commit.com/) runs hooks on staged files before each +commit. Pin every repo by `rev:` so hook versions are reproducible. -## Planned content +```bash +uv tool install pre-commit # or: pipx install pre-commit +pre-commit install # install the git hook +pre-commit install --hook-type commit-msg # for commit-msg hooks +pre-commit run --all-files # run against the whole repo +pre-commit autoupdate # bump rev pins to latest tags +``` -- Python: ruff, mypy, codespell -- Terraform: terraform_fmt, tflint, terraform-docs -- General: trailing-whitespace, EOF, large-files, yaml/toml/json -- Local hooks for project-specific checks +## General hooks + +The `pre-commit-hooks` repo gives you the boring, universal stuff. + +```yaml +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=500] + - id: mixed-line-ending + args: [--fix=lf] + - id: check-yaml + args: [--unsafe] # allow !!python/name: tags (MkDocs Material, etc.) + - id: check-toml + - id: check-json +``` + +## Spelling + +```yaml + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + additional_dependencies: ["tomli"] +``` + +Configure in `pyproject.toml`: + +```toml +[tool.codespell] +skip = "*.lock,./site,./.venv" +ignore-words-list = "te,nd" +``` + +## Conventional commits + +Enforce [Conventional Commits](https://www.conventionalcommits.org/) on the +commit message itself. + +```yaml + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.6.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [feat, fix, chore, docs, refactor, test, ci, build, perf, style, ops] +``` + +## Snippet sync + +[`pre-commit-snippets`](https://github.com/RemoteRabbit/pre-commit-snippets) +keeps shared markdown blocks in sync from a central snippet repo. Wrap a region +in your docs with `` / `` +markers and the hook replaces the contents with `name.md` from the configured +snippet repo on every commit. + +```yaml + - repo: https://github.com/RemoteRabbit/pre-commit-snippets + rev: v1.0.4 + hooks: + - id: snippet-sync +``` + +Then add `.pre-commit-snippets-config.yaml` at the repo root: + +```yaml +snippet_repo: https://github.com/your-org/snippets.git +snippet_branch: main +snippet_subdir: snippets +snippet_ext: .md +cache_path: .snippet-hashes.json +target_files: + - README.md + - docs/CONTRIBUTING.md +``` + +In any `target_files`: + +```markdown +# My Project + + +This block is replaced with the contents of `license-notice.md` from the snippet repo. + +``` + +!!! tip "Why use it" + Useful when you have boilerplate (license blurbs, contributing guides, + security policy) that should be identical across many repos. The hook + auto-stages updated files, so drift is caught at commit time. + +## Python (ruff + mypy) + +```yaml + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + additional_dependencies: ["types-requests"] +``` + +## Terraform / OpenTofu + +```yaml + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.96.1 + hooks: + - id: terraform_fmt + - id: terraform_validate + - id: terraform_tflint + - id: terraform_docs + args: + - --hook-config=--path-to-file=README.md + - --hook-config=--add-to-existing-file=true + - --hook-config=--create-file-if-not-exist=true +``` + +## Local hooks + +For project-specific checks that don't warrant their own repo: + +```yaml + - repo: local + hooks: + - id: zensical-build + name: zensical build + entry: uv run zensical build + language: system + pass_filenames: false + files: ^(docs/|zensical\.toml|pyproject\.toml) + + - id: lychee + name: lychee link check + entry: lychee --config lychee.toml --cache --max-cache-age 7d --no-progress + language: system + pass_filenames: true + files: \.md$ +``` + +!!! warning "`language: system` requires the binary on PATH" + `system` hooks won't be installed for you — make sure `uv`, `lychee`, etc. + are available in CI and locally, or use `language: python` / + `language: docker` / `language: golang` to let pre-commit manage them. + +## References + +- [pre-commit homepage](https://pre-commit.com/) +- [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) +- [pre-commit-snippets](https://github.com/RemoteRabbit/pre-commit-snippets) +- [pre-commit-terraform](https://github.com/antonbabenko/pre-commit-terraform) +- [Conventional Commits](https://www.conventionalcommits.org/) diff --git a/docs/index.md b/docs/index.md index 936b688..e4a5312 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,6 @@ --- title: boilplate +description: Copy-paste-ready boilerplate for Terraform, GitHub Actions, containers, Kubernetes, and more. hide: - navigation --- @@ -25,18 +26,10 @@ straight into a project. Everything here is designed to be: --- - Variables, modules, backends, providers, IAM patterns. + Variables, modules, backends, providers, IAM patterns, and Terragrunt. [:octicons-arrow-right-24: Browse](terraform/index.md) -- :material-layers-triple:{ .lg .middle } **Terragrunt** - - --- - - Modern `root.hcl` + units + explicit stacks. - - [:octicons-arrow-right-24: Browse](terragrunt/index.md) - - :material-github:{ .lg .middle } **GitHub Actions** --- diff --git a/docs/kubernetes/deployment-baseline.md b/docs/kubernetes/deployment-baseline.md index 67c4deb..2050427 100644 --- a/docs/kubernetes/deployment-baseline.md +++ b/docs/kubernetes/deployment-baseline.md @@ -1,5 +1,6 @@ --- title: Deployment + Service + Ingress baseline +description: Production-ready Deployment baseline — resources, probes, security context, and topology. status: stub tags: - kubernetes diff --git a/docs/kubernetes/helm-skeleton.md b/docs/kubernetes/helm-skeleton.md index 8885575..211961e 100644 --- a/docs/kubernetes/helm-skeleton.md +++ b/docs/kubernetes/helm-skeleton.md @@ -1,5 +1,6 @@ --- title: Helm chart skeleton +description: Opinionated Helm chart skeleton with values schema, helpers, and tests. status: stub tags: - kubernetes diff --git a/docs/kubernetes/index.md b/docs/kubernetes/index.md index 7a745a6..0977c10 100644 --- a/docs/kubernetes/index.md +++ b/docs/kubernetes/index.md @@ -1,5 +1,6 @@ --- title: Kubernetes +description: Deployment baselines, probes, scaling, RBAC, and Helm chart skeletons. status: stub tags: - kubernetes diff --git a/docs/kubernetes/probes.md b/docs/kubernetes/probes.md index ee52248..340acb2 100644 --- a/docs/kubernetes/probes.md +++ b/docs/kubernetes/probes.md @@ -1,5 +1,6 @@ --- title: Probes done right +description: Liveness, readiness, and startup probes done right — common mistakes and fixes. status: stub tags: - kubernetes diff --git a/docs/kubernetes/rbac.md b/docs/kubernetes/rbac.md index 8fd54f1..b7c7b74 100644 --- a/docs/kubernetes/rbac.md +++ b/docs/kubernetes/rbac.md @@ -1,5 +1,6 @@ --- title: RBAC patterns +description: Least-privilege ServiceAccount, Role, and ClusterRole patterns for workloads. status: stub tags: - kubernetes diff --git a/docs/kubernetes/scaling.md b/docs/kubernetes/scaling.md index 299b8d4..6bc8a1e 100644 --- a/docs/kubernetes/scaling.md +++ b/docs/kubernetes/scaling.md @@ -1,5 +1,6 @@ --- title: HPA / PDB / NetworkPolicy +description: HPA, PodDisruptionBudget, and NetworkPolicy snippets for autoscaling workloads. status: stub tags: - kubernetes diff --git a/docs/observability/index.md b/docs/observability/index.md index c1dd279..1e743f7 100644 --- a/docs/observability/index.md +++ b/docs/observability/index.md @@ -1,5 +1,6 @@ --- title: Observability +description: Structured logging, OpenTelemetry, and Prometheus snippets for service observability. status: stub tags: - observability diff --git a/docs/observability/opentelemetry.md b/docs/observability/opentelemetry.md index f7fd39b..c02a693 100644 --- a/docs/observability/opentelemetry.md +++ b/docs/observability/opentelemetry.md @@ -1,5 +1,6 @@ --- title: OpenTelemetry init +description: OpenTelemetry SDK setup for traces, metrics, and logs across Python, Go, and Node. status: stub tags: - observability diff --git a/docs/observability/prometheus.md b/docs/observability/prometheus.md index 1ffd1b4..704a582 100644 --- a/docs/observability/prometheus.md +++ b/docs/observability/prometheus.md @@ -1,5 +1,6 @@ --- title: Prometheus naming + RED/USE +description: Prometheus instrumentation following RED and USE methods, with sample alerts. status: stub tags: - observability diff --git a/docs/observability/structured-logging.md b/docs/observability/structured-logging.md index a99f175..85ffda7 100644 --- a/docs/observability/structured-logging.md +++ b/docs/observability/structured-logging.md @@ -1,5 +1,6 @@ --- title: Structured logging +description: Structured (JSON) logging configurations for Python, Go, and Node services. status: stub tags: - observability diff --git a/docs/terraform/aws/backends.md b/docs/terraform/aws/backends.md new file mode 100644 index 0000000..536cc28 --- /dev/null +++ b/docs/terraform/aws/backends.md @@ -0,0 +1,248 @@ +--- +title: Remote state backends +description: S3 remote state for Terraform / OpenTofu with native S3 locking, KMS encryption, versioning, and a bootstrap pattern for the chicken-and-egg state bucket. +tags: + - terraform + - aws +--- + +# Remote state backends + +A production-ready remote state setup on AWS with **S3 native locking** +(`use_lockfile`, Terraform 1.10+), **server-side encryption with KMS**, and +**bucket versioning** so you can recover from a corrupted or accidentally +truncated state file. + +!!! tip "Skip DynamoDB on new projects" + As of Terraform **1.10**, the S3 backend supports a native `.tflock` file + in the same bucket via `use_lockfile = true`. New projects no longer need a + DynamoDB lock table. The legacy option is still documented at the bottom + of this page for existing setups. + +--- + +## Backend block + +A complete `backend "s3"` block with native locking and KMS encryption: + +```hcl +terraform { + required_version = ">= 1.10.0" + + backend "s3" { + bucket = "acme-tfstate-prod-us-east-1" + key = "platform/network/terraform.tfstate" + region = "us-east-1" + + # Native S3 locking (Terraform 1.10+). No DynamoDB table required. + use_lockfile = true + + # SSE-KMS with a customer-managed key. + encrypt = true + kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + # Optional: assume a deploy role from CI/CD. + assume_role = { + role_arn = "arn:aws:iam::111122223333:role/terraform-deploy" + session_name = "terraform" + } + } +} +``` + +!!! note "Pick a deterministic state key" + The `key` is the path of the state file inside the bucket. Use a stable + layout like `///terraform.tfstate` so renaming a + workspace never silently creates a fresh state file. + +--- + +## Partial configuration (recommended) + +Hard-coding the bucket name, region, and key in `backend "s3"` makes a module +hard to reuse across environments. Leave the block empty and pass the values +at `init` time with `-backend-config`: + +```hcl +terraform { + required_version = ">= 1.10.0" + backend "s3" {} +} +``` + +Then in CI / the repo root, per environment: + +```bash +terraform init \ + -backend-config="bucket=acme-tfstate-prod-us-east-1" \ + -backend-config="key=platform/network/terraform.tfstate" \ + -backend-config="region=us-east-1" \ + -backend-config="kms_key_id=arn:aws:kms:us-east-1:111122223333:key/aaaa..." \ + -backend-config="use_lockfile=true" \ + -backend-config="encrypt=true" +``` + +Or with a per-env file: + +```bash +terraform init -backend-config=envs/prod/backend.hcl +``` + +```hcl +# envs/prod/backend.hcl +bucket = "acme-tfstate-prod-us-east-1" +key = "platform/network/terraform.tfstate" +region = "us-east-1" +kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/aaaa..." +use_lockfile = true +encrypt = true +``` + +--- + +## Bootstrapping the state bucket (chicken-and-egg) + +The state bucket itself can't live in the state file it stores. The +conventional fix is a small **bootstrap module** that: + +1. Runs once with a *local* backend. +2. Creates the bucket, KMS key, and (optionally) the legacy DynamoDB table. +3. Is then re-initialised with the new S3 backend, so it manages itself going + forward. + +```hcl +# bootstrap/main.tf +terraform { + required_version = ">= 1.10.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.60" + } + } +} + +provider "aws" { + region = var.region +} + +resource "aws_kms_key" "tfstate" { + description = "Encrypts Terraform state in S3" + enable_key_rotation = true + deletion_window_in_days = 30 +} + +resource "aws_kms_alias" "tfstate" { + name = "alias/tfstate" + target_key_id = aws_kms_key.tfstate.key_id +} + +resource "aws_s3_bucket" "tfstate" { + bucket = var.bucket_name + + # Belt and braces — never let someone delete this by accident. + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_versioning" "tfstate" { + bucket = aws_s3_bucket.tfstate.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" { + bucket = aws_s3_bucket.tfstate.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.tfstate.arn + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "tfstate" { + bucket = aws_s3_bucket.tfstate.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} +``` + +After the first `terraform apply` with a local backend, add the +`backend "s3" {}` block, run: + +```bash +terraform init -migrate-state +``` + +…and Terraform will copy the local state into the bucket it just created. If +the bootstrap module needs to manage *its own* state going forward, also +import the bucket and KMS key into the new state though many teams treat +the bootstrap state as a one-shot artifact and check it into a private +repository instead. + +!!! warning "`prevent_destroy` is mandatory here" + Losing the state bucket means rebuilding every state file from scratch. + Combine `prevent_destroy = true` with bucket versioning and an MFA-delete + policy in production. + +--- + +## Legacy: S3 + DynamoDB locking + +If you're on Terraform < 1.10, or your org still mandates a DynamoDB lock +table, the classic configuration looks like this: + +```hcl +terraform { + backend "s3" { + bucket = "acme-tfstate-prod-us-east-1" + key = "platform/network/terraform.tfstate" + region = "us-east-1" + encrypt = true + kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/aaaa..." + dynamodb_table = "terraform-locks" + } +} +``` + +The lock table needs a single string hash key named `LockID`: + +```hcl +resource "aws_dynamodb_table" "tflocks" { + name = "terraform-locks" + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } + + server_side_encryption { + enabled = true + } +} +``` + +!!! tip "Migrating off DynamoDB" + On Terraform 1.10+ you can set both `use_lockfile = true` and + `dynamodb_table = "..."` during a transition window, then drop the + DynamoDB table once every workspace has been re-initialised. + +--- + +## References + +- [Terraform: S3 backend](https://developer.hashicorp.com/terraform/language/backend/s3) +- [Terraform 1.10 release notes — S3 native locking](https://github.com/hashicorp/terraform/releases/tag/v1.10.0) +- [OpenTofu: S3 backend](https://opentofu.org/docs/language/settings/backends/s3/) +- [AWS: Protecting data with server-side encryption (SSE-KMS)](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html) +- [AWS: Using versioning in S3 buckets](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) diff --git a/docs/terraform/aws/iam-policies.md b/docs/terraform/aws/iam-policies.md new file mode 100644 index 0000000..d9a9803 --- /dev/null +++ b/docs/terraform/aws/iam-policies.md @@ -0,0 +1,365 @@ +--- +title: IAM policy patterns +description: Least-privilege IAM trust and resource policy snippets — GitHub Actions OIDC, cross-account assume-role with ExternalId, S3 TLS-only and encryption-required bucket policies, and a separated KMS key policy. +tags: + - terraform + - aws +--- + +# IAM policy patterns + +Copy-pastable least-privilege policies for the things you wire up on every +project: CI/CD federation, cross-account access, locked-down S3 buckets, and +KMS keys with a clean Admin / Use / Grant split. + +!!! tip "Prefer `aws_iam_policy_document`" + Generating JSON via `data "aws_iam_policy_document"` keeps interpolation + safe (no string-quoting bugs), surfaces typos at `plan` time, and lets you + reuse statement blocks. Hand-written JSON is fine for small static + documents. + +--- + +## GitHub Actions OIDC trust + +Federate GitHub Actions into AWS without long-lived access keys. The trust +policy below scopes the role to a specific repository, branch (`main`), and +deployment environment (`prod`). + +```hcl +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_actions_trust" { + statement { + sid = "GitHubActionsOIDC" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + # Repo + ref + environment scoping. Every condition narrows the trust. + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = [ + "repo:acme-co/platform:ref:refs/heads/main", + "repo:acme-co/platform:environment:prod", + ] + } + } +} + +resource "aws_iam_role" "github_actions_deploy" { + name = "github-actions-deploy" + assume_role_policy = data.aws_iam_policy_document.github_actions_trust.json +} +``` + +!!! warning "Always pin `sub`, never just `repo:*`" + A trust policy that only checks `token.actions.githubusercontent.com:aud` + grants every GitHub Actions workflow on the planet permission to assume + the role. The `sub` claim must be pinned to your repo plus a branch, tag, + or environment. + +You also need the OIDC provider itself once per account: + +```hcl +resource "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] + # GitHub publishes thumbprints — let AWS pick them up automatically since + # the 2023-07 change. An empty list works on current provider versions. + thumbprint_list = [] +} +``` + +--- + +## Cross-account `sts:AssumeRole` with `ExternalId` + +Classic third-party / cross-account access. The `ExternalId` defends against +the [confused-deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) +when the trusted account is shared. + +```hcl +variable "trusted_account_id" { + type = string + description = "12-digit AWS account ID allowed to assume this role." +} + +variable "external_id" { + type = string + description = "Shared secret presented by the trusted principal on AssumeRole." + sensitive = true +} + +data "aws_iam_policy_document" "cross_account_trust" { + statement { + sid = "CrossAccountAssumeRole" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.trusted_account_id}:root"] + } + + condition { + test = "StringEquals" + variable = "sts:ExternalId" + values = [var.external_id] + } + + # Optional: require MFA for human users assuming the role. + condition { + test = "Bool" + variable = "aws:MultiFactorAuthPresent" + values = ["true"] + } + } +} + +resource "aws_iam_role" "cross_account" { + name = "acme-readonly-from-partner" + assume_role_policy = data.aws_iam_policy_document.cross_account_trust.json +} +``` + +The rendered JSON looks like: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CrossAccountAssumeRole", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": { "AWS": "arn:aws:iam::222233334444:root" }, + "Condition": { + "StringEquals": { "sts:ExternalId": "REDACTED" }, + "Bool": { "aws:MultiFactorAuthPresent": "true" } + } + } + ] +} +``` + +--- + +## S3 bucket policy: TLS-only + require encrypted PUTs + +Two statements every S3 bucket should carry: deny any request that wasn't +made over HTTPS, and deny any `PutObject` that doesn't ask for server-side +encryption. + +```hcl +data "aws_iam_policy_document" "bucket_hardening" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + actions = ["s3:*"] + + resources = [ + aws_s3_bucket.this.arn, + "${aws_s3_bucket.this.arn}/*", + ] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } + + statement { + sid = "DenyUnencryptedPut" + effect = "Deny" + actions = ["s3:PutObject"] + resources = ["${aws_s3_bucket.this.arn}/*"] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "StringNotEquals" + variable = "s3:x-amz-server-side-encryption" + values = ["aws:kms", "AES256"] + } + } + + statement { + sid = "DenyMissingEncryptionHeader" + effect = "Deny" + actions = ["s3:PutObject"] + resources = ["${aws_s3_bucket.this.arn}/*"] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "Null" + variable = "s3:x-amz-server-side-encryption" + values = ["true"] + } + } +} + +resource "aws_s3_bucket_policy" "this" { + bucket = aws_s3_bucket.this.id + policy = data.aws_iam_policy_document.bucket_hardening.json +} +``` + +!!! note "Two statements for the encryption check" + `StringNotEquals` only fires when the header is *present and wrong*. To + also catch requests that omit the header entirely you need the second + `Null`-conditioned statement. + +--- + +## KMS key policy: Admin / Use / Grant separation + +A common mistake is granting `kms:*` to the root principal and calling it a +day. Splitting the policy into three roles: **administer**, **use**, and +**grant**. Makes audits actually possible. + +```hcl +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "kms_key" { + # 1) Root account retains break-glass control over the key. + statement { + sid = "EnableIAMUserPermissions" + effect = "Allow" + actions = ["kms:*"] + resources = ["*"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] + } + } + + # 2) Admins: rotate, schedule deletion, edit policy. NO data plane. + statement { + sid = "KeyAdministration" + effect = "Allow" + + actions = [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:TagResource", + "kms:UntagResource", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion", + ] + + resources = ["*"] + + principals { + type = "AWS" + identifiers = var.key_admin_role_arns + } + } + + # 3) Users: encrypt/decrypt data, but cannot change the key itself. + statement { + sid = "KeyUsage" + effect = "Allow" + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ] + + resources = ["*"] + + principals { + type = "AWS" + identifiers = var.key_user_role_arns + } + } + + # 4) Grants: AWS services (RDS, EBS, etc.) need to create grants on behalf + # of users. Scoped with ViaService so an Allow on kms:CreateGrant alone + # can't be used outside the integrated services. + statement { + sid = "AllowAttachmentOfPersistentResources" + effect = "Allow" + + actions = [ + "kms:CreateGrant", + "kms:ListGrants", + "kms:RevokeGrant", + ] + + resources = ["*"] + + principals { + type = "AWS" + identifiers = var.key_user_role_arns + } + + condition { + test = "Bool" + variable = "kms:GrantIsForAWSResource" + values = ["true"] + } + } +} + +resource "aws_kms_key" "this" { + description = "App data encryption key" + enable_key_rotation = true + deletion_window_in_days = 30 + policy = data.aws_iam_policy_document.kms_key.json +} +``` + +!!! warning "Don't drop the root statement" + AWS will let you save a key policy without the root principal — and then + nobody can edit it again. The "EnableIAMUserPermissions" statement is + your one and only break-glass. Keep it. + +--- + +## References + +- [AWS: Configuring OIDC for GitHub Actions](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) +- [AWS: The confused deputy problem and ExternalId](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) +- [AWS: Bucket policy examples — require HTTPS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies.html#example-bucket-policies-secure-transport) +- [AWS: Protecting data with SSE-KMS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html) +- [AWS: Key policies in AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html) +- [Terraform: `aws_iam_policy_document` data source](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) diff --git a/docs/terraform/aws/index.md b/docs/terraform/aws/index.md new file mode 100644 index 0000000..43ca60d --- /dev/null +++ b/docs/terraform/aws/index.md @@ -0,0 +1,44 @@ +--- +title: AWS +description: AWS-specific Terraform snippets — variables, modules, backends, providers, IAM. +tags: + - terraform + - aws +--- + +# AWS + +
+ +- :material-variable:{ .lg .middle } **[Common variables](variables.md)** + + --- + + Typed, validated `variable` blocks: environment, region, tags, CIDRs, + instance type, FQDN, optionals, objects, secrets. + +- :material-folder-multiple:{ .lg .middle } **[Module skeleton](module-skeleton.md)** + + --- + + Opinionated layout for a reusable module. + +- :material-database-export:{ .lg .middle } **[Backends](backends.md)** + + --- + + Remote state backends with locking and encryption. + +- :material-cog:{ .lg .middle } **[Provider configuration](providers.md)** + + --- + + AWS provider defaults: `default_tags`, retries, assume-role, multi-region aliases. + +- :material-shield-key:{ .lg .middle } **[IAM policy patterns](iam-policies.md)** + + --- + + Least-privilege snippets you copy more than you'd like to admit. + +
diff --git a/docs/terraform/aws/module-skeleton.md b/docs/terraform/aws/module-skeleton.md new file mode 100644 index 0000000..c3b90d7 --- /dev/null +++ b/docs/terraform/aws/module-skeleton.md @@ -0,0 +1,312 @@ +--- +title: Module skeleton +description: Opinionated layout for a reusable AWS Terraform / OpenTofu module — file structure, version pinning, default_tags, terraform-docs, native tftest, and pre-commit-terraform. +tags: + - terraform + - aws +--- + +# Module skeleton + +A predictable layout for a reusable AWS module. Drop these files into a new +repo and you have a module that lints, formats, generates docs, and self-tests +out of the box. + +!!! tip "One module, one job" + A module is a unit of *reuse*, not a unit of *deployment*. Keep modules + focused (one VPC, one bucket-with-policy, one ALB), and let the consuming + root configuration glue them together. + +--- + +## Directory layout + +```text +terraform-aws-/ +├── README.md # Generated header + manual content + terraform-docs block +├── main.tf # Resources +├── variables.tf # Inputs (with descriptions, types, validation) +├── outputs.tf # Outputs (with descriptions) +├── locals.tf # Computed values, naming, tag merging +├── versions.tf # required_version + required_providers +├── examples/ +│ └── basic/ +│ ├── main.tf # Smallest working invocation +│ ├── variables.tf +│ ├── outputs.tf +│ └── README.md +├── tests/ +│ └── basic.tftest.hcl # Native `terraform test` cases +├── .terraform-docs.yml # terraform-docs config +├── .tflint.hcl # tflint ruleset +└── .pre-commit-config.yaml +``` + +--- + +## `versions.tf` + +Always pin the Terraform CLI floor and every provider you use. Use the +pessimistic constraint (`~>`) so consumers stay on a known-good major. + +```hcl +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.60, < 6.0" + } + + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} +``` + +!!! note "Modules don't configure providers" + A reusable module declares the providers it *requires* but does not + instantiate them. The root module owns `provider "aws" { ... }` so the + same module can be used in any region or account. + +--- + +## `default_tags` and the tag-merge pattern + +Let consumers set baseline tags on the provider, then merge module-specific +tags in `locals.tf` so they show up on every resource. + +```hcl +# In the consuming root module: +provider "aws" { + region = var.region + + default_tags { + tags = { + Owner = "platform" + Environment = var.environment + ManagedBy = "terraform" + } + } +} +``` + +```hcl +# locals.tf (inside the module) +locals { + name = "${var.project}-${var.environment}-${var.name}" + + tags = merge( + var.tags, + { + Name = local.name + Module = "terraform-aws-${var.name}" + }, + ) +} +``` + +`default_tags` from the provider apply to every taggable resource +automatically, so the module only needs to set tags it specifically owns +(like `Name`). + +--- + +## `examples/basic/main.tf` + +Every example is a real root module not a snippet. CI should +`terraform init && terraform validate` every example on every PR. + +```hcl +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.60, < 6.0" + } + } +} + +provider "aws" { + region = "us-east-1" + + default_tags { + tags = { + Owner = "example" + Environment = "dev" + ManagedBy = "terraform" + } + } +} + +module "bucket" { + source = "../.." + + project = "demo" + environment = "dev" + name = "logs" +} + +output "bucket_arn" { + value = module.bucket.bucket_arn +} +``` + +--- + +## Native testing with `tftest.hcl` + +Terraform 1.6 introduced a built-in test runner. Each `run` block is a plan +or apply against the module under test, with `assert` blocks that fail the +build if the contract drifts. + +```hcl +# tests/basic.tftest.hcl + +variables { + project = "demo" + environment = "dev" + name = "logs" +} + +run "plan_defaults" { + command = plan + + assert { + condition = output.bucket_name == "demo-dev-logs" + error_message = "Bucket name should follow --." + } +} + +run "apply_basic" { + command = apply + + module { + source = "./examples/basic" + } + + assert { + condition = can(regex("^arn:aws:s3:::", run.apply_basic.bucket_arn)) + error_message = "bucket_arn should be a real S3 ARN after apply." + } +} +``` + +Run locally: + +```bash +terraform init +terraform test +``` + +!!! tip "Mock the provider for fast tests" + Use `mock_provider "aws" {}` blocks in your `.tftest.hcl` to run pure + plan-time assertions without ever touching AWS. Reserve real `apply` runs + for an integration job that has credentials. + +--- + +## `README.md` with terraform-docs markers + +Generate the inputs / outputs / providers tables automatically so they never +go stale. + +````markdown +# terraform-aws-logs + +A bucket-with-policy module for application access logs. + +## Usage + +```hcl +module "logs" { + source = "git::https://github.com/acme-co/terraform-aws-logs.git?ref=v1.0.0" + + project = "acme" + environment = "prod" + name = "app-logs" +} +``` + + + +```` + +`.terraform-docs.yml`: + +```yaml +formatter: markdown table + +sections: + show: + - requirements + - providers + - inputs + - outputs + +output: + file: README.md + mode: inject + template: |- + + {{ .Content }} + + +sort: + enabled: true + by: required +``` + +Then `terraform-docs .` rewrites the markers in place. + +--- + +## `pre-commit-terraform` + +Add the [pre-commit-terraform](https://github.com/antonbabenko/pre-commit-terraform) +hooks so every commit gets formatted, validated, linted, and re-documented: + +```yaml +# .pre-commit-config.yaml +repos: + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.96.1 + hooks: + - id: terraform_fmt + - id: terraform_validate + - id: terraform_tflint + - id: terraform_docs + args: + - --hook-config=--path-to-file=README.md + - --hook-config=--add-to-existing-file=true +``` + +Install once per checkout: + +```bash +pre-commit install +pre-commit run --all-files +``` + +| Hook | What it does | +| -------------------- | -------------------------------------------------------------------- | +| `terraform_fmt` | `terraform fmt -recursive` canonical whitespace and key alignment. | +| `terraform_validate` | `terraform validate` against every module and example. | +| `terraform_tflint` | Provider-aware linter; catches deprecated arguments and bad AMI IDs. | +| `terraform_docs` | Regenerates the inputs / outputs table inside the README markers. | + +--- + +## References + +- [Terraform: Standard module structure](https://developer.hashicorp.com/terraform/language/modules/develop/structure) +- [Terraform: Tests (`terraform test`)](https://developer.hashicorp.com/terraform/language/tests) +- [AWS provider: `default_tags`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags) +- [terraform-docs](https://terraform-docs.io/) +- [tflint AWS ruleset](https://github.com/terraform-linters/tflint-ruleset-aws) +- [pre-commit-terraform](https://github.com/antonbabenko/pre-commit-terraform) diff --git a/docs/terraform/aws/providers.md b/docs/terraform/aws/providers.md new file mode 100644 index 0000000..12664a8 --- /dev/null +++ b/docs/terraform/aws/providers.md @@ -0,0 +1,296 @@ +--- +title: Provider configuration +description: Production defaults for the AWS Terraform / OpenTofu provider — version pinning, default_tags, assume_role, retries, multi-region aliases, and OIDC for GitHub Actions. +tags: + - terraform + - aws +--- + +# Provider configuration + +Sensible, production-grade defaults for the [AWS provider](https://registry.terraform.io/providers/hashicorp/aws/latest). +Drop these into your root module to get consistent tagging, friendly retries, +and federated credentials for CI/CD. + +!!! note "Root module owns the provider" + Reusable child modules declare provider *requirements* in `versions.tf` + but never instantiate `provider "aws" { ... }`. Provider configuration + lives in the root module so the same module can be reused across + accounts, regions, and partitions. + +--- + +## `required_providers` pinning + +Pin the AWS provider to a major version with the pessimistic constraint so +new minors flow in but breaking changes don't: + +```hcl +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.60, < 6.0" + } + } +} +``` + +!!! tip "Commit the lockfile" + `terraform init` writes `.terraform.lock.hcl` with checksums for every + provider. Always commit it, it's the only thing keeping you from a + silent supply-chain change. + +--- + +## `default_tags` + +Tags applied here are merged onto every taggable resource managed by this +provider instance. Stop sprinkling `tags = { ... }` across hundreds of +resources. + +```hcl +provider "aws" { + region = "us-east-1" + + default_tags { + tags = { + Owner = "platform" + Environment = var.environment + ManagedBy = "terraform" + CostCenter = var.cost_center + Repository = "github.com/acme-co/platform" + } + } +} +``` + +!!! warning "Tag drift on `aws_autoscaling_group`" + A handful of resources (notably `aws_autoscaling_group` and + `aws_eks_node_group`) propagate tags through a different mechanism and + can show up as drift on plan. Use `lifecycle { ignore_changes = [tag] }` + or set the tags explicitly on those resources. + +--- + +## `assume_role` with `external_id` + +Run plans as a deploy role instead of a long-lived user. The `external_id` +is required when the trust policy on the target role enforces it (recommended +for any cross-account scenario). + +```hcl +provider "aws" { + region = "us-east-1" + + assume_role { + role_arn = "arn:aws:iam::111122223333:role/terraform-deploy" + session_name = "terraform-${var.environment}" + external_id = var.external_id + + # Optional: cap the maximum permissions of the session, even if the + # underlying role has more. Useful for plan-only sessions. + # policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] + } +} +``` + +--- + +## Retry configuration + +The default retry behaviour is conservative. For long applies that touch +hundreds of resources, the **adaptive** retry mode backs off intelligently +when AWS starts throttling. + +```hcl +provider "aws" { + region = "us-east-1" + + max_retries = 10 + retry_mode = "adaptive" # one of: "standard" | "adaptive" | "legacy" +} +``` + +!!! tip "Pair with `-parallelism`" + Adaptive retries help, but if you're hitting throttling regularly, + consider lowering `terraform apply -parallelism=10` (default 10, but + sometimes worth dropping further on heavy IAM/EC2 modules). + +--- + +## Multi-region with provider aliases + +Some resources are global (CloudFront, ACM certs for CloudFront, IAM) and +must be created in `us-east-1`. Define an aliased provider and pass it +explicitly to those modules. + +```hcl +provider "aws" { + region = var.region # e.g. eu-west-1 +} + +provider "aws" { + alias = "us_east_1" + region = "us-east-1" +} + +# ACM cert for a CloudFront distribution must live in us-east-1. +module "cdn_cert" { + source = "./modules/acm-cert" + domain = "www.example.com" + + providers = { + aws = aws.us_east_1 + } +} +``` + +A child module that needs more than one provider declares the aliases it +expects in its own `versions.tf`: + +```hcl +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.60, < 6.0" + configuration_aliases = [aws.us_east_1] + } + } +} +``` + +--- + +## OIDC + GitHub Actions (`assume_role_with_web_identity`) + +Inside a GitHub Actions runner, exchange the workflow's OIDC token for AWS +credentials — no static keys, no `aws-actions/configure-aws-credentials` +env-var dance required by Terraform itself. + +```hcl +provider "aws" { + region = "us-east-1" + + assume_role_with_web_identity { + role_arn = "arn:aws:iam::111122223333:role/github-actions-deploy" + session_name = "gha-${var.github_run_id}" + web_identity_token_file = "/var/run/secrets/github/token" + } +} +``` + +In practice you'll keep using `aws-actions/configure-aws-credentials` to +fetch the token and write it to the env, then Terraform will pick it up +automatically because it reads the standard +`AWS_ROLE_ARN` / `AWS_WEB_IDENTITY_TOKEN_FILE` variables. The explicit block +above is useful when you need to assume *a different* role than the one the +action configured (for example, a per-env role). + +A typical workflow step: + +```yaml +# .github/workflows/deploy.yml +permissions: + id-token: write # required for OIDC + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy + aws-region: us-east-1 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.10.0 + + - run: terraform init + - run: terraform apply -auto-approve +``` + +See the matching trust policy in [IAM policy patterns → GitHub Actions OIDC](iam-policies.md#github-actions-oidc-trust). + +--- + +## Putting it all together + +A complete root-module provider block for a CI-driven, multi-region +deployment: + +```hcl +terraform { + required_version = ">= 1.10.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.60, < 6.0" + } + } +} + +provider "aws" { + region = var.region + max_retries = 10 + retry_mode = "adaptive" + + assume_role { + role_arn = var.deploy_role_arn + session_name = "terraform-${var.environment}" + external_id = var.external_id + } + + default_tags { + tags = { + Owner = "platform" + Environment = var.environment + ManagedBy = "terraform" + Repository = "github.com/acme-co/platform" + } + } +} + +provider "aws" { + alias = "us_east_1" + region = "us-east-1" + max_retries = 10 + retry_mode = "adaptive" + + assume_role { + role_arn = var.deploy_role_arn + session_name = "terraform-${var.environment}-use1" + external_id = var.external_id + } + + default_tags { + tags = { + Owner = "platform" + Environment = var.environment + ManagedBy = "terraform" + Repository = "github.com/acme-co/platform" + } + } +} +``` + +--- + +## References + +- [AWS provider documentation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) +- [AWS provider: `default_tags`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block) +- [AWS provider: `assume_role`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#assume_role-configuration-block) +- [AWS provider: `assume_role_with_web_identity`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#assume_role_with_web_identity-configuration-block) +- [AWS SDK retry behaviour (adaptive vs standard)](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) +- [Terraform: Provider configuration](https://developer.hashicorp.com/terraform/language/providers/configuration) +- [Terraform: Multiple provider configurations (aliases)](https://developer.hashicorp.com/terraform/language/providers/configuration#alias-multiple-provider-configurations) +- [`aws-actions/configure-aws-credentials`](https://github.com/aws-actions/configure-aws-credentials) diff --git a/docs/terraform/variables.md b/docs/terraform/aws/variables.md similarity index 92% rename from docs/terraform/variables.md rename to docs/terraform/aws/variables.md index 707e8c2..6a62785 100644 --- a/docs/terraform/variables.md +++ b/docs/terraform/aws/variables.md @@ -38,43 +38,37 @@ variable "aws_region" { ## Account ID -```hcl +``` hcl linenums="1" hl_lines="2 6" variable "aws_account_id" { - type = string + type = string # (1)! description = "The AWS account ID (12-digit number)." validation { - condition = can(regex("^[0-9]{12}$", var.aws_account_id)) + condition = can(regex("^[0-9]{12}$", var.aws_account_id)) # (2)! error_message = "The aws_account_id must be exactly 12 digits (0-9), with no spaces, dashes, or other characters." } } ``` -!!! note "Hard character limit" - AWS account IDs are always exactly 12 numeric digits — anchoring with ^...$ rejects accidental whitespace or extra characters. - -!!! note "Why not number type" - Keep them as string, not number — leading zeros are valid in account IDs and number would strip them. +1. Keep them as string, not number. Leading zeros are valid in account IDs and number would strip them. +2. AWS account IDs are always exactly 12 numeric digits anchoring with `^...$` rejects accidental whitespace or extra characters. ## List of Account IDs -```hcl +``` hcl linenums="1" hl_lines="2 6" variable "aws_account_ids" { - type = list(string) + type = list(string) # (1)! description = "A list of AWS account IDs (each a 12-digit number)." validation { - condition = alltrue([for id in var.aws_account_ids : can(regex("^[0-9]{12}$", id))]) + condition = alltrue([for id in var.aws_account_ids : can(regex("^[0-9]{12}$", id))]) # (2)! error_message = "Each entry in aws_account_ids must be exactly 12 digits (0-9)." } } ``` -!!! note "Hard character limit" - AWS account IDs are always exactly 12 numeric digits — anchoring with ^...$ rejects accidental whitespace or extra characters. - -!!! note "Why not number type" - Keep them as string, not number — leading zeros are valid in account IDs and number would strip them. +1. Keep them as string, not number. Leading zeros are valid in account IDs and number would strip them. +2. AWS account IDs are always exactly 12 numeric digits anchoring with `^...$` rejects accidental whitespace or extra characters. ## Environment @@ -106,7 +100,7 @@ variable "project" { ## Tags (with required keys) -Validates the map *and* enforces that specific keys are present — useful for +Validates the map *and* enforces that specific keys are present useful for governance / cost-allocation tags. ```hcl diff --git a/docs/terraform/azure/backends.md b/docs/terraform/azure/backends.md new file mode 100644 index 0000000..59745ef --- /dev/null +++ b/docs/terraform/azure/backends.md @@ -0,0 +1,190 @@ +--- +title: Remote state backends +description: Azure Storage backend configuration for Terraform / OpenTofu — blob lease locking, OIDC auth from CI, and the bootstrap pattern. +tags: + - terraform + - azure +--- + +# Remote state backends + +The **`azurerm`** backend stores Terraform state as a blob in an Azure Storage +container. State locking is automatic via blob leases (no separate lock table +like AWS DynamoDB), and encryption-at-rest is on by default. + +!!! tip "Use Azure AD auth, not storage keys" + Setting `use_azuread_auth = true` makes the backend authenticate with your + Azure AD identity (CLI or workload identity) instead of a shared storage + account key. Pair it with the **Storage Blob Data Contributor** role on + the container. + +## Minimal backend block + +```hcl +terraform { + required_version = ">= 1.3" + + backend "azurerm" { + resource_group_name = "tfstate-rg" + storage_account_name = "tfstateprod001" # globally unique, 3–24 lowercase + container_name = "tfstate" + key = "platform/network.tfstate" + + use_azuread_auth = true # AAD instead of access keys + subscription_id = "00000000-0000-0000-0000-000000000000" + tenant_id = "11111111-1111-1111-1111-111111111111" + } +} +``` + +!!! note "Locking" + The azurerm backend acquires a blob lease on the state object for the + duration of any operation that mutates state. If a previous run crashed, + break the lease with: + + ```bash + az storage blob lease break \ + --container-name tfstate \ + --blob-name platform/network.tfstate \ + --account-name tfstateprod001 \ + --auth-mode login + ``` + +## OIDC from GitHub Actions + +Federated credentials let CI authenticate without a long-lived secret. Set +`use_oidc = true` in the backend and export `ARM_USE_OIDC=true` in the +workflow; `azurerm` picks up `ACTIONS_ID_TOKEN_REQUEST_TOKEN` / +`ACTIONS_ID_TOKEN_REQUEST_URL` automatically. + +```hcl +terraform { + backend "azurerm" { + resource_group_name = "tfstate-rg" + storage_account_name = "tfstateprod001" + container_name = "tfstate" + key = "platform/network.tfstate" + + use_azuread_auth = true + use_oidc = true + subscription_id = "00000000-0000-0000-0000-000000000000" + tenant_id = "11111111-1111-1111-1111-111111111111" + client_id = "22222222-2222-2222-2222-222222222222" + } +} +``` + +```yaml +# .github/workflows/terraform.yml +permissions: + id-token: write # required for OIDC + contents: read + +jobs: + plan: + runs-on: ubuntu-latest + env: + ARM_USE_OIDC: "true" + ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - run: terraform init + - run: terraform plan +``` + +## RBAC required on the state container + +The identity running Terraform needs **data-plane** access on the blob +container, control-plane roles like *Contributor* are not enough when +`use_azuread_auth = true`. + +```hcl +resource "azurerm_role_assignment" "tfstate_writer" { + scope = azurerm_storage_container.tfstate.resource_manager_id + role_definition_name = "Storage Blob Data Contributor" + principal_id = data.azuread_service_principal.ci.object_id +} +``` + +| Role | Why | +| --------------------------------- | ----------------------------------- | +| Storage Blob Data Contributor | Read / write state blobs and leases | +| Storage Blob Data Reader | `terraform plan -refresh-only` only | + +## Bootstrap pattern + +You can't store the state of the storage account *in* the storage account +itself. Solve it with a one-shot bootstrap module that runs against **local** +state, then migrate it. + +```hcl +# bootstrap/main.tf — apply with local state, then `terraform state push`. +terraform { + required_version = ">= 1.3" + required_providers { + azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" } + } +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id +} + +resource "azurerm_resource_group" "tfstate" { + name = "tfstate-rg" + location = "eastus" +} + +resource "azurerm_storage_account" "tfstate" { + name = "tfstateprod001" + resource_group_name = azurerm_resource_group.tfstate.name + location = azurerm_resource_group.tfstate.location + account_tier = "Standard" + account_replication_type = "GRS" + account_kind = "StorageV2" + min_tls_version = "TLS1_2" + shared_access_key_enabled = false # force AAD auth + allow_nested_items_to_be_public = false + + blob_properties { + versioning_enabled = true # recover overwritten state + change_feed_enabled = true + delete_retention_policy { days = 30 } + container_delete_retention_policy { days = 30 } + } +} + +resource "azurerm_storage_container" "tfstate" { + name = "tfstate" + storage_account_id = azurerm_storage_account.tfstate.id + container_access_type = "private" +} +``` + +Workflow: + +1. `cd bootstrap && terraform init && terraform apply` (local state). +2. Add the `backend "azurerm"` block to the bootstrap module. +3. `terraform init -migrate-state`: Terraform copies the local state into + the new container. +4. Commit; never apply the bootstrap module from CI again. + +!!! warning "Protect the storage account" + Enable blob versioning, soft delete (≥ 30 days), and a resource lock + (`azurerm_management_lock` with `lock_level = "CanNotDelete"`). Losing + state for a production environment is *much* harder to recover from than + losing infrastructure. + +--- + +## References + +- [Terraform: azurerm backend](https://developer.hashicorp.com/terraform/language/backend/azurerm) +- [OpenTofu: azurerm backend](https://opentofu.org/docs/language/settings/backends/azurerm/) +- [Microsoft Learn: Store Terraform state in Azure Storage](https://learn.microsoft.com/en-us/azure/developer/terraform/store-state-in-azure-storage) +- [Microsoft Learn: GitHub Actions OIDC for Azure](https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure) +- [Azure Storage Blob Data Contributor role](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-blob-data-contributor) diff --git a/docs/terraform/azure/iam-policies.md b/docs/terraform/azure/iam-policies.md new file mode 100644 index 0000000..a76a8af --- /dev/null +++ b/docs/terraform/azure/iam-policies.md @@ -0,0 +1,233 @@ +--- +title: RBAC role assignments +description: Azure RBAC patterns for Terraform — built-in role assignments, custom role definitions, and federated CI identities (OIDC) for GitHub Actions. +tags: + - terraform + - azure +--- + +# RBAC role assignments + +Azure doesn't have AWS-style IAM policies. Permissions are expressed as +**role definitions** (a list of allowed/denied actions) bound to a +**principal** at a **scope** (management group, subscription, resource group, +or resource). The binding itself is an `azurerm_role_assignment`. + +!!! tip "Prefer built-in roles" + Microsoft maintains 200+ [built-in roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles). + Reach for a custom role only when no built-in role fits — custom roles + are scoped to one tenant and harder to audit. + +## Built-in role assignment + +Assign a built-in role at any scope. The trio (`scope`, +`role_definition_name`, `principal_id`) uniquely identifies the assignment. + +### Subscription scope + +```hcl +data "azurerm_subscription" "current" {} + +resource "azurerm_role_assignment" "platform_reader" { + scope = data.azurerm_subscription.current.id + role_definition_name = "Reader" + principal_id = azuread_group.platform_team.object_id +} +``` + +### Resource-group scope + +```hcl +resource "azurerm_role_assignment" "rg_contributor" { + scope = azurerm_resource_group.app.id + role_definition_name = "Contributor" + principal_id = azuread_service_principal.app_deployer.object_id +} +``` + +### Resource scope (managed identity → Key Vault) + +```hcl +resource "azurerm_user_assigned_identity" "app" { + name = "id-${var.project}-${var.environment}" + resource_group_name = azurerm_resource_group.app.name + location = azurerm_resource_group.app.location +} + +resource "azurerm_role_assignment" "app_kv_secrets" { + scope = azurerm_key_vault.app.id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_user_assigned_identity.app.principal_id +} +``` + +!!! note "principal_type" + For service principals or managed identities created in the same apply, + set `principal_type = "ServicePrincipal"` to skip the AAD propagation + poll and avoid `PrincipalNotFound` errors on first run. + +## Custom role definition + +When a built-in role is too broad, define a custom one. List only the +control-plane operations you need; use `data_actions` for data-plane +operations on storage / Key Vault / etc. + +```hcl +data "azurerm_subscription" "current" {} + +resource "azurerm_role_definition" "vm_operator" { + name = "VM Operator (start/stop)" + scope = data.azurerm_subscription.current.id + description = "Start, stop, restart, and view VMs. No create/delete/modify." + + permissions { + actions = [ + "Microsoft.Compute/virtualMachines/read", + "Microsoft.Compute/virtualMachines/start/action", + "Microsoft.Compute/virtualMachines/restart/action", + "Microsoft.Compute/virtualMachines/deallocate/action", + "Microsoft.Compute/virtualMachines/instanceView/read", + ] + not_actions = [] + } + + assignable_scopes = [ + data.azurerm_subscription.current.id, + ] +} + +resource "azurerm_role_assignment" "ops_team_vm_operator" { + scope = data.azurerm_subscription.current.id + role_definition_id = azurerm_role_definition.vm_operator.role_definition_resource_id + principal_id = azuread_group.ops_team.object_id +} +``` + +!!! warning "Use `role_definition_resource_id`" + `role_definition_id` on the role definition is a tenant-scoped GUID; + role *assignments* need the full resource ID (which embeds the scope). + Always reference `role_definition_resource_id` when wiring them up. + +## Federated CI: GitHub Actions → Azure (OIDC) + +Federated identity credentials let a GitHub Actions workflow assume an Entra +ID app registration without storing a client secret. The flow: + +1. Create an app registration + service principal. +2. Add a federated identity credential that trusts a specific + `repo:owner/name:ref:refs/heads/main` subject. +3. Assign the SP an Azure role at the right scope. +4. In CI, set `ARM_USE_OIDC=true` and `ARM_CLIENT_ID/_TENANT_ID/_SUBSCRIPTION_ID`. + +```hcl +terraform { + required_providers { + azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" } + azuread = { source = "hashicorp/azuread", version = "~> 3.0" } + } +} + +# 1. App + SP for the CI pipeline +resource "azuread_application" "ci" { + display_name = "github-${var.project}-${var.environment}" +} + +resource "azuread_service_principal" "ci" { + client_id = azuread_application.ci.client_id +} + +# 2. Trust GitHub's OIDC issuer for a specific repo + ref +resource "azuread_application_federated_identity_credential" "ci_main" { + application_id = azuread_application.ci.id + display_name = "github-main" + description = "Trust GitHub Actions on main branch" + audiences = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:my-org/${var.project}:ref:refs/heads/main" +} + +resource "azuread_application_federated_identity_credential" "ci_pr" { + application_id = azuread_application.ci.id + display_name = "github-pull-request" + audiences = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:my-org/${var.project}:pull_request" +} + +# 3. Grant the SP rights on the target subscription +resource "azurerm_role_assignment" "ci_contributor" { + scope = data.azurerm_subscription.current.id + role_definition_name = "Contributor" + principal_id = azuread_service_principal.ci.object_id + principal_type = "ServicePrincipal" +} + +# Plus blob data access for the tfstate container +resource "azurerm_role_assignment" "ci_tfstate" { + scope = azurerm_storage_container.tfstate.resource_manager_id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azuread_service_principal.ci.object_id + principal_type = "ServicePrincipal" +} + +output "github_actions_env" { + description = "Copy these into your GitHub repo as Variables (not Secrets)." + value = { + AZURE_CLIENT_ID = azuread_application.ci.client_id + AZURE_TENANT_ID = data.azurerm_client_config.current.tenant_id + AZURE_SUBSCRIPTION_ID = data.azurerm_subscription.current.subscription_id + } +} +``` + +!!! tip "Subject claim format" + `subject` is matched literally, there is no glob support. Add one + federated credential per ref pattern you want to trust: + + | Use case | Subject | + | ------------------- | ------------------------------------------------ | + | Branch | `repo:org/repo:ref:refs/heads/main` | + | Tag | `repo:org/repo:ref:refs/tags/v1.2.3` | + | Pull request | `repo:org/repo:pull_request` | + | GitHub environment | `repo:org/repo:environment:production` | + +## Service principal for non-OIDC CI + +Where federated identity isn't available (self-hosted runners on legacy +networks, third-party CI without OIDC), fall back to a client secret stored +in your secret manager. Rotate it on a schedule. + +```hcl +resource "azuread_application" "ci_legacy" { + display_name = "ci-${var.project}-legacy" +} + +resource "azuread_service_principal" "ci_legacy" { + client_id = azuread_application.ci_legacy.client_id +} + +resource "azuread_application_password" "ci_legacy" { + application_id = azuread_application.ci_legacy.id + display_name = "rotation-2026-q2" + end_date = "2026-08-01T00:00:00Z" +} + +resource "azurerm_role_assignment" "ci_legacy_contributor" { + scope = azurerm_resource_group.app.id + role_definition_name = "Contributor" + principal_id = azuread_service_principal.ci_legacy.object_id + principal_type = "ServicePrincipal" +} +``` + +--- + +## References + +- [azurerm_role_assignment](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) +- [azurerm_role_definition](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_definition) +- [azuread_application_federated_identity_credential](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/application_federated_identity_credential) +- [Microsoft Learn: Azure RBAC overview](https://learn.microsoft.com/en-us/azure/role-based-access-control/overview) +- [Microsoft Learn: Built-in roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles) +- [Microsoft Learn: Workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation) +- [GitHub Docs: Configuring OIDC in Azure](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-azure) diff --git a/docs/terraform/azure/index.md b/docs/terraform/azure/index.md new file mode 100644 index 0000000..be8cb2d --- /dev/null +++ b/docs/terraform/azure/index.md @@ -0,0 +1,44 @@ +--- +title: Azure +description: Azure-specific Terraform snippets — variables, modules, backends, providers, RBAC. +tags: + - terraform + - azure +--- + +# Azure + +
+ +- :material-variable:{ .lg .middle } **[Common variables](variables.md)** + + --- + + Typed, validated `variable` blocks: environment, region, tags, CIDRs, + instance type, FQDN, optionals, objects, secrets. + +- :material-folder-multiple:{ .lg .middle } **[Module skeleton](module-skeleton.md)** + + --- + + Opinionated layout for a reusable module. + +- :material-database-export:{ .lg .middle } **[Backends](backends.md)** + + --- + + Remote state backends with locking and encryption. + +- :material-cog:{ .lg .middle } **[Provider configuration](providers.md)** + + --- + + azurerm provider defaults: `features {}`, OIDC auth, multi-subscription aliases. + +- :material-shield-key:{ .lg .middle } **[RBAC role assignments](iam-policies.md)** + + --- + + Built-in and custom roles, scopes, and federated CI identities. + +
diff --git a/docs/terraform/azure/module-skeleton.md b/docs/terraform/azure/module-skeleton.md new file mode 100644 index 0000000..197033f --- /dev/null +++ b/docs/terraform/azure/module-skeleton.md @@ -0,0 +1,349 @@ +--- +title: Module skeleton +description: Opinionated layout for a reusable Azure Terraform / OpenTofu module — file structure, version pinning, naming conventions, terraform-docs, native tftest, and pre-commit-terraform. +tags: + - terraform + - azure +--- + +# Module skeleton + +A predictable layout for a reusable Azure module. Drop these files into a new +repo and you have a module that lints, formats, generates docs, and self-tests +out of the box. + +!!! tip "One module, one job" + A module is a unit of *reuse*, not a unit of *deployment*. Keep modules + focused (one VNet, one Storage account, one App Service plan), and let + the consuming root configuration glue them together. + +--- + +## Directory layout + +```text +terraform-azurerm-/ +├── README.md # Generated header + manual content + terraform-docs block +├── main.tf # Resources +├── variables.tf # Inputs (with descriptions, types, validation) +├── outputs.tf # Outputs (with descriptions) +├── locals.tf # Computed values, naming, tag merging +├── versions.tf # required_version + required_providers +├── examples/ +│ └── basic/ +│ ├── main.tf # Smallest working invocation +│ ├── variables.tf +│ ├── outputs.tf +│ └── README.md +├── tests/ +│ └── basic.tftest.hcl # Native `terraform test` cases +├── .terraform-docs.yml # terraform-docs config +├── .tflint.hcl # tflint ruleset (azurerm plugin) +└── .pre-commit-config.yaml +``` + +--- + +## `versions.tf` + +Always pin the Terraform CLI floor and every provider you use. Use the +pessimistic constraint (`~>`) so consumers stay on a known-good major. + +```hcl +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + + azuread = { + source = "hashicorp/azuread" + version = "~> 3.0" + } + + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} +``` + +!!! note "Modules don't configure providers" + A reusable module declares the providers it *requires* but does not + instantiate them. The root module owns + `provider "azurerm" { features {} ... }` so the same module can be used + in any subscription, tenant, or region. + +--- + +## Naming conventions and tag merging + +Azure has per-resource naming rules (length, allowed chars, sometimes +globally unique). Centralise the pattern in `locals.tf`: + +```hcl +# locals.tf +locals { + # --, e.g. acme-prod-app + name_prefix = "${var.project}-${var.environment}-${var.name}" + + # Resource-type abbreviations follow Microsoft's CAF guidance. + rg_name = "rg-${local.name_prefix}" + vnet_name = "vnet-${local.name_prefix}" + kv_name = substr(replace("kv${var.project}${var.environment}${var.name}", "-", ""), 0, 24) + + tags = merge( + var.tags, + { + Module = "terraform-azurerm-${var.name}" + Environment = var.environment + Project = var.project + }, + ) +} +``` + +```hcl +# main.tf +resource "azurerm_resource_group" "this" { + name = local.rg_name + location = var.location + tags = local.tags +} + +resource "azurerm_virtual_network" "this" { + name = local.vnet_name + resource_group_name = azurerm_resource_group.this.name + location = azurerm_resource_group.this.location + address_space = var.address_space + tags = local.tags +} +``` + +!!! warning "Tags don't auto-propagate on Azure" + Unlike AWS `default_tags`, the azurerm provider has no global tag + injection. Apply `local.tags` (or pass `var.tags` through) on **every** + taggable resource, or use Azure Policy `inheritTagsFromResourceGroup` + at the subscription scope. + +--- + +## `examples/basic/main.tf` + +Every example is a real root module, not a snippet. CI should +`terraform init && terraform validate` every example on every PR. + +```hcl +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id +} + +variable "subscription_id" { + type = string +} + +module "network" { + source = "../.." + + project = "demo" + environment = "dev" + name = "core" + location = "eastus" + + address_space = ["10.10.0.0/16"] + + tags = { + Owner = "platform" + Environment = "dev" + CostCenter = "0001" + } +} + +output "vnet_id" { + value = module.network.vnet_id +} +``` + +--- + +## Native testing with `tftest.hcl` + +Terraform 1.6 introduced a built-in test runner. Each `run` block is a plan +or apply against the module under test, with `assert` blocks that fail the +build if the contract drifts. + +```hcl +# tests/basic.tftest.hcl + +variables { + project = "demo" + environment = "dev" + name = "core" + location = "eastus" + address_space = ["10.10.0.0/16"] + tags = { + Owner = "platform" + Environment = "dev" + CostCenter = "0001" + } +} + +run "plan_defaults" { + command = plan + + assert { + condition = output.resource_group_name == "rg-demo-dev-core" + error_message = "Resource group should follow rg---." + } +} + +run "apply_basic" { + command = apply + + module { + source = "./examples/basic" + } + + assert { + condition = can(regex("^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/", run.apply_basic.vnet_id)) + error_message = "vnet_id should be a real Azure resource ID after apply." + } +} +``` + +Run locally: + +```bash +terraform init +terraform test +``` + +!!! tip "Mock the provider for fast tests" + Use `mock_provider "azurerm" {}` blocks in your `.tftest.hcl` to run + pure plan-time assertions without ever touching Azure. Reserve real + `apply` runs for an integration job that has credentials. + +--- + +## `README.md` with terraform-docs markers + +Generate the inputs / outputs / providers tables automatically so they never +go stale. + +````markdown +# terraform-azurerm-network + +A VNet + subnet module for the platform team. + +## Usage + +```hcl +module "network" { + source = "git::https://github.com/acme-co/terraform-azurerm-network.git?ref=v1.0.0" + + project = "acme" + environment = "prod" + name = "core" + location = "eastus" + + address_space = ["10.0.0.0/16"] +} +``` + + + +```` + +`.terraform-docs.yml`: + +```yaml +formatter: markdown table + +sections: + show: + - requirements + - providers + - inputs + - outputs + +output: + file: README.md + mode: inject + template: |- + + {{ .Content }} + + +sort: + enabled: true + by: required +``` + +Then `terraform-docs .` rewrites the markers in place. + +--- + +## `pre-commit-terraform` + +Add the [pre-commit-terraform](https://github.com/antonbabenko/pre-commit-terraform) +hooks so every commit gets formatted, validated, linted, and re-documented: + +```yaml +# .pre-commit-config.yaml +repos: + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.96.1 + hooks: + - id: terraform_fmt + - id: terraform_validate + - id: terraform_tflint + args: + - --args=--enable-plugin=azurerm + - id: terraform_docs + args: + - --hook-config=--path-to-file=README.md + - --hook-config=--add-to-existing-file=true +``` + +Install once per checkout: + +```bash +pre-commit install +pre-commit run --all-files +``` + +| Hook | What it does | +| -------------------- | -------------------------------------------------------------------- | +| `terraform_fmt` | `terraform fmt -recursive`, canonical whitespace and key alignment. | +| `terraform_validate` | `terraform validate` against every module and example. | +| `terraform_tflint` | Provider-aware linter; catches deprecated arguments and bad VM SKUs. | +| `terraform_docs` | Regenerates the inputs / outputs table inside the README markers. | + +--- + +## References + +- [Terraform: Standard module structure](https://developer.hashicorp.com/terraform/language/modules/develop/structure) +- [Terraform: Tests (`terraform test`)](https://developer.hashicorp.com/terraform/language/tests) +- [Microsoft Learn: Cloud Adoption Framework — naming conventions](https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-naming) +- [Microsoft Learn: Recommended abbreviations for Azure resource types](https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations) +- [azurerm provider reference](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) +- [terraform-docs](https://terraform-docs.io/) +- [tflint azurerm ruleset](https://github.com/terraform-linters/tflint-ruleset-azurerm) +- [pre-commit-terraform](https://github.com/antonbabenko/pre-commit-terraform) diff --git a/docs/terraform/azure/providers.md b/docs/terraform/azure/providers.md new file mode 100644 index 0000000..99d77c6 --- /dev/null +++ b/docs/terraform/azure/providers.md @@ -0,0 +1,247 @@ +--- +title: Provider configuration +description: Sensible azurerm + azuread provider defaults for Terraform / OpenTofu — features block, OIDC auth from CI, multi-subscription aliases, and ARM environment variables. +tags: + - terraform + - azure +--- + +# Provider configuration + +Drop-in `provider "azurerm"` blocks for the most common shapes: local +developer auth via Azure CLI, OIDC from CI, and multi-subscription aliases. +Targets **azurerm ≥ 4.0** and **azuread ≥ 3.0**. + +!!! note "`features {}` is mandatory" + The empty `features {}` block is required even when you don't override + anything — `terraform validate` will fail without it. Use it to control + destroy-time behaviours like Key Vault soft-delete recovery and resource + group force-delete. + +## `required_providers` + +```hcl +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + + azuread = { + source = "hashicorp/azuread" + version = "~> 3.0" + } + } +} +``` + +## Baseline `provider "azurerm"` block + +```hcl +provider "azurerm" { + subscription_id = var.subscription_id + tenant_id = var.tenant_id + + features { + key_vault { + purge_soft_delete_on_destroy = false + purge_soft_deleted_secrets_on_destroy = false + recover_soft_deleted_key_vaults = true + recover_soft_deleted_secrets = true + } + + resource_group { + prevent_deletion_if_contains_resources = true + } + + virtual_machine { + delete_os_disk_on_deletion = true + graceful_shutdown = false + skip_shutdown_and_force_delete = false + } + + log_analytics_workspace { + permanently_delete_on_destroy = false + } + + storage { + data_plane_available = true + } + } +} +``` + +!!! tip "Recover, don't purge" + Defaults set above prefer **recovery** over **purge** for Key Vault and + Log Analytics. Accidental destroys are recoverable; flip the flags only + in ephemeral environments where you want a clean tear-down. + +## `azuread` provider + +```hcl +provider "azuread" { + tenant_id = var.tenant_id +} +``` + +## Authentication + +The provider tries auth methods in this order: **environment variables → +managed identity → OIDC → CLI**. Pick exactly one method per environment so +behaviour stays predictable. + +### Local development — Azure CLI + +```bash +az login +az account set --subscription "" +``` + +```hcl +provider "azurerm" { + features {} + # subscription_id is read from the CLI context if omitted. + use_cli = true # default — shown for clarity +} +``` + +### CI — GitHub Actions OIDC + +```hcl +provider "azurerm" { + features {} + + use_oidc = true + subscription_id = var.subscription_id + tenant_id = var.tenant_id + client_id = var.client_id +} +``` + +```yaml +# .github/workflows/terraform.yml +permissions: + id-token: write + contents: read + +jobs: + apply: + runs-on: ubuntu-latest + env: + ARM_USE_OIDC: "true" + ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - run: terraform init + - run: terraform apply -auto-approve +``` + +### Self-hosted runners — Managed Identity + +```hcl +provider "azurerm" { + features {} + use_msi = true + subscription_id = var.subscription_id +} +``` + +### Service principal + secret (legacy) + +```bash +export ARM_CLIENT_ID="…" +export ARM_CLIENT_SECRET="…" # rotate via Key Vault, never commit +export ARM_SUBSCRIPTION_ID="…" +export ARM_TENANT_ID="…" +``` + +```hcl +provider "azurerm" { + features {} + # All four IDs are read from ARM_* env vars. +} +``` + +## Multi-subscription aliases + +Terraform supports multiple instances of the same provider via `alias`. Use +this to provision shared resources (DNS, Log Analytics) in one subscription +while everything else lives in a workload subscription. + +```hcl +provider "azurerm" { + alias = "workload" + features {} + subscription_id = var.workload_subscription_id +} + +provider "azurerm" { + alias = "shared" + features {} + subscription_id = var.shared_subscription_id +} + +# Workload-subscription resource (default-ish — pick the alias explicitly) +resource "azurerm_resource_group" "app" { + provider = azurerm.workload + name = "rg-app-prod" + location = "eastus" +} + +# Shared DNS zone lives in the platform subscription +resource "azurerm_dns_a_record" "api" { + provider = azurerm.shared + name = "api" + zone_name = "example.com" + resource_group_name = "rg-shared-dns" + ttl = 300 + records = [azurerm_public_ip.api.ip_address] +} +``` + +!!! warning "Pass aliases into modules explicitly" + A child module that uses an aliased provider must declare it in its own + `required_providers` and you must wire it up via `providers = { ... }` + on the `module` call: + + ```hcl + module "dns" { + source = "./modules/dns" + providers = { + azurerm = azurerm.shared + } + } + ``` + +## ARM environment variables (reference) + +| Variable | Purpose | +| -------------------------- | ------------------------------------------------------ | +| `ARM_SUBSCRIPTION_ID` | Default subscription (overrides CLI context). | +| `ARM_TENANT_ID` | Entra ID tenant. | +| `ARM_CLIENT_ID` | Service principal / app registration client ID. | +| `ARM_CLIENT_SECRET` | SP secret (avoid; prefer OIDC or MSI). | +| `ARM_USE_OIDC` | `true` to use OIDC token from CI. | +| `ARM_OIDC_TOKEN_FILE_PATH` | Path to a file containing the OIDC token (Kubernetes). | +| `ARM_USE_MSI` | `true` to use the runner's managed identity. | +| `ARM_USE_CLI` | `true` to use the Azure CLI session (default locally). | +| `ARM_ENVIRONMENT` | `public`, `usgovernment`, `china`, `german`. | + +--- + +## References + +- [azurerm provider reference](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) +- [azurerm `features {}` block](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/features-block) +- [azurerm authentication overview](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/azure_cli) +- [azurerm OIDC from GitHub Actions](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/service_principal_oidc) +- [azuread provider reference](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs) +- [Terraform: Provider configuration](https://developer.hashicorp.com/terraform/language/providers/configuration) +- [Terraform: Multiple provider instances (`alias`)](https://developer.hashicorp.com/terraform/language/providers/configuration#alias-multiple-provider-configurations) +- [Microsoft Learn: Authenticate Terraform to Azure](https://learn.microsoft.com/en-us/azure/developer/terraform/authenticate-to-azure) diff --git a/docs/terraform/azure/variables.md b/docs/terraform/azure/variables.md new file mode 100644 index 0000000..bba49e2 --- /dev/null +++ b/docs/terraform/azure/variables.md @@ -0,0 +1,325 @@ +--- +title: Common variables +description: Reusable, well-validated Terraform / OpenTofu variable blocks for Azure — location, subscription, tenant, resource groups, tags, CIDRs, VM sizes, and more. +tags: + - terraform + - azure +--- + +# Common variables + +Drop-in `variable` blocks for the **azurerm** provider with `type`, +`description`, sensible defaults, and `validation` rules. They work with +**Terraform ≥ 1.3**, **OpenTofu ≥ 1.6**, and **azurerm ≥ 4.0**. + +!!! tip "Conventions used on this page" + - All variables have a `description`. + - `error_message` is a complete sentence ending in a period. + - Defaults are only set when there's a safe, common choice. + - Optional values are typed `string` with `default = null` and + `nullable = true` rather than empty strings, so missing values are explicit. + +--- + +# Azure + +## Location + +```hcl +variable "location" { + description = "Azure region to deploy into (e.g. eastus, westeurope, northeurope)." + type = string + default = "eastus" + + validation { + condition = can(regex("^[a-z]+[a-z0-9]*[0-9]*$", var.location)) + error_message = "location must be a lowercase Azure region short name (e.g. eastus, westeurope, australiaeast)." + } +} +``` + +!!! tip "Discover valid regions" + Run `az account list-locations -o table --query "[].name"` to see the + regions enabled for your subscription. + +## Subscription ID + +```hcl linenums="1" hl_lines="2 6" +variable "subscription_id" { + type = string # (1)! + description = "The Azure subscription ID (UUID)." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) # (2)! + error_message = "subscription_id must be a valid GUID (8-4-4-4-12 hex digits)." + } +} +``` + +1. Always a string. Azure subscription IDs are GUIDs, not numbers. +2. Anchored `^...$` rejects accidental whitespace or partial matches. + +## Tenant ID + +```hcl +variable "tenant_id" { + description = "The Microsoft Entra ID (Azure AD) tenant ID (UUID)." + type = string + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.tenant_id)) + error_message = "tenant_id must be a valid GUID (8-4-4-4-12 hex digits)." + } +} +``` + +## Resource group name + +```hcl +variable "resource_group_name" { + description = "Name of the Azure Resource Group. 1–90 chars; letters, digits, underscores, parentheses, hyphens, periods. Cannot end with a period." + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9_().-]{1,89}[a-zA-Z0-9_()-]$", var.resource_group_name)) + error_message = "resource_group_name must be 1–90 characters of letters, digits, underscores, parentheses, hyphens, or periods, and must not end with a period." + } +} +``` + +## Environment + +```hcl +variable "environment" { + description = "Deployment environment. Used in resource names, tags, and conditional logic." + type = string + + validation { + condition = contains(["dev", "stg", "prod"], var.environment) + error_message = "environment must be one of: dev, stg, prod." + } +} +``` + +## Project / application name + +```hcl +variable "project" { + description = "Short project identifier used as a prefix for resource names. Lowercase letters, digits, and hyphens only; 2–24 characters." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]{1,23}$", var.project)) + error_message = "project must start with a lowercase letter and contain only lowercase letters, digits, or hyphens (2–24 chars)." + } +} +``` + +## Tags (with required keys) + +Validates the map *and* enforces that specific keys are present, useful for +governance / cost-allocation tags. Tags on Azure are case-sensitive, max 512 +chars per value (256 for storage), and limited to 50 per resource. + +```hcl +variable "tags" { + description = "Tags applied to every resource. Must include Owner, Environment, and CostCenter." + type = map(string) + default = {} + + validation { + condition = alltrue([ + for k in ["Owner", "Environment", "CostCenter"] : contains(keys(var.tags), k) + ]) + error_message = "tags must include the keys: Owner, Environment, CostCenter." + } + + validation { + condition = length(var.tags) <= 50 + error_message = "Azure resources accept at most 50 tags." + } + + validation { + condition = alltrue([for v in values(var.tags) : length(v) > 0 && length(v) <= 256]) + error_message = "Every tag value must be a non-empty string of at most 256 characters." + } +} +``` + +## Address space (VNet) + +```hcl +variable "address_space" { + description = "IPv4 CIDR blocks assigned to the virtual network. Each entry must be a valid CIDR." + type = list(string) + default = ["10.0.0.0/16"] + + validation { + condition = length(var.address_space) > 0 + error_message = "address_space must contain at least one CIDR block." + } + + validation { + condition = alltrue([for c in var.address_space : can(cidrnetmask(c))]) + error_message = "Every entry in address_space must be a valid IPv4 CIDR block (e.g. 10.0.0.0/16)." + } + + validation { + condition = alltrue([for c in var.address_space : tonumber(split("/", c)[1]) >= 8 && tonumber(split("/", c)[1]) <= 29]) + error_message = "Each address_space prefix length must be between /8 and /29." + } +} +``` + +## VM size + +```hcl +variable "vm_size" { + description = "Azure VM SKU, e.g. Standard_D2s_v5 or Standard_B2ms." + type = string + default = "Standard_D2s_v5" + + validation { + condition = can(regex("^(Standard|Basic)_[A-Z]+[0-9]+[a-z]*(_v[0-9]+)?$", var.vm_size)) + error_message = "vm_size must look like a valid Azure VM SKU (e.g. Standard_D2s_v5, Standard_B2ms)." + } +} +``` + +## Domain name + +```hcl +variable "domain_name" { + description = "Fully qualified domain name (e.g. api.example.com). Lowercase, no trailing dot." + type = string + + validation { + condition = can(regex("^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$", var.domain_name)) + error_message = "domain_name must be a lowercase FQDN such as api.example.com (no trailing dot)." + } +} +``` + +## Optional string (nullable) + +Prefer `null` over `""` so "unset" is explicit: + +```hcl +variable "key_vault_id" { + description = "Optional Key Vault resource ID for CMK encryption. When null, a platform-managed key is used." + type = string + default = null + nullable = true + + validation { + condition = var.key_vault_id == null || can(regex("^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/[^/]+/providers/Microsoft.KeyVault/vaults/[^/]+$", var.key_vault_id)) + error_message = "key_vault_id must be null or a valid Key Vault resource ID." + } +} +``` + +## Boolean feature flag + +```hcl +variable "enable_diagnostics" { + description = "Whether to send platform logs to Log Analytics. Disable in cost-sensitive environments." + type = bool + default = true +} +``` + +## Object with optional attributes (Log Analytics retention) + +Uses `optional()` from Terraform 1.3+ / OpenTofu so consumers only specify what +they care about: + +```hcl +variable "log_analytics" { + description = "Log Analytics workspace configuration. Any field not specified falls back to defaults." + type = object({ + enabled = optional(bool, true) + sku = optional(string, "PerGB2018") + retention_in_days = optional(number, 30) + workspace_name = optional(string) + }) + default = {} + + validation { + condition = var.log_analytics.retention_in_days >= 30 && var.log_analytics.retention_in_days <= 730 + error_message = "log_analytics.retention_in_days must be between 30 and 730 (Log Analytics workspace limits)." + } + + validation { + condition = contains(["Free", "PerNode", "Premium", "Standard", "Standalone", "Unlimited", "CapacityReservation", "PerGB2018"], var.log_analytics.sku) + error_message = "log_analytics.sku must be a valid Log Analytics SKU (PerGB2018 is recommended)." + } +} +``` + +## Map of subnets + +```hcl +variable "subnets" { + description = "Map of subnet name to its CIDR address prefixes." + type = map(object({ + address_prefixes = list(string) + })) + default = {} + + validation { + condition = alltrue([ + for s in values(var.subnets) : length(s.address_prefixes) > 0 + ]) + error_message = "Every subnets[*].address_prefixes must contain at least one CIDR block." + } + + validation { + condition = alltrue(flatten([ + for s in values(var.subnets) : [for c in s.address_prefixes : can(cidrnetmask(c))] + ])) + error_message = "Every entry in subnets[*].address_prefixes must be a valid IPv4 CIDR block." + } +} +``` + +## Secrets / sensitive values + +!!! warning "Never commit secret values" + Provide via `TF_VAR_*` env vars, Azure Key Vault references, or a + `.auto.tfvars` file that is `.gitignore`-d. The validation below only + enforces a minimum length and Azure SQL complexity rules. + +```hcl +variable "sql_admin_password" { + description = "Azure SQL administrator password. Provide via TF_VAR_sql_admin_password or Key Vault — do not commit." + type = string + sensitive = true + + validation { + condition = length(var.sql_admin_password) >= 16 + error_message = "sql_admin_password must be at least 16 characters." + } + + validation { + condition = ( + can(regex("[A-Z]", var.sql_admin_password)) && + can(regex("[a-z]", var.sql_admin_password)) && + can(regex("[0-9]", var.sql_admin_password)) && + can(regex("[^A-Za-z0-9]", var.sql_admin_password)) + ) + error_message = "sql_admin_password must contain uppercase, lowercase, digit, and non-alphanumeric characters (Azure SQL complexity rule)." + } +} +``` + +--- + +## References + +- [Terraform: Input Variables](https://developer.hashicorp.com/terraform/language/values/variables) +- [Terraform: Custom Validation Rules](https://developer.hashicorp.com/terraform/language/values/variables#custom-validation-rules) +- [OpenTofu: Variables](https://opentofu.org/docs/language/values/variables/) +- [Microsoft Learn: azurerm provider](https://learn.microsoft.com/en-us/azure/developer/terraform/overview) +- [azurerm provider reference](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) +- [Azure resource naming rules](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules) +- [Azure VM sizes](https://learn.microsoft.com/en-us/azure/virtual-machines/sizes) diff --git a/docs/terraform/backends.md b/docs/terraform/backends.md deleted file mode 100644 index 0f2a714..0000000 --- a/docs/terraform/backends.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Remote state backends -status: stub -tags: - - terraform - - aws ---- - -# Remote state backends - -!!! note "Stub page" - Drop-in backend configurations with locking, encryption, and least-privilege access. - -## Planned content - -- S3 + DynamoDB (legacy) and S3 native locking (`use_lockfile`, TF 1.10+) -- GCS backend with state versioning -- Azure Storage backend -- Backend bootstrap — chicken-and-egg pattern (one-shot module) diff --git a/docs/terraform/gcp/backends.md b/docs/terraform/gcp/backends.md new file mode 100644 index 0000000..af51a12 --- /dev/null +++ b/docs/terraform/gcp/backends.md @@ -0,0 +1,226 @@ +--- +title: Remote state backends +description: GCS backend configuration for Terraform/OpenTofu — versioning, encryption, automatic locking, bootstrap, and Workload Identity Federation auth from CI. +tags: + - terraform + - gcp +--- + +# Remote state backends + +The `gcs` backend stores Terraform state in a Google Cloud Storage bucket. +State **locking is automatic** — GCS uses object generations to coordinate +concurrent writers, so there is no DynamoDB-equivalent table to provision. + +!!! tip "Pick a single regional bucket per state" + Use a regional (not multi-region) bucket close to where you run plans, + enable **Object Versioning**, **Uniform bucket-level access**, and either + a Google-managed key or a CMEK. One bucket can hold many state files — + use `prefix` to namespace them. + +--- + +## Minimal `backend "gcs"` block + +```hcl +terraform { + required_version = ">= 1.3" + + backend "gcs" { + bucket = "acme-tfstate-prod" + prefix = "platform/network" + } +} +``` + +State will be stored as `gs://acme-tfstate-prod/platform/network/default.tfstate` +(per-workspace files live alongside it). + +## Customer-managed encryption (CMEK) + +```hcl +terraform { + backend "gcs" { + bucket = "acme-tfstate-prod" + prefix = "platform/network" + encryption_key = "projects/acme-sec/locations/us/keyRings/tfstate/cryptoKeys/state" + } +} +``` + +If `encryption_key` is omitted, GCS encrypts state with Google-managed keys. +Either is fine, pick CMEK only when policy demands it. + +## State versioning + +GCS *bucket* Object Versioning gives you point-in-time recovery for state. +Enable it on the bucket itself, not via the backend block: + +```hcl +resource "google_storage_bucket" "tfstate" { + name = "acme-tfstate-prod" + location = "US-CENTRAL1" + project = "acme-shared" + force_destroy = false + uniform_bucket_level_access = true + public_access_prevention = "enforced" + + versioning { + enabled = true + } + + lifecycle_rule { + condition { + num_newer_versions = 10 + } + action { + type = "Delete" + } + } +} +``` + +## Locking + +There is **nothing to configure**. The `gcs` backend uses GCS object +generation preconditions to acquire/release a `.tflock` object atomically. +Concurrent `terraform apply` runs will block with `Error acquiring the state +lock` until the holder finishes (or you `terraform force-unlock`). + +--- + +## Bootstrap pattern (chicken-and-egg) + +You can't store the state of the bucket *in* the bucket on first apply. +Standard pattern: + +1. **Apply once locally** with the default `local` backend to create the + state bucket (and KMS key, if any). +2. Add the `backend "gcs"` block. +3. Run `terraform init -migrate-state` to push `terraform.tfstate` into GCS. +4. Commit and delete the local `terraform.tfstate*` files. + +```hcl +# bootstrap/main.tf — run with local state, ONCE per org +terraform { + required_version = ">= 1.3" + required_providers { + google = { source = "hashicorp/google", version = "~> 6.0" } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +resource "google_storage_bucket" "tfstate" { + name = "${var.project_id}-tfstate" + location = upper(var.region) + force_destroy = false + uniform_bucket_level_access = true + public_access_prevention = "enforced" + + versioning { enabled = true } +} + +output "backend_hcl" { + value = <<-EOT + terraform { + backend "gcs" { + bucket = "${google_storage_bucket.tfstate.name}" + prefix = "REPLACE_ME" + } + } + EOT +} +``` + +Then in any downstream stack: + +```bash +terraform init -migrate-state +``` + +--- + +## Workload Identity Federation (GitHub Actions auth) + +Don't ship long-lived JSON service-account keys. Use **Workload Identity +Federation** so GitHub Actions exchanges its OIDC token for short-lived +Google credentials. + +```hcl +resource "google_iam_workload_identity_pool" "github" { + project = var.project_id + workload_identity_pool_id = "github-pool" + display_name = "GitHub Actions" +} + +resource "google_iam_workload_identity_pool_provider" "github" { + project = var.project_id + workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id + workload_identity_pool_provider_id = "github" + display_name = "GitHub OIDC" + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.repository" = "assertion.repository" + "attribute.ref" = "assertion.ref" + } + + # Hard scope tokens to your org/repo so a fork can't impersonate you. + attribute_condition = "assertion.repository_owner == 'acme-co'" + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + } +} + +resource "google_service_account" "tf_deployer" { + project = var.project_id + account_id = "tf-deployer" + display_name = "Terraform deployer (GitHub Actions)" +} + +# Allow only main-branch runs of acme-co/infra to impersonate the SA. +resource "google_service_account_iam_member" "tf_deployer_wif" { + service_account_id = google_service_account.tf_deployer.name + role = "roles/iam.workloadIdentityUser" + member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/acme-co/infra" +} +``` + +GitHub Actions workflow: + +```yaml +permissions: + id-token: write + contents: read + +jobs: + plan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github + service_account: tf-deployer@acme-platform-prod.iam.gserviceaccount.com + - uses: hashicorp/setup-terraform@v3 + - run: terraform init + - run: terraform plan +``` + +The deployer SA needs `roles/storage.objectAdmin` (or finer) on the state +bucket and whatever roles are required to manage your resources. + +--- + +## References + +- [Terraform: gcs backend](https://developer.hashicorp.com/terraform/language/backend/gcs) +- [GCS: Object Versioning](https://cloud.google.com/storage/docs/object-versioning) +- [GCS: Customer-managed encryption keys](https://cloud.google.com/storage/docs/encryption/customer-managed-keys) +- [GCP: Workload Identity Federation overview](https://cloud.google.com/iam/docs/workload-identity-federation) +- [google-github-actions/auth](https://github.com/google-github-actions/auth) diff --git a/docs/terraform/gcp/iam-policies.md b/docs/terraform/gcp/iam-policies.md new file mode 100644 index 0000000..addbf47 --- /dev/null +++ b/docs/terraform/gcp/iam-policies.md @@ -0,0 +1,233 @@ +--- +title: IAM bindings & custom roles +description: GCP IAM in Terraform — additive vs authoritative bindings, custom roles, Workload Identity Federation for GitHub Actions, and service-account impersonation. +tags: + - terraform + - gcp +--- + +# IAM bindings & custom roles + +GCP IAM in Terraform comes in **three flavours**, and picking the wrong one +will silently wipe other teams' access. Read this page before you reach for +`google_*_iam_policy`. + +--- + +## `_iam_member` vs `_iam_binding` vs `_iam_policy` + +| Resource family | Scope | Authoritative? | Safe default? | +| ---------------------- | ------------------------------------------- | ------------------ | ------------------------------------- | +| `google_*_iam_member` | Single (role, member) pair | No — additive | ✅ Yes | +| `google_*_iam_binding` | Whole role (all members for that one role) | Yes — for the role | ⚠️ Only if Terraform owns *that role* | +| `google_*_iam_policy` | The entire resource's IAM policy | Yes — total | ❌ Almost never | + +!!! warning "`google_project_iam_policy` is destructive" + `google_project_iam_policy` overwrites **every** binding on the project, + including the default `roles/owner` granted to the project creator and + any access set up by other tools. Use `google_project_iam_member` unless + you have a deliberate reason not to. + +### Additive (recommended default) + +```hcl +resource "google_project_iam_member" "deployer_run_admin" { + project = var.project_id + role = "roles/run.admin" + member = "serviceAccount:${google_service_account.tf_deployer.email}" +} +``` + +### Authoritative on a single role + +Use when Terraform is the source of truth for *who* holds a role: + +```hcl +resource "google_project_iam_binding" "owners" { + project = var.project_id + role = "roles/owner" + + members = [ + "group:platform-admins@acme.com", + ] +} +``` + +Anything else previously holding `roles/owner` (a person, an SA, a Google +group) will be removed on the next apply. + +### Authoritative on the whole project + +```hcl +# Don't do this unless you really mean it. +data "google_iam_policy" "project" { + binding { + role = "roles/owner" + members = ["group:platform-admins@acme.com"] + } + binding { + role = "roles/viewer" + members = ["group:engineers@acme.com"] + } +} + +resource "google_project_iam_policy" "project" { + project = var.project_id + policy_data = data.google_iam_policy.project.policy_data +} +``` + +If you forget to include a binding here, it disappears. + +!!! tip "Conditional bindings" + Use `condition { ... }` on `_iam_member` to scope grants by time, request + attribute, or resource name (CEL syntax). Great for "this SA can read + only buckets prefixed `staging-`". + +--- + +## Custom roles + +When the predefined roles are too broad, define a custom role with the exact +permissions you need: + +```hcl +resource "google_project_iam_custom_role" "state_reader" { + project = var.project_id + role_id = "tfstateReader" + title = "Terraform state reader" + description = "Read-only access to Terraform state objects in GCS." + stage = "GA" + + permissions = [ + "storage.buckets.get", + "storage.objects.get", + "storage.objects.list", + ] +} + +resource "google_project_iam_member" "ci_state_reader" { + project = var.project_id + role = google_project_iam_custom_role.state_reader.name + member = "serviceAccount:${google_service_account.ci_planner.email}" +} +``` + +Custom roles can also be defined at the organisation level +(`google_organization_iam_custom_role`) when the same role is reused across +many projects. + +--- + +## Workload Identity Federation for GitHub Actions + +Trade GitHub's OIDC token for a short-lived Google access token, no +service-account JSON keys. + +```hcl +resource "google_iam_workload_identity_pool" "github" { + project = var.project_id + workload_identity_pool_id = "github-pool" + display_name = "GitHub Actions" +} + +resource "google_iam_workload_identity_pool_provider" "github" { + project = var.project_id + workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id + workload_identity_pool_provider_id = "github" + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.repository" = "assertion.repository" + "attribute.repository_owner" = "assertion.repository_owner" + "attribute.ref" = "assertion.ref" + "attribute.environment" = "assertion.environment" + } + + # Reject tokens from forks or other orgs. + attribute_condition = "assertion.repository_owner == 'acme-co'" + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + } +} + +resource "google_service_account" "tf_deployer" { + project = var.project_id + account_id = "tf-deployer" + display_name = "Terraform deployer" +} + +# Only allow the acme-co/infra repo to impersonate this SA via WIF. +resource "google_service_account_iam_member" "tf_deployer_wif" { + service_account_id = google_service_account.tf_deployer.name + role = "roles/iam.workloadIdentityUser" + member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/acme-co/infra" +} +``` + +The `principalSet://...` member maps to the `attribute.*` you mapped above. +Common variants: + +| Scope | Member | +| ---------------------------------- | --------------------------------------------------------------------------------------------------- | +| Whole repo | `principalSet://iam.googleapis.com/POOL/attribute.repository/acme-co/infra` | +| One environment | `principalSet://iam.googleapis.com/POOL/attribute.environment/prod` | +| One ref (e.g. `refs/heads/main`) | `principalSet://iam.googleapis.com/POOL/attribute.ref/refs/heads/main` | + +--- + +## Service-account impersonation pattern + +Even with WIF, the cleanest pattern is: + +1. CI authenticates as a **bootstrap SA** (or directly via WIF) with no + resource permissions of its own. +2. CI then *impersonates* an **environment-specific deploy SA** that holds + the project-level roles. + +```hcl +resource "google_service_account" "deploy_prod" { + project = var.project_id + account_id = "tf-deploy-prod" + display_name = "Terraform deploy SA (prod)" +} + +# CI bootstrap SA can mint tokens for the prod deploy SA. +resource "google_service_account_iam_member" "ci_can_impersonate_prod" { + service_account_id = google_service_account.deploy_prod.name + role = "roles/iam.serviceAccountTokenCreator" + member = "serviceAccount:${google_service_account.tf_deployer.email}" +} +``` + +Then point the provider at the impersonated SA: + +```hcl +provider "google" { + project = var.project_id + region = var.region + impersonate_service_account = google_service_account.deploy_prod.email +} +``` + +This gives you a clear, auditable boundary: WIF says *who's calling*, the +deploy SA says *what they can do*, and rotating one doesn't disturb the other. + +--- + +## References + +- [google_project_iam_*][gpi-resources] — member vs binding vs policy +- [google_project_iam_custom_role][gpi-custom-role] +- [google_iam_workload_identity_pool][wif-pool] +- [google_iam_workload_identity_pool_provider][wif-provider] +- [GCP: Workload Identity Federation with GitHub][wif-github] +- [GCP: Service account impersonation](https://cloud.google.com/iam/docs/service-account-impersonation) +- [GCP: Understanding custom roles](https://cloud.google.com/iam/docs/understanding-custom-roles) + +[gpi-resources]: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam +[gpi-custom-role]: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam_custom_role +[wif-pool]: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool +[wif-provider]: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool_provider +[wif-github]: https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines diff --git a/docs/terraform/gcp/index.md b/docs/terraform/gcp/index.md new file mode 100644 index 0000000..21aa40c --- /dev/null +++ b/docs/terraform/gcp/index.md @@ -0,0 +1,44 @@ +--- +title: GCP +description: GCP-specific Terraform snippets — variables, modules, backends, providers, IAM. +tags: + - terraform + - gcp +--- + +# GCP + +
+ +- :material-variable:{ .lg .middle } **[Common variables](variables.md)** + + --- + + Typed, validated `variable` blocks: environment, region, tags, CIDRs, + instance type, FQDN, optionals, objects, secrets. + +- :material-folder-multiple:{ .lg .middle } **[Module skeleton](module-skeleton.md)** + + --- + + Opinionated layout for a reusable module. + +- :material-database-export:{ .lg .middle } **[Backends](backends.md)** + + --- + + Remote state backends with locking and encryption. + +- :material-cog:{ .lg .middle } **[Provider configuration](providers.md)** + + --- + + google provider defaults: project/region, user_project_override, WIF auth, impersonation aliases. + +- :material-shield-key:{ .lg .middle } **[IAM policy patterns](iam-policies.md)** + + --- + + Least-privilege snippets you copy more than you'd like to admit. + +
diff --git a/docs/terraform/gcp/module-skeleton.md b/docs/terraform/gcp/module-skeleton.md new file mode 100644 index 0000000..3e05bd9 --- /dev/null +++ b/docs/terraform/gcp/module-skeleton.md @@ -0,0 +1,254 @@ +--- +title: Module skeleton +description: Opinionated layout for a reusable Terraform/OpenTofu module targeting the google + google-beta providers. +tags: + - terraform + - gcp +--- + +# Module skeleton + +A minimal, opinionated layout for a reusable GCP module. Targets +**Terraform ≥ 1.3** / **OpenTofu ≥ 1.6** and the **google / google-beta** +providers ≥ 6.0. + +```text +modules/gcs-bucket/ +├── README.md +├── versions.tf +├── providers.tf # provider configuration aliases (google-beta) +├── variables.tf +├── locals.tf +├── main.tf +├── outputs.tf +├── examples/ +│ └── basic/ +│ ├── main.tf +│ ├── variables.tf +│ └── outputs.tf +└── tests/ + └── basic.tftest.hcl +``` + +!!! tip "Configure providers *outside* the module" + A module should declare *required* providers in `versions.tf` but not + `provider {}` blocks. The root module owns auth, project, and region. + The exception is provider aliases (e.g. `google-beta`), which the module + can require but the caller must pass via `providers = { ... }`. + +--- + +## `versions.tf` + +```hcl +terraform { + required_version = ">= 1.3" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 6.0, < 7.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 6.0, < 7.0" + } + } +} +``` + +## `variables.tf` + +```hcl +variable "project_id" { + description = "GCP project ID the bucket lives in." + type = string +} + +variable "name" { + description = "Bucket name (must be globally unique)." + type = string +} + +variable "location" { + description = "Bucket location (region or multi-region). Defaults to US-CENTRAL1." + type = string + default = "US-CENTRAL1" +} + +variable "labels" { + description = "Additional labels to merge over the module defaults." + type = map(string) + default = {} +} + +variable "environment" { + description = "Deployment environment (dev/stg/prod)." + type = string + + validation { + condition = contains(["dev", "stg", "prod"], var.environment) + error_message = "environment must be one of: dev, stg, prod." + } +} +``` + +## `locals.tf` + +```hcl +locals { + default_labels = { + managed_by = "terraform" + module = "gcs-bucket" + environment = var.environment + } + + labels = merge(local.default_labels, var.labels) +} +``` + +## `main.tf` + +```hcl +resource "google_storage_bucket" "this" { + project = var.project_id + name = var.name + location = var.location + force_destroy = false + uniform_bucket_level_access = true + public_access_prevention = "enforced" + + versioning { + enabled = true + } + + labels = local.labels +} +``` + +## `outputs.tf` + +```hcl +output "name" { + description = "Name of the bucket." + value = google_storage_bucket.this.name +} + +output "url" { + description = "gs:// URL of the bucket." + value = google_storage_bucket.this.url +} + +output "self_link" { + description = "Self-link of the bucket." + value = google_storage_bucket.this.self_link +} +``` + +--- + +## `examples/basic/main.tf` + +```hcl +terraform { + required_version = ">= 1.3" + required_providers { + google = { source = "hashicorp/google", version = "~> 6.0" } + } +} + +provider "google" { + project = var.project_id + region = "us-central1" +} + +module "bucket" { + source = "../.." + + project_id = var.project_id + name = "${var.project_id}-example" + environment = "dev" + + labels = { + owner = "platform" + cost_center = "infra" + } +} + +variable "project_id" { type = string } + +output "bucket_url" { value = module.bucket.url } +``` + +--- + +## Native tests + +Terraform `*.tftest.hcl` files run with `terraform test`. They support pure +plan-only assertions (fast, no resources created) and full apply runs. + +```hcl +# tests/basic.tftest.hcl + +variables { + project_id = "acme-platform-test" + environment = "dev" +} + +run "plan_basic" { + command = plan + + module { + source = "./examples/basic" + } + + assert { + condition = module.bucket.name == "acme-platform-test-example" + error_message = "Bucket name was not derived from project_id." + } +} + +run "labels_merged" { + command = plan + + module { + source = "./examples/basic" + } + + assert { + condition = lookup(module.bucket.labels, "managed_by", "") == "terraform" + error_message = "Default label managed_by=terraform was not applied." + } +} +``` + +Run with: + +```bash +terraform init +terraform test +``` + +--- + +## README structure + +Generate the inputs/outputs tables from source, never hand-maintain them: + +```bash +terraform-docs markdown table --output-file README.md --output-mode inject . +``` + +Recommended sections (in order): **Purpose**, **Usage** (smallest possible +example), **Inputs** (auto), **Outputs** (auto), **Providers** (auto), +**Requirements** (auto). + +--- + +## References + +- [Terraform: Module structure](https://developer.hashicorp.com/terraform/language/modules/develop/structure) +- [Terraform: Tests](https://developer.hashicorp.com/terraform/language/tests) +- [Terraform Registry: google provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs) +- [Terraform Registry: google-beta provider](https://registry.terraform.io/providers/hashicorp/google-beta/latest/docs) +- [terraform-docs](https://terraform-docs.io/) diff --git a/docs/terraform/gcp/providers.md b/docs/terraform/gcp/providers.md new file mode 100644 index 0000000..613affd --- /dev/null +++ b/docs/terraform/gcp/providers.md @@ -0,0 +1,222 @@ +--- +title: Provider configuration +description: Sensible defaults for the google and google-beta providers — pinning, project/region/zone, user_project_override, ADC vs Workload Identity Federation, aliases, and impersonation. +tags: + - terraform + - gcp +--- + +# Provider configuration + +GCP provider boilerplate that you'll repeat in almost every stack. Targets +the **google** and **google-beta** providers ≥ 6.0 with **Terraform ≥ 1.3** +or **OpenTofu ≥ 1.6**. + +--- + +## Pin in `required_providers` + +```hcl +terraform { + required_version = ">= 1.3" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 6.0, < 7.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 6.0, < 7.0" + } + } +} +``` + +!!! tip "Pin both `google` and `google-beta` to the same version range" + The two providers ship in lockstep. Mixing versions across them produces + confusing schema drift. + +## Default `google` and `google-beta` providers + +```hcl +provider "google" { + project = var.project_id + region = var.region + zone = var.zone +} + +provider "google-beta" { + project = var.project_id + region = var.region + zone = var.zone +} +``` + +Setting `project`, `region`, and `zone` here means resource blocks don't have +to repeat them. Resources can still override per-resource. + +## `user_project_override` and `billing_project` + +Some APIs (notably anything fronted by Service Usage, BigQuery, or +"requester-pays" buckets) bill the **caller's** project, not the resource's +project. Set both fields when your auth identity lives in a different +project from the resources you're managing: + +```hcl +provider "google" { + project = var.project_id + region = var.region + user_project_override = true + billing_project = var.billing_project_id +} +``` + +`user_project_override = true` tells the provider to send +`X-Goog-User-Project: ` with each request. + +--- + +## Authentication + +### Local development — Application Default Credentials + +```bash +gcloud auth application-default login +gcloud config set project acme-platform-dev +``` + +The provider picks up ADC automatically. No `credentials = ...` argument, +no JSON key on disk. + +### CI — Workload Identity Federation (no keys) + +In CI, exchange the runner's OIDC token for a short-lived Google token. With +GitHub Actions: + +```yaml +permissions: + id-token: write + contents: read + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github + service_account: tf-deployer@acme-platform-prod.iam.gserviceaccount.com + - uses: hashicorp/setup-terraform@v3 + - run: terraform init + - run: terraform apply -auto-approve +``` + +The `auth` action writes credentials to a path in `GOOGLE_APPLICATION_CREDENTIALS`, +and the google provider transparently picks them up. + +!!! warning "Don't ship JSON service-account keys" + Static SA keys are the #1 source of GCP credential leaks. Use ADC + locally and Workload Identity Federation in CI. See the + [IAM bindings page](iam-policies.md#workload-identity-federation-for-github-actions) + for the WIF resource setup. + +--- + +## Provider aliases for multi-project + +When one stack manages resources across projects (e.g. shared VPC host + +service projects), declare an aliased provider per project: + +```hcl +provider "google" { + alias = "host" + project = var.host_project_id + region = var.region +} + +provider "google" { + alias = "svc_app" + project = var.app_project_id + region = var.region +} + +resource "google_compute_subnetwork" "app" { + provider = google.host + name = "app-subnet" + ip_cidr_range = "10.10.0.0/20" + region = var.region + network = google_compute_network.shared.id +} + +resource "google_compute_instance" "api" { + provider = google.svc_app + name = "api" + machine_type = "e2-medium" + zone = var.zone + # ... +} +``` + +Modules accept aliased providers via the `providers` argument: + +```hcl +module "shared_vpc" { + source = "./modules/shared-vpc" + + providers = { + google = google.host + } + + # ... +} +``` + +--- + +## Impersonate a service account + +Useful when your human/CI identity has only `roles/iam.serviceAccountTokenCreator` +on a deploy SA, and the deploy SA holds the actual resource permissions: + +```hcl +provider "google" { + project = var.project_id + region = var.region + impersonate_service_account = "tf-deploy-prod@acme-platform-prod.iam.gserviceaccount.com" +} +``` + +Combine with aliases for per-environment impersonation in a single stack: + +```hcl +provider "google" { + alias = "prod" + project = "acme-platform-prod" + region = "us-central1" + impersonate_service_account = "tf-deploy-prod@acme-platform-prod.iam.gserviceaccount.com" +} + +provider "google" { + alias = "stg" + project = "acme-platform-stg" + region = "us-central1" + impersonate_service_account = "tf-deploy-stg@acme-platform-stg.iam.gserviceaccount.com" +} +``` + +The caller (you, or the CI SA) only needs `roles/iam.serviceAccountTokenCreator` +on each deploy SA — nothing else. + +--- + +## References + +- [Terraform Registry: google provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs) +- [Terraform Registry: google-beta provider](https://registry.terraform.io/providers/hashicorp/google-beta/latest/docs) +- [google provider: Authentication](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference#authentication) +- [GCP: Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) +- [GCP: Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation) +- [GCP: Service account impersonation](https://cloud.google.com/iam/docs/service-account-impersonation) +- [google-github-actions/auth](https://github.com/google-github-actions/auth) diff --git a/docs/terraform/gcp/variables.md b/docs/terraform/gcp/variables.md new file mode 100644 index 0000000..947f322 --- /dev/null +++ b/docs/terraform/gcp/variables.md @@ -0,0 +1,318 @@ +--- +title: Common variables +description: Reusable, well-validated Terraform / OpenTofu variable blocks for GCP — project, region, zone, labels, CIDRs, machine type, and more. +tags: + - terraform + - gcp +--- + +# Common variables + +Drop-in `variable` blocks for the **google** and **google-beta** providers, with +`type`, `description`, sensible defaults, and `validation` rules. They work +with **Terraform ≥ 1.3** and **OpenTofu ≥ 1.6** and the **google provider ≥ 6.0**. + +!!! tip "Conventions used on this page" + - All variables have a `description`. + - `error_message` is a complete sentence ending in a period. + - Defaults are only set when there's a safe, common choice. + - Optional values are typed `string` with `default = null` and + `nullable = true` rather than empty strings, so missing values are explicit. + +--- + +## Project ID + +GCP project IDs must start with a lowercase letter, be 6–30 characters long, +and contain only lowercase letters, digits, and hyphens. + +```hcl +variable "project_id" { + description = "GCP project ID (e.g. acme-platform-prod). 6–30 chars, lowercase letter start." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id)) + error_message = "project_id must start with a lowercase letter, be 6–30 chars, and contain only lowercase letters, digits, or hyphens (no trailing hyphen)." + } +} +``` + +## Region + +```hcl +variable "region" { + description = "GCP region to deploy regional resources into (e.g. us-central1)." + type = string + default = "us-central1" + + validation { + condition = can(regex("^(asia|australia|europe|me|northamerica|southamerica|us|africa)-[a-z]+[0-9]+$", var.region)) + error_message = "region must look like a valid GCP region (e.g. us-central1, europe-west4, asia-southeast1)." + } +} +``` + +## Zone + +```hcl +variable "zone" { + description = "GCP zone for zonal resources (e.g. us-central1-a). Must belong to var.region." + type = string + default = "us-central1-a" + + validation { + condition = can(regex("^(asia|australia|europe|me|northamerica|southamerica|us|africa)-[a-z]+[0-9]+-[a-z]$", var.zone)) + error_message = "zone must look like a valid GCP zone (e.g. us-central1-a, europe-west4-b)." + } +} +``` + +## Environment + +```hcl +variable "environment" { + description = "Deployment environment. Used in resource names, labels, and conditional logic." + type = string + + validation { + condition = contains(["dev", "stg", "prod"], var.environment) + error_message = "environment must be one of: dev, stg, prod." + } +} +``` + +## Project / application name + +A short naming prefix used for resources, distinct from the GCP `project_id`. + +```hcl +variable "project" { + description = "Short project identifier used as a prefix for resource names. Lowercase letters, digits, and hyphens only; 2–24 characters." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]{1,23}$", var.project)) + error_message = "project must start with a lowercase letter and contain only lowercase letters, digits, or hyphens (2–24 chars)." + } +} +``` + +## Labels (with required keys) + +!!! warning "GCP label rules" + GCP labels are **lowercase only** and limited to letters, digits, hyphens, + and underscores. Keys must start with a lowercase letter; values may be + empty. Maximum length is 63 characters for both keys and values, and a + resource may have at most 64 labels. + +```hcl +variable "labels" { + description = "Labels applied to every labelable resource. Must include owner, environment, and cost_center." + type = map(string) + default = {} + + validation { + condition = alltrue([ + for k in ["owner", "environment", "cost_center"] : contains(keys(var.labels), k) + ]) + error_message = "labels must include the keys: owner, environment, cost_center." + } + + validation { + condition = alltrue([for k in keys(var.labels) : can(regex("^[a-z][a-z0-9_-]{0,62}$", k))]) + error_message = "Every label key must start with a lowercase letter and contain only lowercase letters, digits, hyphens, or underscores (≤63 chars)." + } + + validation { + condition = alltrue([for v in values(var.labels) : can(regex("^[a-z0-9_-]{0,63}$", v))]) + error_message = "Every label value must be lowercase letters, digits, hyphens, or underscores only (≤63 chars)." + } +} +``` + +## CIDR block + +```hcl +variable "network_cidr" { + description = "IPv4 CIDR block for the VPC subnet primary range. Must be a /16–/29 RFC 1918 range." + type = string + default = "10.0.0.0/16" + + validation { + condition = can(cidrnetmask(var.network_cidr)) + error_message = "network_cidr must be a valid IPv4 CIDR block (e.g. 10.0.0.0/16)." + } + + validation { + condition = tonumber(split("/", var.network_cidr)[1]) >= 16 && tonumber(split("/", var.network_cidr)[1]) <= 29 + error_message = "network_cidr prefix length must be between /16 and /29." + } +} +``` + +### List of CIDRs (allowlist) + +```hcl +variable "allowed_cidrs" { + description = "Source CIDR blocks allowed to reach the service. Use [\"0.0.0.0/0\"] only deliberately." + type = list(string) + default = [] + + validation { + condition = alltrue([for c in var.allowed_cidrs : can(cidrnetmask(c))]) + error_message = "Every entry in allowed_cidrs must be a valid IPv4 CIDR block." + } +} +``` + +## Machine type + +```hcl +variable "machine_type" { + description = "Compute Engine machine type, e.g. e2-medium or n2-standard-4." + type = string + default = "e2-medium" + + validation { + condition = can(regex("^(([a-z][0-9][a-z]?)-(micro|small|medium|standard|highmem|highcpu|megamem|ultramem)(-[0-9]+)?|custom-[0-9]+-[0-9]+)$", var.machine_type)) + error_message = "machine_type must look like a valid Compute Engine type (e.g. e2-medium, n2-standard-4, c3-highcpu-8)." + } +} +``` + +## Domain name + +```hcl +variable "domain_name" { + description = "Fully qualified domain name (e.g. api.example.com). Lowercase, no trailing dot." + type = string + + validation { + condition = can(regex("^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$", var.domain_name)) + error_message = "domain_name must be a lowercase FQDN such as api.example.com (no trailing dot)." + } +} +``` + +## Service account email + +```hcl +variable "service_account_email" { + description = "Email of an existing service account, e.g. deployer@my-project.iam.gserviceaccount.com." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]@[a-z][a-z0-9-]{4,28}[a-z0-9]\\.iam\\.gserviceaccount\\.com$", var.service_account_email)) + error_message = "service_account_email must be a valid GCP service account email of the form NAME@PROJECT_ID.iam.gserviceaccount.com." + } +} +``` + +## Optional KMS key (nullable) + +Prefer `null` over `""` so "unset" is explicit: + +```hcl +variable "kms_key_name" { + description = "Optional Cloud KMS CryptoKey resource ID. When null, Google-managed encryption keys are used." + type = string + default = null + nullable = true + + validation { + condition = var.kms_key_name == null || can(regex("^projects/[a-z][a-z0-9-]{4,28}[a-z0-9]/locations/[a-z0-9-]+/keyRings/[a-zA-Z0-9_-]+/cryptoKeys/[a-zA-Z0-9_-]+$", var.kms_key_name)) + error_message = "kms_key_name must be null or a fully-qualified CryptoKey resource ID (projects/.../locations/.../keyRings/.../cryptoKeys/...)." + } +} +``` + +## Boolean feature flag + +```hcl +variable "enable_logging" { + description = "Whether to enable verbose access logging. Disable in cost-sensitive environments." + type = bool + default = true +} +``` + +## Object with optional attributes (Cloud Logging) + +Uses `optional()` from Terraform 1.3+ / OpenTofu so consumers only specify what +they care about. Cloud Logging custom retention is between 1 and 3650 days. + +```hcl +variable "logging" { + description = "Cloud Logging configuration for a log bucket. Any field not specified falls back to defaults." + type = object({ + enabled = optional(bool, true) + retention_days = optional(number, 30) + log_bucket_name = optional(string) + location = optional(string, "global") + }) + default = {} + + validation { + condition = var.logging.retention_days >= 1 && var.logging.retention_days <= 3650 + error_message = "logging.retention_days must be between 1 and 3650 (Cloud Logging custom retention range)." + } +} +``` + +## Map of subnets + +```hcl +variable "subnets" { + description = "Map of subnet name to its primary CIDR range and region." + type = map(object({ + ip_cidr_range = string + region = string + })) + default = {} + + validation { + condition = alltrue([for s in values(var.subnets) : can(cidrnetmask(s.ip_cidr_range))]) + error_message = "Every subnets[*].ip_cidr_range must be a valid IPv4 CIDR block." + } + + validation { + condition = alltrue([ + for s in values(var.subnets) : + can(regex("^(asia|australia|europe|me|northamerica|southamerica|us|africa)-[a-z]+[0-9]+$", s.region)) + ]) + error_message = "Every subnets[*].region must look like a valid GCP region (e.g. us-central1)." + } +} +``` + +## Secrets / sensitive values + +!!! warning "Never commit secret values" + Provide via `TF_VAR_*` env vars, Secret Manager, or a `.auto.tfvars` file + that is `.gitignore`-d. The validation below only enforces a minimum length. + +```hcl +variable "db_password" { + description = "Cloud SQL admin password. Provide via TF_VAR_db_password or Secret Manager — do not commit." + type = string + sensitive = true + + validation { + condition = length(var.db_password) >= 16 + error_message = "db_password must be at least 16 characters." + } +} +``` + +--- + +## References + +- [Terraform: Input Variables](https://developer.hashicorp.com/terraform/language/values/variables) +- [Terraform Registry: google provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs) +- [Terraform Registry: google-beta provider](https://registry.terraform.io/providers/hashicorp/google-beta/latest/docs) +- [GCP: Project IDs](https://cloud.google.com/resource-manager/docs/creating-managing-projects#identifying_projects) +- [GCP: Regions and zones](https://cloud.google.com/compute/docs/regions-zones) +- [GCP: Labels requirements](https://cloud.google.com/resource-manager/docs/creating-managing-labels#requirements) +- [GCP: Machine types](https://cloud.google.com/compute/docs/machine-resource) diff --git a/docs/terraform/iam-policies.md b/docs/terraform/iam-policies.md deleted file mode 100644 index 807ea2d..0000000 --- a/docs/terraform/iam-policies.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: IAM policy patterns -status: stub -tags: - - terraform - - aws ---- - -# IAM policy patterns - -!!! note "Stub page" - Least-privilege IAM policy snippets you copy more than you'd like to admit. - -## Planned content - -- GitHub Actions OIDC trust policy (with branch/environment conditions) -- Cross-account assume-role with external ID -- S3 bucket policy: deny non-TLS, deny non-encrypted PUT -- KMS key policy: separate admin vs use vs grant diff --git a/docs/terraform/index.md b/docs/terraform/index.md index 4002746..96e6944 100644 --- a/docs/terraform/index.md +++ b/docs/terraform/index.md @@ -1,5 +1,6 @@ --- title: Terraform / OpenTofu +description: Reusable Terraform / OpenTofu snippets for AWS, Azure, GCP, and Terragrunt. tags: - terraform --- @@ -13,35 +14,28 @@ Snippets here work with both [Terraform](https://developer.hashicorp.com/terrafo
-- :material-variable:{ .lg .middle } **[Common variables](variables.md)** +- :material-aws:{ .lg .middle } **[AWS](aws/index.md)** --- - Typed, validated `variable` blocks: environment, region, tags, CIDRs, - instance type, FQDN, optionals, objects, secrets. + Amazon Web Services (AWS) common variables and more. -- :material-folder-multiple:{ .lg .middle } **[Module skeleton](module-skeleton.md)** +- :material-microsoft-azure:{ .lg .middle } **[Azure](azure/index.md)** --- - Opinionated layout for a reusable module. + Microsoft Azure common variables and more. -- :material-database-export:{ .lg .middle } **[Backends](backends.md)** +- :material-google-cloud:{ .lg .middle } **[GCP](gcp/index.md)** --- - Remote state backends with locking and encryption. + Google Cloud Platform (GCP) common variables and more. -- :material-cog:{ .lg .middle } **[Provider configuration](providers.md)** +- :material-rocket-launch:{ .lg .middle } **[Terragrunt](terragrunt/index.md)** --- - Sensible defaults for AWS, GCP, Azure providers. - -- :material-shield-key:{ .lg .middle } **[IAM policy patterns](iam-policies.md)** - - --- - - Least-privilege snippets you copy more than you'd like to admit. + Modern Terragrunt patterns: `root.hcl`, units, and explicit stacks.
diff --git a/docs/terraform/module-skeleton.md b/docs/terraform/module-skeleton.md deleted file mode 100644 index 46c0097..0000000 --- a/docs/terraform/module-skeleton.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Module skeleton -status: stub -tags: - - terraform ---- - -# Module skeleton - -!!! note "Stub page" - Opinionated layout for a reusable Terraform/OpenTofu module. - -## Planned content - -- `versions.tf` with required_providers + required_version -- `main.tf`, `variables.tf`, `outputs.tf`, `locals.tf` split conventions -- README structure (inputs/outputs auto-generated by terraform-docs) -- examples/ directory pattern -- tests/ with terratest or native test framework diff --git a/docs/terraform/providers.md b/docs/terraform/providers.md deleted file mode 100644 index ff53c37..0000000 --- a/docs/terraform/providers.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Provider configuration -status: stub -tags: - - terraform ---- - -# Provider configuration - -!!! note "Stub page" - Sensible defaults for the AWS, GCP, and Azure providers. - -## Planned content - -- AWS: `default_tags`, retry config, assume_role, regional alias pattern -- GCP: project/region defaults, user_project_override -- Azure: features block defaults -- Provider version pinning strategy diff --git a/docs/terraform/terragrunt/index.md b/docs/terraform/terragrunt/index.md new file mode 100644 index 0000000..b5408f0 --- /dev/null +++ b/docs/terraform/terragrunt/index.md @@ -0,0 +1,88 @@ +--- +title: Terragrunt +description: Modern Terragrunt patterns aligned with the 0.66+/1.0 release line. +tags: + - terragrunt + - terraform +--- + +# Terragrunt + +[Terragrunt](https://terragrunt.gruntwork.io/) is a thin orchestration layer that +sits on top of Terraform / OpenTofu. It does not replace HCL modules — you still +write modules the same way — it just removes the copy-paste involved in wiring +the same module into many environments. + +## What it adds on top of Terraform / OpenTofu + +- **DRY remote state** — one `remote_state` block at the root generates the + `backend "s3" {}` for every unit, with the state key derived from the unit's + path on disk. +- **DRY provider blocks** — one `generate "provider"` block writes a + `provider.tf` into every working directory, so units don't repeat + `provider "aws" { region = ... }` boilerplate. +- **Dependency graph** — `dependency "vpc" { config_path = "../vpc" }` lets one + unit consume another unit's outputs without manual `terraform_remote_state` + data sources, and Terragrunt sequences applies in topological order. +- **`run --all`** (formerly `run-all`) — plan/apply/destroy across every unit + under a directory, respecting the dependency graph. +- **OpenTofu support** — set `terraform_binary = "tofu"` in `terraform_binary` + or via the `TG_TF_PATH` env var to drive `tofu` instead of `terraform`. + +## When to use it + +Reach for Terragrunt when you have: + +- More than one environment (dev / staging / prod) of the same stack. +- Many small units (vpc, eks, rds, …) that share state-bucket layout, providers, + and tagging. +- A platform team that wants `cd live/prod && terragrunt run --all plan` to be + the daily driver. + +Skip it for a single-environment, single-state-file project — a plain +`terraform` root with a backend block is simpler. + +!!! tip "OpenTofu users" + Everything on the child pages works with OpenTofu. Set + `terraform_binary = "tofu"` in your `root.hcl` `terraform` block, or export + `TG_TF_PATH=tofu`. + +## Migration from legacy patterns + +If you're coming from older Terragrunt: + +- The root config file is now conventionally **`root.hcl`**, not a root-level + `terragrunt.hcl`. Units reference it with + `find_in_parent_folders("root.hcl")`. The legacy implicit lookup of a parent + `terragrunt.hcl` is deprecated and emits a warning. +- The old **`_envcommon/`** directory pattern (one HCL file per module shared + across envs) is replaced by explicit `include "env" { path = ... }` blocks + reading an `env.hcl` via `read_terragrunt_config`. +- `run-all` is now `run --all`. `terragrunt-include-dir` flags are now + `--queue-include-dir`. + +## Pages + +
+ +- :material-file-tree:{ .lg .middle } **[root.hcl + env.hcl + unit pattern](root-config.md)** + + --- + + The modern 3-file layout: shared root config, per-environment locals, and + thin per-unit `terragrunt.hcl` files. + +- :material-layers-triple:{ .lg .middle } **[Explicit stacks](stacks.md)** + + --- + + Define an app-shaped bundle (vpc + eks + rds + queue) once in a + `terragrunt.stack.hcl`, instantiate it per environment. + +
+ +## References + +- [Terragrunt docs home](https://docs.terragrunt.com/getting-started/quick-start/) +- [Migrating to `root.hcl`](https://terragrunt.gruntwork.io/docs/migrate/migrating-from-root-terragrunt-hcl/) +- [CLI reference](https://terragrunt.gruntwork.io/docs/reference/cli-options/) diff --git a/docs/terraform/terragrunt/root-config.md b/docs/terraform/terragrunt/root-config.md new file mode 100644 index 0000000..6f185a4 --- /dev/null +++ b/docs/terraform/terragrunt/root-config.md @@ -0,0 +1,216 @@ +--- +title: root.hcl + env.hcl + unit pattern +description: The modern 3-file Terragrunt layout — shared root, per-environment locals, thin per-unit configs. +tags: + - terragrunt + - terraform +--- + +# root.hcl + env.hcl + unit pattern + +The current Gruntwork-recommended layout splits configuration into three files +that compose by location on disk: + +1. **`root.hcl`** — one per repo, at the top of `live/`. Holds the remote-state + backend, generated provider, and locals shared by every unit. +2. **`env.hcl`** — one per environment directory. Holds variables that differ + per env (region, account ID, environment name). +3. **Unit `terragrunt.hcl`** — one per deployable unit. Includes `root` (and + optionally `env`), points at a module `source`, and supplies `inputs`. + +## Directory layout + +```text +live/ + root.hcl + dev/ + env.hcl + us-east-1/ + vpc/ + terragrunt.hcl + eks/ + terragrunt.hcl + prod/ + env.hcl + us-east-1/ + vpc/ + terragrunt.hcl + eks/ + terragrunt.hcl +``` + +## 1. `root.hcl` + +```hcl +# live/root.hcl +locals { + # Pull the env.hcl that lives somewhere above the current unit. + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + + account_id = local.env_vars.locals.account_id + aws_region = local.env_vars.locals.aws_region + environment = local.env_vars.locals.environment + + default_tags = { + Environment = local.environment + ManagedBy = "terragrunt" + Repo = "infra-live" + } +} + +# One S3 backend definition for every unit. The state key is derived from +# each unit's path under live/, so vpc/ and eks/ get separate state files +# automatically. +remote_state { + backend = "s3" + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } + config = { + bucket = "acme-tfstate-${local.account_id}" + key = "${path_relative_to_include()}/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true # native S3 locking, no DynamoDB table required + } +} + +# A provider.tf written into every working directory. +generate "provider" { + path = "provider.tf" + if_exists = "overwrite_terragrunt" + contents = <` is equivalent to `cd .terragrunt-stack && terragrunt run --all `, +respecting `dependency` blocks across the generated units. + +## In-place migration: `no_dot_terragrunt_stack` + +If you're converting an existing tree of hand-written units into a stack +without breaking everyone's `cd live/dev/us-east-1/vpc` muscle memory, set: + +```hcl +# terragrunt.stack.hcl +no_dot_terragrunt_stack = true + +unit "vpc" { + source = "..." + path = "vpc" # written directly under live/dev/us-east-1/vpc/ + values = { ... } +} +``` + +With `no_dot_terragrunt_stack = true`, generated units land at `path` directly +(no `.terragrunt-stack/` prefix). This is the recommended path for converting +an existing 3-file repo to stacks one environment at a time. + +!!! warning "Don't hand-edit generated files" + Anything under `.terragrunt-stack/` (or under the in-place `path` when + `no_dot_terragrunt_stack = true`) is overwritten on the next + `terragrunt stack generate`. Make changes in the catalog repo or in + `terragrunt.stack.hcl`'s `values`, never in the generated `terragrunt.hcl`. + +## References + +- [Stacks overview](https://terragrunt.gruntwork.io/docs/features/stacks/) +- [`terragrunt.stack.hcl` reference](https://docs.terragrunt.com/reference/config-blocks-and-attributes/#stack) +- [`terragrunt stack` CLI](https://terragrunt.gruntwork.io/docs/reference/cli/commands/stack/run/) +- [Infrastructure catalog pattern](https://terragrunt.gruntwork.io/docs/features/catalog/) diff --git a/docs/terragrunt/index.md b/docs/terragrunt/index.md deleted file mode 100644 index 43df8cc..0000000 --- a/docs/terragrunt/index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Terragrunt -status: stub -tags: - - terragrunt - - terraform ---- - -# Terragrunt - -!!! note "Stub page" - Modern Terragrunt patterns aligned with the 1.0 release (root.hcl, units, stacks). - -## Planned content - -- When to use Terragrunt vs plain Terraform/OpenTofu -- Migration notes from older patterns (`terragrunt.hcl` root, `_envcommon`) diff --git a/docs/terragrunt/root-config.md b/docs/terragrunt/root-config.md deleted file mode 100644 index fbb2e0f..0000000 --- a/docs/terragrunt/root-config.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: root.hcl + env.hcl + unit pattern -status: stub -tags: - - terragrunt ---- - -# root.hcl + env.hcl + unit pattern - -!!! note "Stub page" - Current-best-practice 3-file layout: shared root, per-env locals, per-unit overrides. - -## Planned content - -- `root.hcl` with remote_state, generate provider, common locals -- `env.hcl` per environment (dev/stg/prod) with vars consumed via `read_terragrunt_config` -- Unit `terragrunt.hcl` with `include "root"`, `terraform { source }`, and `inputs` -- Working directory tree example diff --git a/docs/terragrunt/stacks.md b/docs/terragrunt/stacks.md deleted file mode 100644 index 46b8392..0000000 --- a/docs/terragrunt/stacks.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Explicit stacks (terragrunt.stack.hcl) -status: stub -tags: - - terragrunt ---- - -# Explicit stacks (terragrunt.stack.hcl) - -!!! note "Stub page" - Define a reusable infrastructure pattern (e.g. app + db + queue) once, instantiate per environment. - -## Planned content - -- `terragrunt.stack.hcl` syntax: `unit`, `stack`, `values`, dependencies -- When to use stacks vs raw units -- Catalog repo pattern (units + stacks in a separate repo) -- `no_dot_terragrunt_stack` for in-place migration diff --git a/pyproject.toml b/pyproject.toml index 7c7420b..3dd8544 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "boilplate" version = "0.0.0" description = "Copy-paste-ready boilerplate, published as an MkDocs Material site." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "RemoteRabbit" }] @@ -30,6 +30,6 @@ package = false # --------------------------------------------------------------------------- [tool.codespell] skip = "uv.lock,site,.venv,*.svg,terragrunt" -ignore-words-list = "hcl,opentofu,fo,nd,te,ot" +ignore-words-list = "CAF,hcl,opentofu,fo,nd,te,ot" check-filenames = true check-hidden = true diff --git a/renovate.json b/renovate.json index 5db72dd..f6b1760 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,28 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:recommended" + "config:recommended", + ":dependencyDashboard", + ":semanticCommits", + "schedule:weekly" + ], + "labels": ["dependencies"], + "prHourlyLimit": 4, + "prConcurrentLimit": 10, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 5am on monday"] + }, + "packageRules": [ + { + "description": "Group GitHub Actions updates into a single PR.", + "matchManagers": ["github-actions"], + "groupName": "github-actions" + }, + { + "description": "Group pre-commit hook updates into a single PR.", + "matchManagers": ["pre-commit"], + "groupName": "pre-commit hooks" + } ] } diff --git a/terragrunt/terragrunt.hcl b/terragrunt/terragrunt.hcl index 88b54b7..0331d1e 100644 --- a/terragrunt/terragrunt.hcl +++ b/terragrunt/terragrunt.hcl @@ -15,11 +15,11 @@ generate "provider" { remote_state { backend = "s3" config = { - encrypt = true - bucket = "" - key = "${path_relative_to_include()}/terraform.tfstate" - region = "us-east-2" - encrypt = true + encrypt = true + # TODO: fill in the state bucket name (must be created out-of-band). + bucket = "" + key = "${path_relative_to_include()}/terraform.tfstate" + region = "us-east-2" } generate = { path = "backend.tf" diff --git a/uv.lock b/uv.lock index 661f638..43df444 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.12" [[package]] name = "boilplate" @@ -118,28 +118,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, @@ -272,24 +250,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -336,15 +296,6 @@ version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, @@ -384,15 +335,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - [[package]] name = "virtualenv" version = "21.3.1" @@ -402,7 +344,6 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } wheels = [ diff --git a/zensical.toml b/zensical.toml index 40e8a27..8d717fd 100644 --- a/zensical.toml +++ b/zensical.toml @@ -17,21 +17,37 @@ extra_css = ["assets/extra.css"] extra_javascript = ["assets/external-links.js"] nav = [ { Home = "index.md" }, - { Examples = [ - { "Code block features" = "examples/code-blocks.md" }, - ] }, { "Terraform / OpenTofu" = [ "terraform/index.md", - { "Common variables" = "terraform/variables.md" }, - { "Module skeleton" = "terraform/module-skeleton.md" }, - { Backends = "terraform/backends.md" }, - { "Provider configuration" = "terraform/providers.md" }, - { "IAM policy patterns" = "terraform/iam-policies.md" }, - ] }, - { Terragrunt = [ - "terragrunt/index.md", - { "root.hcl + env.hcl + unit" = "terragrunt/root-config.md" }, - { "Explicit stacks" = "terragrunt/stacks.md" }, + { "AWS" = [ + "terraform/aws/index.md", + {"Common Variables" = "terraform/aws/variables.md"}, + {"Module Skeleton" = "terraform/aws/module-skeleton.md"}, + {"Backends" = "terraform/aws/backends.md"}, + {"Provider" = "terraform/aws/providers.md"}, + {"IAM Policies" = "terraform/aws/iam-policies.md"}, + ]}, + { "Azure" = [ + "terraform/azure/index.md", + {"Common Variables" = "terraform/azure/variables.md"}, + {"Module Skeleton" = "terraform/azure/module-skeleton.md"}, + {"Backends" = "terraform/azure/backends.md"}, + {"Provider" = "terraform/azure/providers.md"}, + {"IAM Policies" = "terraform/azure/iam-policies.md"}, + ]}, + { "GCP" = [ + "terraform/gcp/index.md", + {"Common Variables" = "terraform/gcp/variables.md"}, + {"Module Skeleton" = "terraform/gcp/module-skeleton.md"}, + {"Backends" = "terraform/gcp/backends.md"}, + {"Provider" = "terraform/gcp/providers.md"}, + {"IAM Policies" = "terraform/gcp/iam-policies.md"}, + ]}, + { "Terragrunt" = [ + "terraform/terragrunt/index.md", + {"root.hcl + env.hcl + unit" = "terraform/terragrunt/root-config.md"}, + {"Explicit stacks" = "terraform/terragrunt/stacks.md"}, + ]}, ] }, { "GitHub Actions" = [ "github-actions/index.md", @@ -149,7 +165,6 @@ view = "material/eye" [project.theme.icon.tag] default = "lucide/hash" # Section tags (one per nav top-level section) -examples = "lucide/code-2" api = "lucide/plug" data = "lucide/database" observability = "lucide/activity"