Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ infra/ # Terraform. bootstrap/ is #8; modules/pixel and envs/de
- **Calendar:** features read events from the core `Calendar` (neutral `CalendarEvent`s) and never touch a platform. An adapter plugs in a `CalendarSource` when it's ready. **Text written by other people** (event titles, locations and so on) must go through `escapeMarkdown` before it's put in a reply.
- **Content:** `content/info/*.md` is committed to a **public** repository, so never put secrets (wifi passwords, door codes, tokens) or personal data in it, and don't add member-only topics. Keep topics short and link to the canonical page on pixelbar.nl rather than copying it. Don't invent facts: every claim should come from Pixelbar's own pages or from the board. Adding, removing or renaming a topic needs the commands re-registered (`just register dev` or `just register prod`), and CI loads the real content, so keep it valid.
- **Background work:** a feature that needs it declares `start()`, which returns a stop function. Don't start timers or listeners at import time or in a factory. Anything that runs from a timer must catch its own failures so none escapes as an unhandled rejection.
- **Secrets manifest:** [`infra/secrets.manifest.yaml`](infra/secrets.manifest.yaml) is the one list of Key Vault secrets (names and metadata only, never a value; [ADR 0015](docs/adr/0015-secrets-manifest.md)). A new secret is an entry there plus its Terraform variable and write-only entry, the setting in `config.ts`, `.env.example`, the architecture table and the runbook; `scripts/secrets-manifest.test.ts` names what you missed. Don't hard-code a secret's vault name in `locals.tf`. Maintainers sync the `app` group with `just secrets-pull` / `just secrets-push` ([ADR 0016](docs/adr/0016-secrets-sync.md)); the sync never prints a value, so keep it that way. Coding agents never run `just secrets-push prod` or `just push prod`. `terraform.tfvars` is shared through Key Vault (`terraform-tfvars`, the `tooling` group, which Terraform must keep skipping) and holds settings only: secret Terraform inputs go in gitignored `secrets.auto.tfvars`.
- **Secrets manifest:** [`infra/secrets.manifest.yaml`](infra/secrets.manifest.yaml) is the one list of Key Vault secrets (names and metadata only, never a value; [ADR 0015](docs/adr/0015-secrets-manifest.md)). A new secret is an entry there plus the setting in `config.ts`, `.env.example`, the architecture table and the runbook (an `infra`-group secret also gets its Terraform variable and write-only entry); `scripts/secrets-manifest.test.ts` names what you missed. **`just secrets-push` / `just push` are the only writers of the `app` group** ([ADR 0017](docs/adr/0017-one-writer-for-app-secrets.md)): Terraform references those secrets but never writes them, so don't add an app secret to `secrets.tf` or give it a Terraform input; `write_secrets` covers the `infra` group only. Don't hard-code a secret's vault name in `locals.tf`. Maintainers sync the `app` group with `just secrets-pull` / `just secrets-push` ([ADR 0016](docs/adr/0016-secrets-sync.md)); the sync never prints a value, so keep it that way. Coding agents never run `just secrets-push prod` or `just push prod`. `terraform.tfvars` is shared through Key Vault (`terraform-tfvars`, the `tooling` group, which Terraform must keep skipping) and holds settings only: secret Terraform inputs go in gitignored `secrets.auto.tfvars`.
- **Config:** a new environment variable must be added to `config.ts`, `.env.example` and the architecture doc's config table.
- **Errors:** throw `UserFacingError` for problems the user should see. Anything else is reported to Sentry, and the user gets a generic reply.
- **Types:** `strict`, no `any` (use `unknown` and narrow it). Prefer `type` over `interface`.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t
### Changed

- CI jobs run only when the files they depend on have not already passed. Each workflow's first job hashes the job's inputs (a list in `.github/path-filters.yml`, plus the workflow running it) and looks for a marker that an earlier run with exactly those files succeeded. So pushing only a CHANGELOG entry, re-pushing the same code, or opening a docs-only PR no longer runs `just check`, the Docker build, the docs site build or Terraform validate again. Skipped jobs no longer start a runner. Test-only changes no longer build or publish an image. CD publishes when the image inputs differ from the last publish, so a CD run cancelled by a newer merge no longer leaves code without a GHCR image. Terraform plan jobs that aren't needed never start, so they never wait on an Environment. The changelog gate no longer runs `pnpm install`. `just ci-inputs <list>` shows what a list matches; the runbook's "CI and CD" says how to force a rerun.
- `just secrets-push` / `just push` are now the only writers of the app secrets in Key Vault (Discord token, `admins.yaml`, Home Assistant, Mastodon, space state, Sentry). Terraform's `write_secrets` writes only `tailscale-auth-key` and `ghcr-pull-token`, and the app-secret Terraform inputs (`discord_token`, `admins_yaml_file`, `sentry_dsn`, `spacestate_user` and the rest) are gone, so turning on a feature no longer makes an apply demand or collide with secrets the sync tool set. Push an app secret before the apply that turns its feature on. The next `just tf-plan dev|prod` drops the app secrets Terraform had written from state ("will no longer be managed by Terraform, but will not be destroyed"); nothing leaves Key Vault. Delete the app-secret lines from any `secrets.auto.tfvars`. See [ADR 0017](docs/adr/0017-one-writer-for-app-secrets.md).

### Fixed

Expand Down
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ export default defineConfig({
},
{ text: "0015 · Secrets manifest", link: "/maintainers/adr/0015-secrets-manifest" },
{ text: "0016 · Secrets sync", link: "/maintainers/adr/0016-secrets-sync" },
{
text: "0017 · One writer for app secrets",
link: "/maintainers/adr/0017-one-writer-for-app-secrets",
},
],
},
],
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0015-secrets-manifest.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0015. A secrets manifest is the one list of Key Vault secrets

* Status: accepted (the sync tool is [ADR 0016](0016-secrets-sync.md))
* Status: accepted (the sync tool is [ADR 0016](0016-secrets-sync.md); [ADR 0017](0017-one-writer-for-app-secrets.md) makes it the only writer of the `app` group)
* Date: 2026-10-15

## Context
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0016-secrets-sync.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0016. A laptop tool syncs app secrets with Key Vault

* Status: accepted
* Status: accepted (Terraform's part narrowed by [ADR 0017](0017-one-writer-for-app-secrets.md))
* Date: 2026-10-15

## Context
Expand All @@ -18,7 +18,7 @@ Maintainers set Pixel's secrets in Key Vault by pasting values into gitignored `

## Consequences

- Terraform's `write_secrets` path still works and still owns the `infra` secrets. If an environment keeps `write_secrets = true` with app values in its tfvars (now `secrets.auto.tfvars`, see the addendum), a later apply that bumps `secrets_version` puts those values back, so keep them up to date or turn `write_secrets` off once the vault is seeded.
- Terraform's `write_secrets` path owns only the `infra` secrets. It used to write app values too, which collided with this tool; since [ADR 0017](0017-one-writer-for-app-secrets.md) this tool is the only writer of the `app` group.
- A maintainer needs Key Vault Secrets Officer on the vault (Secrets User is enough to pull), and the same storage access as `just ha-devices` for the `members.yaml` check.
- The local files hold real secrets. They are gitignored and written with mode `0600`; don't copy them anywhere else.
- Coding agents don't run `just secrets-push prod` or `just push prod` (AGENTS.md: no prod secrets).
Expand Down
24 changes: 24 additions & 0 deletions docs/adr/0017-one-writer-for-app-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 0017. Only the sync tool writes app secrets

* Status: accepted (narrows [ADR 0016](0016-secrets-sync.md))
* Date: 2026-10-15

## Context

After [ADR 0016](0016-secrets-sync.md), two things wrote the manifest's `app` secrets into Key Vault: `just secrets-push` / `just push`, and Terraform's `write_secrets` path, which created `azurerm_key_vault_secret.managed` for every enabled secret except the `tooling` group. They collided. Turning on a feature (`spacestate_api_url`) made Terraform demand the new values as inputs and fail with "Resource precondition failed" when they lived only in `.env.prod`; when the tool had already created them, Terraform failed with "already exists, needs to be imported". A prod apply failed this way on 2026-10-15 over `spacestate-user` and `spacestate-token`.

## Decision

- **`just secrets-push` / `just push` are the only writers of the `app` group** (`discord-token`, `admins-yaml`, `home-assistant-token`, `mastodon-access-token`, `spacestate-user`, `spacestate-token`, `spacestate-additional-headers`, `sentry-dsn`).
- **Terraform's `write_secrets` writes only the `infra` group** (`tailscale-auth-key`, `ghcr-pull-token`): `azurerm_key_vault_secret.infra`, whose `for_each` (`kv_infra_secret_names` in `locals.tf`) is derived from the manifest.
- **The Container App still references every enabled secret** (`key_vault_secret_ids`), app group included. Only the writing moved.
- **The app-group Terraform inputs are gone** (`discord_token`, `admins_yaml`, `admins_yaml_file`, `home_assistant_token`, `mastodon_access_token`, `spacestate_user`, `spacestate_token`, `spacestate_additional_headers`, `sentry_dsn`), from the module and both stacks, so there is no way to set them through Terraform. An old `secrets.auto.tfvars` that still sets them only gets an "undeclared variable" warning; delete those lines.
- **Moving state, deleting nothing.** `moved` blocks carry `managed["tailscale-auth-key"]` and `managed["ghcr-pull-token"]` to `infra[...]`. A `removed` block with `lifecycle { destroy = false }` drops whatever is left under `azurerm_key_vault_secret.managed` (the app secrets) from state and leaves them in Key Vault. `removed` can't name single instances, so the infra keys are moved out first. Keep both until every stack has applied them.

## Consequences

- The next `just tf-plan dev|prod` shows each app secret Terraform had written as "will no longer be managed by Terraform, but will not be destroyed", any infra secret it had written as "has moved to", and no `destroy` for a Key Vault secret. Anything else in that plan is unrelated to this change.
- Turning on a feature that needs an app secret is: `just secrets-push <env>` first, then set the feature in the tfvars and apply. The apply only references the secret; the Container App won't start a revision until it exists.
- `write_secrets = true` still needs the infra inputs (`tailscale_auth_key`, `container_registry_password`) for the infra features that are on, and turning it off later removes the infra secrets Terraform wrote from Key Vault, as before. Keep it on while Terraform owns them, or set them with `az` and leave it off from the start.
- The Release workflow's identity may read `discord-token` (ADR 0014). That role assignment is scoped to the secret, so push `discord-token` before the first apply that sets `github_register_principal_id`.
- `scripts/secrets-manifest.test.ts` fails if Terraform's write map names an app secret, or if the `removed` block goes missing.
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ There is **no database**. Access, schedules and (planned) account linking are fi

- **Platform:** Azure Container Apps, **exactly one replica**, no ingress, with a managed identity. A Discord gateway connection needs an always-on process. Two replicas would both connect and answer every command twice. Max replicas = 1 (not a variable). Deploys **stop the old instance before starting the new one** (#11). This stack only pins min=max=1 and `revision_mode = Single`. A database lock is not a plan; there is no database.
- **Images:** local `just deploy-dev` pushes `ghcr.io/pixelbar/pixel:<sha>` (or a `dev-dirty-*` tag) and moving `:dev` for Azure `dev`. Merges to `main` push the SHA and moving `:main` via [`.github/workflows/cd.yml`](../.github/workflows/cd.yml). A Release rebuilds from the bump commit and also tags `:vX.Y.Z`. Builds are **linux/amd64** (Azure Container Apps); the Mini is arm64, so `just deploy-dev` cross-builds. The images contain no secrets and no access lists. The GHCR package is **public** so Container Apps can pull without a registry password. If it is ever private again, `dev` can set `container_registry_server` / `container_registry_username` and Key Vault `ghcr-pull-token`. The running app pins an explicit tag, never `latest` alone. After first create, **dev** Terraform ignores that pin so apply cannot roll it back (#73). Prod apply will set `container_image`. CI still builds with `push: false` as a smoke check.
- **Secrets:** Key Vault. Discord token, Home Assistant token, Sentry DSN, Tailscale auth key, `admins.yaml`, and optional `ghcr-pull-token` (private GHCR only) reach the container as secrets, never as image layers or Terraform state. Terraform may write them at apply from ephemeral write-only inputs (`write_secrets`); otherwise set the app ones with `just secrets-push dev|prod` ([ADR 0016](adr/0016-secrets-sync.md)), `az` or the portal. Prod must not receive the Mini local / Pixel Dev token (that bot lives in `pixel-dev-kv`).
- **Secrets:** Key Vault. Discord token, Home Assistant token, Sentry DSN, Tailscale auth key, `admins.yaml`, and optional `ghcr-pull-token` (private GHCR only) reach the container as secrets, never as image layers or Terraform state. The app ones are written only by `just secrets-push dev|prod` ([ADR 0016](adr/0016-secrets-sync.md), [ADR 0017](adr/0017-one-writer-for-app-secrets.md)); Terraform writes only the Tailscale key and `ghcr-pull-token`, from ephemeral write-only inputs (`write_secrets`). Prod must not receive the Mini local / Pixel Dev token (that bot lives in `pixel-dev-kv`).
- **Access files:** `admins.yaml` is a **read-only** Key Vault secret mount. `members.yaml` is rewritten by admin commands, so it **cannot** be a read-only secret mount. It lives on a **writable Azure Files volume with snapshots**. `config/home-assistant/` is on the same volume: `devices.yaml` is a human allow-list (changed with `just ha-devices`). There is no block-list (#6).
- **Runtime files:** the Azure Files share is mounted at `/app/persist` as uid 1000 (`node`), covering `members.yaml`, `home-assistant/`, and `PIXEL_DATA_DIR` (`schedules.yaml` must persist; `home-switches.state` lost → doors start **off**; `space.state` and `announcement-posts/` only cost details; `announcement-routes.yaml` is where `/admin announcements` choices live, so keep it). A container's own filesystem is thrown away on every deploy.
- **Environments:** `dev` (Pixel Dev bot, test guild) is `infra/envs/dev`. `prod` (production Pixel bot, production guild) is `infra/envs/prod`. Separate bots, tokens and vaults. **`just deploy-dev`** (a local build) rolls Azure `dev` (one replica, then `just register dev`, then Sentry release). **Merges to `main`** publish GHCR only. **Release** (`workflow_dispatch` on `main`) cuts the version, publishes that image, GitHub Environment `prod` approval rolls `pixel-prod` to that digest (no Terraform apply) and then registers prod slash commands, and publishes the docs site. CI does not deploy pull requests. Fail closed: never copy the Mini / Pixel Dev Discord token into prod. See [ADR 0012](adr/0012-prod-environment.md), [ADR 0013](adr/0013-release-workflow.md) and [ADR 0014](adr/0014-release-registers-commands.md).
Expand Down Expand Up @@ -582,3 +582,4 @@ Record significant decisions as short ADRs in `docs/adr/NNNN-title.md`. Numbers
| 0014 | Release registers prod slash commands; `pixel-github-prod` may read only `discord-token` | accepted |
| 0015 | Secrets manifest (`infra/secrets.manifest.yaml`) is the one list of Key Vault secrets; Terraform reads it and a test fails on drift | accepted |
| 0016 | `just secrets-pull` / `just secrets-push` sync the manifest's app secrets between Key Vault and `.env.<env>`, diffing against the live vault before a push; `just pull` / `just push` add the shared `terraform-tfvars` and read-only share mirrors | accepted |
| 0017 | `just secrets-push` / `just push` are the only writers of the app secrets; Terraform's `write_secrets` writes only the infra group, and `removed` blocks drop the old app secrets from state without deleting them | accepted |
4 changes: 2 additions & 2 deletions docs/infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ flowchart TB

1. **Bootstrap** (hand-applied) created the three resource groups, state storage, and per-env GitHub identities. Env stacks deploy *into* those groups; they do not recreate them.
2. **`infra/modules/pixel`** + **`infra/envs/dev`** created the vault, files share, CAE, app, backup, logs, and `pixel-dev-app`. Remote state is container `dev`. **`infra/envs/prod`** is the same module in `pixel-prod` (state container `prod`).
3. **Secrets** are Key Vault names (`discord-token`, `admins-yaml`, `home-assistant-token`, `mastodon-access-token`, `spacestate-user`, `spacestate-token`, `sentry-dsn`). The Container App references **URIs + UAMI**. Terraform may write values as ephemeral `write_secrets` inputs, or they are set with `az` / the portal. Discord / HA / Sentry values do **not** belong in state. The Azure Files **account key** does (Container Apps can only mount SMB with the key).
3. **Secrets** are Key Vault names (`discord-token`, `admins-yaml`, `home-assistant-token`, `mastodon-access-token`, `spacestate-user`, `spacestate-token`, `sentry-dsn`). The Container App references **URIs + UAMI**. Only `just secrets-push` writes these app values ([ADR 0017](adr/0017-one-writer-for-app-secrets.md)); Terraform's ephemeral `write_secrets` inputs cover just the infra secrets `tailscale-auth-key` and `ghcr-pull-token`. Discord / HA / Sentry values do **not** belong in state. The Azure Files **account key** does (Container Apps can only mount SMB with the key).
4. **Image:** GHCR is public, so the app pulls with no registry password. `just deploy-dev` updates the running **dev** image with `az containerapp update` and does **not** apply Terraform. After first create, Terraform **ignores** that **dev** image so apply cannot roll it back (#73). Prod apply sets `container_image`; a Release then rolls `pixel-prod` to the released SHA (same digest, no Terraform apply). Pin an explicit SHA (or `dev-dirty-*` on **dev** only), never `latest` alone.
5. **Discord:** outbound gateway + REST. No FQDN. `/healthz` is process up; `/readyz` is Discord connected. Home Assistant is **not** on the probes (HA down must not restart the replica).
6. **Home Assistant:** both URL and token, or neither. Live `dev` uses a Nabu Casa URL. The allow-list is `devices.yaml` on the share. A Tailscale sidecar is the preferred path ([#43](https://github.com/pixelbar/pixel/issues/43), [#74](https://github.com/pixelbar/pixel/issues/74)); the Terraform stub is **off** and is not enough to flip on as-is. Dev must not control the real doors.
Expand Down Expand Up @@ -194,7 +194,7 @@ just tf-plan dev # needs az login; does not apply
just tf-apply dev # maintainer; first create / vault / volume (not the running dev image)
# prod: PIXEL_APPLY_PROD=1 just tf-apply prod (coding agents: do not)
# seed Files: members.yaml + home-assistant/*.yaml (not the example IDs)
# seed KV if write_secrets was false
# seed KV app secrets: just secrets-push dev
just deploy-dev # this tree → GHCR SHA → pixel-dev, then just register dev
```

Expand Down
1 change: 1 addition & 0 deletions docs/maintainers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Architecture decision records, newest last.
| [0014](./adr/0014-release-registers-commands) | Releases register prod slash commands. |
| [0015](./adr/0015-secrets-manifest) | One manifest lists every Key Vault secret. |
| [0016](./adr/0016-secrets-sync) | `just pull` / `just push` (and `secrets-pull` / `secrets-push`) sync tfvars and app secrets between Key Vault and the laptop. |
| [0017](./adr/0017-one-writer-for-app-secrets) | Only the sync tool writes app secrets; Terraform writes just the infra group. |

## Working on the site

Expand Down
Loading
Loading