From 89128ad4c731d3d47f3bbaced97229e9ea16c6d1 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 15 Oct 2026 15:25:54 +0200 Subject: [PATCH] Centralise laptop tooling around one env definition (#116) scripts/envs.ts is now the only place that names dev and prod: Azure subscription, resource group, Container App, Key Vault, file share, env file, and the prod/subscription guards. ha-devices, secrets-sync, register, command-access, restart, deploy and tf-apply all read it. - scripts/target.ts loads a Discord tool's env file in TypeScript, replacing the discord-env bash helper (still drops .env leaks). - scripts/env.ts answers recipes: show, shell, check-az, apply, deploy. - just env dev|prod; just deploy (deploy-dev kept as an alias, deploy prod refuses); just --list grouped with clear summaries. - Tests pin the names to the Terraform example tfvars and fail if the justfile spells an Azure name. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +- CHANGELOG.md | 4 + README.md | 6 +- docs/architecture.md | 12 +-- docs/home-assistant.md | 2 +- docs/infra.md | 14 +-- docs/runbook.md | 28 +++--- infra/README.md | 2 +- infra/bootstrap/README.md | 4 +- infra/envs/dev/README.md | 6 +- infra/envs/prod/README.md | 2 +- justfile | 157 ++++++++++++++--------------- scripts/command-access.ts | 7 +- scripts/env.ts | 80 +++++++++++++++ scripts/envs.test.ts | 163 ++++++++++++++++++++++++++++++ scripts/envs.ts | 177 +++++++++++++++++++++++++++++++++ scripts/ha-devices-lib.test.ts | 24 +---- scripts/ha-devices-lib.ts | 62 +----------- scripts/ha-devices.ts | 14 ++- scripts/register-commands.ts | 7 +- scripts/register-env.test.ts | 35 ------- scripts/register-env.ts | 28 ------ scripts/secrets-sync-lib.ts | 41 ++++---- scripts/secrets-sync.ts | 50 ++++------ scripts/share.test.ts | 27 +++++ scripts/share.ts | 60 +++++++++++ scripts/target.ts | 55 ++++++++++ 27 files changed, 746 insertions(+), 330 deletions(-) create mode 100644 scripts/env.ts create mode 100644 scripts/envs.test.ts create mode 100644 scripts/envs.ts delete mode 100644 scripts/register-env.test.ts delete mode 100644 scripts/register-env.ts create mode 100644 scripts/share.test.ts create mode 100644 scripts/share.ts create mode 100644 scripts/target.ts diff --git a/AGENTS.md b/AGENTS.md index 93247ad..dfa6de2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Before making structural changes, read: ## Current scope: phase 1 -Build **the core, the Discord adapter, and the Mastodon publisher (outbound only)**. Do not add Telegram, a database or account linking unless a human asks for it. Design for them, but don't build them. **Terraform:** bootstrap (`infra/bootstrap`, #8) is applied; the Container Apps module, `dev` (`infra/envs/dev`, #9, live) and `prod` (`infra/envs/prod`, #12) are in repo. **Do not apply prod** (`just tf-apply prod` refuses unless `PIXEL_APPLY_PROD=1`) and do not write prod secrets. **CD:** `just deploy-dev` rolls Azure `dev`; merges to `main` publish GHCR only. Prod and the docs site ship from the Release workflow (`workflow_dispatch` on `main`, Environment `prod` for Azure). Never copy the Mini / Pixel Dev Discord token into prod. Do **not** start Pixel locally on the same token while `pixel-dev` is up. Do **not** add Postgres or any database; access, schedules and planned linking are files. Do not treat old GitHub issues as ground truth if they disagree with this file, `docs/architecture.md` Deployment, `docs/infra.md`, or `docs/runbook.md`. +Build **the core, the Discord adapter, and the Mastodon publisher (outbound only)**. Do not add Telegram, a database or account linking unless a human asks for it. Design for them, but don't build them. **Terraform:** bootstrap (`infra/bootstrap`, #8) is applied; the Container Apps module, `dev` (`infra/envs/dev`, #9, live) and `prod` (`infra/envs/prod`, #12) are in repo. **Do not apply prod** (`just tf-apply prod` refuses unless `PIXEL_APPLY_PROD=1`) and do not write prod secrets. **CD:** `just deploy dev` rolls Azure `dev`; merges to `main` publish GHCR only. Prod and the docs site ship from the Release workflow (`workflow_dispatch` on `main`, Environment `prod` for Azure). Never copy the Mini / Pixel Dev Discord token into prod. Do **not** start Pixel locally on the same token while `pixel-dev` is up. Do **not** add Postgres or any database; access, schedules and planned linking are files. Do not treat old GitHub issues as ground truth if they disagree with this file, `docs/architecture.md` Deployment, `docs/infra.md`, or `docs/runbook.md`. ## Project at a glance @@ -41,13 +41,14 @@ just bump # cut [Unreleased] into a dated version and bump package.j just tf-validate # terraform fmt -check and validate bootstrap + envs/dev just tf-plan dev # plan the dev stack (needs Azure login; does not apply) just tf-apply dev # apply the dev stack — only when a maintainer says so -just deploy-dev # local GHCR push + Azure pixel-dev (never prod; do not also just dev on that token) +just deploy dev # local GHCR push + Azure pixel-dev (never prod; do not also just dev on that token) just register dev # slash commands on Pixel Dev / a test guild (.env.dev or .env) just register prod # slash commands on the production guild (.env.prod, PIXEL_ENV=prod) just ha-devices dev # pick HA devices and push them to that env's devices.yaml on Azure (also prod) +just env dev # where an env lives on Azure (names only); every laptop tool reads them from scripts/envs.ts ``` -If you need a new repeatable task, add a `just` recipe instead of documenting a raw command. +If you need a new repeatable task, add a `just` recipe instead of documenting a raw command. A recipe or script that acts on `dev` or `prod` takes the env as an argument and gets everything about it from [`scripts/envs.ts`](scripts/envs.ts): Azure names, the env file and the guards (prod, subscription). TypeScript tools call `loadTarget` (`scripts/target.ts`); recipes `eval "$(pnpm exec tsx scripts/env.ts shell )"`. Don't spell a subscription, resource group, vault or storage account anywhere else (a test checks the justfile). ## Layout @@ -69,7 +70,7 @@ src/ config/ # *.example.yaml committed; real admins.yaml / members.yaml gitignored data/ # runtime state; gitignored. Safe to delete EXCEPT schedules.yaml (the scheduled posts) content/info/ # markdown for /info topics, one file per topic; PUBLIC, reviewed -scripts/ # register-commands, validate-config, changelog check/bump +scripts/ # envs (the one definition of dev/prod), register-commands, validate-config, changelog check/bump docs/ # architecture, identity, infra, Home Assistant setup, ADRs, runbook; # also the VitePress website: see docs/AGENTS.md before changing it CHANGELOG.md # Keep a Changelog 1.1.0; most PRs add an Unreleased entry diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8668d..bf47d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t - `just pull dev|prod` and `just push dev|prod`: everything needed to work on an environment from any laptop. Each stack's `terraform.tfvars` is now shared as the Key Vault secret `terraform-tfvars` (a new `tooling` group in the secrets manifest that Terraform and the Container App never see). `just pull` also mirrors the share's `members.yaml`, `devices.yaml` and `announcement-routes.yaml` read-only. `just push` sends back the tfvars and app secrets, and refuses a tfvars that holds a secret, sets an undeclared variable or is for the other environment: secret Terraform inputs go in gitignored `secrets.auto.tfvars`. See the runbook's "Pull and push an environment". The first maintainer with a working tfvars runs `just push ` once to share it. +### Changed + +- Laptop tooling now shares one definition of each environment ([#116](https://github.com/pixelbar/pixel/issues/116)). `scripts/envs.ts` holds every Azure name (subscription, resource group, Container App, Key Vault, file share), the env file and the guards, and `just pull`/`push`, `secrets-pull`/`push`, `ha-devices`, `register`, `command-access`, `restart`, `deploy` and `tf-apply` all take them from there. New `just env dev|prod` shows where an environment lives. `just --list` is grouped (develop, environments, config, terraform, release, docs). `just deploy dev` replaces `just deploy-dev`, which still works for now; `just deploy prod` refuses because prod ships from the Release workflow. Discord tools load their env file in TypeScript rather than through a bash helper, still dropping anything `just` loaded from `.env`. + ## [0.15.0] - 2026-10-15 ### Added diff --git a/README.md b/README.md index 0e48b9b..496458e 100644 --- a/README.md +++ b/README.md @@ -158,16 +158,16 @@ Run `just` to list every recipe. | `just tf-apply dev` | Apply `dev` β€” only when a maintainer says so | | `just tf-plan prod` | Plan the `prod` stack | | `PIXEL_APPLY_PROD=1 just tf-apply prod` | Apply `prod` β€” maintainer only; agents must not | -| `just deploy-dev` | Local GHCR push, roll onto Azure `pixel-dev`, Sentry release, `just register dev` (never prod) | +| `just deploy dev` | Local GHCR push, roll onto Azure `pixel-dev`, Sentry release, `just register dev` (never prod) | | `just sentry-release` | Upload source maps; skipped unless Sentry org/token are set | ## CI -Every pull request and push to `main` runs [CI](.github/workflows/ci.yml). It runs `just check` (lint, type-check, tests with coverage thresholds) and the production build, and checks that the Docker image builds. Pull requests also run a [changelog](.github/workflows/changelog.yml) job that fails unless `CHANGELOG.md` changed, or the PR has the `skip-changelog` label. [Terraform](.github/workflows/terraform.yml) validates bootstrap, `infra/envs/dev` and `infra/envs/prod` only when `infra/`, that workflow, or the `justfile` change, then plans each env only if `infra/` Terraform files changed (fails closed without Environment `ARM_*`). [CD](.github/workflows/cd.yml) on `main` publishes `ghcr.io/pixelbar/pixel:` and uploads Sentry source maps (fails closed without org/token). It does **not** deploy prod. A [Release](.github/workflows/release.yml) is run by a maintainer on `main`: it cuts Unreleased, tags `vX.Y.Z`, opens a GitHub Release, publishes that image, rolls `pixel-prod` after Environment `prod` approval, and publishes the docs site. Azure `dev` is `just deploy-dev` from a local build. No Terraform apply on merge. +Every pull request and push to `main` runs [CI](.github/workflows/ci.yml). It runs `just check` (lint, type-check, tests with coverage thresholds) and the production build, and checks that the Docker image builds. Pull requests also run a [changelog](.github/workflows/changelog.yml) job that fails unless `CHANGELOG.md` changed, or the PR has the `skip-changelog` label. [Terraform](.github/workflows/terraform.yml) validates bootstrap, `infra/envs/dev` and `infra/envs/prod` only when `infra/`, that workflow, or the `justfile` change, then plans each env only if `infra/` Terraform files changed (fails closed without Environment `ARM_*`). [CD](.github/workflows/cd.yml) on `main` publishes `ghcr.io/pixelbar/pixel:` and uploads Sentry source maps (fails closed without org/token). It does **not** deploy prod. A [Release](.github/workflows/release.yml) is run by a maintainer on `main`: it cuts Unreleased, tags `vX.Y.Z`, opens a GitHub Release, publishes that image, rolls `pixel-prod` after Environment `prod` approval, and publishes the docs site. Azure `dev` is `just deploy dev` from a local build. No Terraform apply on merge. ## Deployment -Terraform **bootstrap** (`infra/bootstrap`, #8) is applied: remote state and GitHub OIDC. Container App **`pixel-dev`** is live (#9). **`infra/envs/prod`** (#12) is in repo; coding agents must not apply it. `just deploy-dev` (local GHCR push) rolls Azure `dev`; merges to `main` publish GHCR only. Prod and the docs site ship from a [Release](.github/workflows/release.yml). **No database.** See the [infra overview](docs/infra.md) (diagrams), [Deployment](docs/architecture.md#deployment), the [dev README](infra/envs/dev/README.md), the [prod README](infra/envs/prod/README.md), [ADR 0012](docs/adr/0012-prod-environment.md) and [ADR 0013](docs/adr/0013-release-workflow.md). If `pixel-dev` is up, do not also run `just dev` on the same Pixel Dev token. +Terraform **bootstrap** (`infra/bootstrap`, #8) is applied: remote state and GitHub OIDC. Container App **`pixel-dev`** is live (#9). **`infra/envs/prod`** (#12) is in repo; coding agents must not apply it. `just deploy dev` (local GHCR push) rolls Azure `dev`; merges to `main` publish GHCR only. Prod and the docs site ship from a [Release](.github/workflows/release.yml). **No database.** See the [infra overview](docs/infra.md) (diagrams), [Deployment](docs/architecture.md#deployment), the [dev README](infra/envs/dev/README.md), the [prod README](infra/envs/prod/README.md), [ADR 0012](docs/adr/0012-prod-environment.md) and [ADR 0013](docs/adr/0013-release-workflow.md). If `pixel-dev` is up, do not also run `just dev` on the same Pixel Dev token. ## Running and operating Pixel diff --git a/docs/architecture.md b/docs/architecture.md index aa96bc3..02c67ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -371,7 +371,7 @@ The text of the answer, in markdown… - **The file name is the topic's ID**: lowercase letters, digits and single dashes, up to 32 characters (`membership.md`). It's what people pick in `/info topic:`. - **Short answers that link to the canonical page.** Pixelbar's website is the source of truth for prices, rules and opening times, so each topic is a few lines plus a link. That way the bot doesn't become a second copy to keep up to date. - **Everything is checked at startup**, and Pixel refuses to start if anything is wrong, listing every problem at once: a bad file name, a missing title or summary, unknown keys, empty text, text over 4,000 characters, or more than 25 topics (Discord's limit for choices). CI loads the real `content/info/` folder in a test, so a broken edit fails the PR. -- **`/info` with no topic lists them all** with their summaries, in a private reply, since the menu is only for the person asking. The topic choices are built from the files, so they're registered with Discord: **editing a topic's text goes live on the next deploy**, and **adding, removing or renaming a topic also needs the commands re-registered** (`just register dev` or `just register prod`; `just deploy-dev` does the Dev guild). +- **`/info` with no topic lists them all** with their summaries, in a private reply, since the menu is only for the person asking. The topic choices are built from the files, so they're registered with Discord: **editing a topic's text goes live on the next deploy**, and **adding, removing or renaming a topic also needs the commands re-registered** (`just register dev` or `just register prod`; `just deploy dev` does the Dev guild). - **Content is baked into the Docker image** (`COPY content ./content`), so changing it means a deploy. - **The content is public.** This repository is public, so never put secrets (wifi passwords, door codes) or personal data in `content/`. That's why there are no member-only topics yet. - The text is shown as written, since it comes from this reviewed repository. (Text written by other people, like event titles, is always escaped.) @@ -471,16 +471,16 @@ How the hosted pieces fit together (diagrams of Azure, GitHub, Discord, Home Ass There is **no database**. Access, schedules and (planned) account linking are files. Do not add Postgres because an old issue said so (#15, #18). - **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:` (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. +- **Images:** local `just deploy dev` pushes `ghcr.io/pixelbar/pixel:` (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`). - **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). -- **Terraform layout:** `infra/bootstrap` (state storage, GitHub OIDC, applied) then `infra/modules/pixel`, `infra/envs/dev` (#9) and `infra/envs/prod` (#12). Secret values never go into Terraform state; apply can take them as ephemeral write-only inputs. Subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, West Europe. See [ADR 0008](adr/0008-terraform-bootstrap.md), [ADR 0009](adr/0009-container-apps-dev.md) and [ADR 0012](adr/0012-prod-environment.md). CD does **not** apply Terraform. After first create, **dev** apply ignores the Pixel container image; `just deploy-dev` / `az` updates it. Prod apply sets `container_image`. Coding agents must not apply prod (`PIXEL_APPLY_PROD=1` required). +- **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). +- **Terraform layout:** `infra/bootstrap` (state storage, GitHub OIDC, applied) then `infra/modules/pixel`, `infra/envs/dev` (#9) and `infra/envs/prod` (#12). Secret values never go into Terraform state; apply can take them as ephemeral write-only inputs. Subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, West Europe. See [ADR 0008](adr/0008-terraform-bootstrap.md), [ADR 0009](adr/0009-container-apps-dev.md) and [ADR 0012](adr/0012-prod-environment.md). CD does **not** apply Terraform. After first create, **dev** apply ignores the Pixel container image; `just deploy dev` / `az` updates it. Prod apply sets `container_image`. Coding agents must not apply prod (`PIXEL_APPLY_PROD=1` required). - **Home Assistant from Azure:** Tailscale sidecar into the space network is the preferred path (`tailscale_enabled`); `HOME_ASSISTANT_URL` can still be a Nabu Casa URL. Both URL and token or neither. Sidecar is userspace (no TUN). LAN MagicDNS through it is remaining work for #43. - **Cost (rough):** always-on `dev` is about €40–55/month West Europe without Tailscale, plus about €15 with the sidecar. Not a quote. - **CI (built):** [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs on every PR and on pushes to `main`. [`dorny/paths-filter`](https://github.com/dorny/paths-filter) with allowlists in [`.github/path-filters.yml`](../.github/path-filters.yml) decides which expensive jobs run: **check** (`just check` + `just build`) when app/scripts/config/tooling paths change; **Docker smoke** when image inputs change (`src/` including tests, `content/`, Dockerfile, lockfile, …). Jobs always start so required GitHub checks stay green when that job is not needed. [`changelog.yml`](../.github/workflows/changelog.yml) still runs on every PR. [`docs.yml`](../.github/workflows/docs.yml) Biome-lints and builds the site when `docs/` changes. [`terraform.yml`](../.github/workflows/terraform.yml) runs `just tf-validate` only when `infra/`, that workflow, or the `justfile` change, then `terraform plan` on each env via GitHub Environment `dev` / `prod` OIDC **only if `infra/` Terraform files changed** (markdown does not). Missing `ARM_*` **fails closed**. No apply on merge. GitHub is not Key Vault Secrets User, so a secret refresh would 403 (ADR 0012). Actions are pinned to commit SHAs. -- **CD:** `just deploy-dev` for Azure `dev` (local GHCR push + `az` + `just register dev` + Sentry release). Production slash commands: the Release's `register-prod` job (fallback `just register prod`). [`.github/workflows/cd.yml`](../.github/workflows/cd.yml) on `main` only, and only when an **image** path changed: publish with `GITHUB_TOKEN` (`packages: write` on that job) and upload Sentry source maps (fails closed if org/token are missing). **No Azure login and no `pixel-prod` update** on merge. [`.github/workflows/release.yml`](../.github/workflows/release.yml) (`workflow_dispatch` on `main`) cuts the version, publishes `:vX.Y.Z`, Environment `prod` approval rolls `pixel-prod` (`az`, min=max=1, no Terraform apply), a second approval runs `register-prod` (reads only `discord-token` from `pixel-prod-kv`), and publishes GitHub Pages. See [ADR 0010](adr/0010-ghcr-cd.md), [ADR 0012](adr/0012-prod-environment.md) and [ADR 0013](adr/0013-release-workflow.md). +- **CD:** `just deploy dev` for Azure `dev` (local GHCR push + `az` + `just register dev` + Sentry release). Production slash commands: the Release's `register-prod` job (fallback `just register prod`). [`.github/workflows/cd.yml`](../.github/workflows/cd.yml) on `main` only, and only when an **image** path changed: publish with `GITHUB_TOKEN` (`packages: write` on that job) and upload Sentry source maps (fails closed if org/token are missing). **No Azure login and no `pixel-prod` update** on merge. [`.github/workflows/release.yml`](../.github/workflows/release.yml) (`workflow_dispatch` on `main`) cuts the version, publishes `:vX.Y.Z`, Environment `prod` approval rolls `pixel-prod` (`az`, min=max=1, no Terraform apply), a second approval runs `register-prod` (reads only `discord-token` from `pixel-prod-kv`), and publishes GitHub Pages. See [ADR 0010](adr/0010-ghcr-cd.md), [ADR 0012](adr/0012-prod-environment.md) and [ADR 0013](adr/0013-release-workflow.md). ### CI and CD pipelines @@ -563,7 +563,7 @@ Record significant decisions as short ADRs in `docs/adr/NNNN-title.md`. Numbers | 0005 | Sentry for errors (no PII), pino to stdout, a rotating file and Sentry Logs | accepted | | 0008 | Terraform bootstrap: remote state (Azure AD, no shared keys) and GitHub OIDC (UAMI per env) | accepted | | 0009 | Container Apps module + `dev`: one replica, required Azure Files volume, Key Vault, optional Tailscale sidecar | accepted | -| 0010 | Temporary GHCR CD: local `just deploy-dev` β†’ Azure `dev`; `main` publishes GHCR only (prod CD off until #12) | accepted (prod-off superseded by 0012) | +| 0010 | Temporary GHCR CD: local `just deploy dev` β†’ Azure `dev`; `main` publishes GHCR only (prod CD off until #12) | accepted (prod-off superseded by 0012) | | 0011 | Home Assistant: official WebSocket client, non-admin token, no inbound webhooks, allow-list is the fence | accepted | | 0012 | Prod environment (`infra/envs/prod`) and approved CD to `pixel-prod` (same digest, no rebuild) | accepted (merge promotion superseded by 0013, registration by 0014) | | 0013 | Release workflow cuts versions and promotes prod + the docs site; merges to `main` do not roll `pixel-prod` | accepted (registration superseded by 0014) | diff --git a/docs/home-assistant.md b/docs/home-assistant.md index e5fd974..9718fe3 100644 --- a/docs/home-assistant.md +++ b/docs/home-assistant.md @@ -87,7 +87,7 @@ On Azure `dev`, put the token in Key Vault (`home-assistant-token`) and the URL ```sh just validate-config -just dev # or just deploy-dev for Azure pixel-dev +just dev # or just deploy dev for Azure pixel-dev ``` In Discord, `/admin status` should show **Connected** and `0 devices allowed`. If it warns that the token belongs to an admin, you signed in as the wrong HA user: delete that token, go back to step 2 as the non-admin user. diff --git a/docs/infra.md b/docs/infra.md index 822c87f..88f38ed 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -22,9 +22,9 @@ West Europe, subscription in bootstrap / env variables. Live **`pixel-dev`** is | Logs | `pixel-dev-logs` / `pixel-prod-logs` | Container Apps diagnostics, 30 days | | CAE | `pixel-dev-cae` / `pixel-prod-cae` | Consumption, **no VNet** | | App | `pixel-dev` / `pixel-prod` | `min = max` replicas **1**, `revision_mode = Single`, ingress **none** | -| Image | `ghcr.io/pixelbar/pixel:` (public) | No `.env`, no access YAML. Azure `dev` is rolled by `just deploy-dev`; `prod` by the Release workflow after approval | +| Image | `ghcr.io/pixelbar/pixel:` (public) | No `.env`, no access YAML. Azure `dev` is rolled by `just deploy dev`; `prod` by the Release workflow after approval | -**CD:** `just deploy-dev` on a laptop builds linux/amd64, pushes GHCR, `az containerapp update` on `pixel-dev` with min=max=1, then `just register dev`. Merges to `main` publish GHCR (`:` and `:main`) and Sentry source maps. They do **not** roll `pixel-prod`. A Release rebuilds from the bumped commit (`:vX.Y.Z`), publishes the docs site, then Environment `prod` approval rolls `pixel-prod` to **that digest** (no Terraform apply). Production slash commands: the Release's `register-prod` job, after a second approval (fallback `just register prod`). +**CD:** `just deploy dev` on a laptop builds linux/amd64, pushes GHCR, `az containerapp update` on `pixel-dev` with min=max=1, then `just register dev`. Merges to `main` publish GHCR (`:` and `:main`) and Sentry source maps. They do **not** roll `pixel-prod`. A Release rebuilds from the bumped commit (`:vX.Y.Z`), publishes the docs site, then Environment `prod` approval rolls `pixel-prod` to **that digest** (no Terraform apply). Production slash commands: the Release's `register-prod` job, after a second approval (fallback `just register prod`). ## Diagrams @@ -87,7 +87,7 @@ flowchart LR UAMIP --> KVP APPP --> FILESP APPP --> KVP - GHCR -->|just deploy-dev pulls SHA| APP + GHCR -->|just deploy dev pulls SHA| APP GHCR -->|Release after approval| APPP end @@ -130,7 +130,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). -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. +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. 7. **Sentry:** the app reads `SENTRY_DSN` from Key Vault. Heartbeat monitor `pixel-`. Source maps on `main` and on a Release use `SENTRY_AUTH_TOKEN` plus org/project variables. @@ -156,7 +156,7 @@ Do not raise replica count. Two replicas (or a laptop `just dev` plus this app) | `devices.yaml` | gitignored `config/home-assistant/devices.yaml` | `/app/persist/home-assistant/` | humans; `/admin reload` | | HA token / URL | HA long-lived token (non-admin user) + plain URL | KV + env | humans | | `SENTRY_DSN` | Sentry project β†’ KV | env | humans | -| Image | GHCR (`just deploy-dev`, CD on `main`, or a Release) | Container App | `az containerapp update` | +| Image | GHCR (`just deploy dev`, CD on `main`, or a Release) | Container App | `az containerapp update` | | `content/info/*.md` | git, `COPY` in the Dockerfile | image | next image | | `data/schedules.yaml` | created when someone uses `/schedule` | `/app/persist/data/` | Pixel; **not safe to delete** | | `data/announcement-routes.yaml` | `/admin announcements set` | `/app/persist/data/` | Pixel; missing β†’ only the channels from the environment | @@ -168,7 +168,7 @@ How to seed the share and vault: [dev README](../infra/envs/dev/README.md). ## One replica, no public ingress -Hard constraints in the module (not variables): `min_replicas = 1`, `max_replicas = 1`. `just deploy-dev` re-asserts that and refuses prod names / `PIXEL_ENV=prod`. The Release prod job re-asserts the same on `pixel-prod`. +Hard constraints in the module (not variables): `min_replicas = 1`, `max_replicas = 1`. `just deploy dev` re-asserts that, and `just deploy prod` refuses (prod ships from the Release workflow). The Release prod job re-asserts the same on `pixel-prod`. Ingress is off. Discord, SpaceAPI, Sentry, GHCR, Key Vault, and today’s Nabu Casa path all use ordinary Azure egress. Nothing in Discord connects *to* Azure. @@ -195,7 +195,7 @@ just tf-apply dev # maintainer; first create / vault / volume (not the r # 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 -just deploy-dev # this tree β†’ GHCR SHA β†’ pixel-dev, then just register dev +just deploy dev # this tree β†’ GHCR SHA β†’ pixel-dev, then just register dev ``` Do not apply Terraform from CD. Do not copy `pixel-dev-kv` tokens into prod. After first create, **dev** apply ignores the Pixel container image (#73). Prod apply sets `container_image`; a Release then rolls the published SHA. diff --git a/docs/runbook.md b/docs/runbook.md index 4220a6e..f642f36 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -2,7 +2,7 @@ How to run Pixel day to day and what to do when something goes wrong. It is written for volunteers: anyone with the right access should be able to follow it without the original author. -**This is a living document.** Bootstrap (#8) is applied. Container App `pixel-dev` is live (#9). `infra/envs/prod` is in repo (#12). CD: `just deploy-dev` rolls Azure `dev`; merges to `main` publish GHCR when an image path changed. Prod and the docs site ship from the Release workflow. Every procedure has two parts: +**This is a living document.** Bootstrap (#8) is applied. Container App `pixel-dev` is live (#9). `infra/envs/prod` is in repo (#12). CD: `just deploy dev` rolls Azure `dev`; merges to `main` publish GHCR when an image path changed. Prod and the docs site ship from the Release workflow. Every procedure has two parts: - **Today:** what works now, with Pixel run locally or in a container you start yourself. Everything here has been checked against the code. - **🚧 Azure:** a marker for the steps that still depend on hosting that is not applied, or on #12. Search for `🚧` to find what's left. @@ -49,7 +49,7 @@ Before you change anything on **prod**, read "Rules of thumb" below. | --- | --- | | **Environments** | `dev` (Pixel Dev bot, test guild) and `prod` (production Pixel bot, production guild). Separate bots, tokens and secrets. Never copy the Dev token into prod. | | **Who is on the hook when the bot is down** | 🚧 Not decided. Put a named contact (and a backup) here, and where to reach them. | -| **Where Pixel runs** | Azure `pixel-dev` (one replica, no public ingress) plus `pixel-prod` after a maintainer apply, plus any laptop `just dev`. Subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, West Europe. Resource groups `pixel-bootstrap`, `pixel-dev`, `pixel-prod`. `just deploy-dev` rolls `pixel-dev`. **Do not** also run `just dev` on the Pixel Dev token while that app is up. A Release rolls `pixel-prod` after Environment `prod` approval. Diagrams: [`infra.md`](infra.md). | +| **Where Pixel runs** | Azure `pixel-dev` (one replica, no public ingress) plus `pixel-prod` after a maintainer apply, plus any laptop `just dev`. Subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, West Europe. Resource groups `pixel-bootstrap`, `pixel-dev`, `pixel-prod`. `just deploy dev` rolls `pixel-dev`. **Do not** also run `just dev` on the Pixel Dev token while that app is up. A Release rolls `pixel-prod` after Environment `prod` approval. Diagrams: [`infra.md`](infra.md). | | **Where the code and CI are** | GitHub, `pixelbar/pixel`. CI runs `just check` and/or a Docker smoke build only when those jobs' path allowlists match. Docs PRs run Biome + the site build. CD on `main` publishes `ghcr.io/pixelbar/pixel:` when an image path changed. | | **Access you may need** | The Discord Developer Portal (bot token), a Discord role that can manage the server, the Sentry project, the GitHub repo, the Home Assistant admin account, Owner on the Pixel Azure subscription (to apply bootstrap), and the host. | @@ -66,18 +66,18 @@ Before you change anything on **prod**, read "Rules of thumb" below. | A door action looks wrong | [If a door action looks wrong](#if-a-door-action-looks-wrong) | | Re-read the access lists and devices after a hand edit | `/admin reload` (admins only). Or restart | | Check the config files without starting Pixel | `just validate-config` | -| Make Discord pick up new or changed commands | Prod: a Release does it (the `register-prod` job, after a second Environment `prod` approval). Dev: `just deploy-dev` does it. By hand (rollbacks, or before `github_register_principal_id` is applied): `just register dev` or `just register prod`. Prod uses `.env.prod` (`PIXEL_ENV=prod`), never the Mini Pixel Dev token | +| Make Discord pick up new or changed commands | Prod: a Release does it (the `register-prod` job, after a second Environment `prod` approval). Dev: `just deploy dev` does it. By hand (rollbacks, or before `github_register_principal_id` is applied): `just register dev` or `just register prod`. Prod uses `.env.prod` (`PIXEL_ENV=prod`), never the Mini Pixel Dev token | | Add Pixel to a Discord server | [Adding Pixel to a Discord server](#adding-pixel-to-a-discord-server) | | Turn closing-time posts on or off, or change the text | [Closing-time](#closing-time) | | Look up a person | `/admin level get user:` (admins only) | | See what changed between versions | [`CHANGELOG.md`](../CHANGELOG.md) | | Cut a production release | GitHub β†’ Actions β†’ **Release** β†’ Run workflow on `main` | -Useful `just` recipes: `just dev`, `just check`, `just validate-config`, `just check-changelog`, `just bump`, `just register dev`, `just register prod`, `just sentry-release`, `just command-access dev`, `just docker-build`, `just deploy-dev`, `just tf-validate`, `just tf-plan prod`. Run `just` to list them all. +Useful `just` recipes: `just dev`, `just check`, `just validate-config`, `just check-changelog`, `just bump`, `just register dev`, `just register prod`, `just sentry-release`, `just command-access dev`, `just docker-build`, `just deploy dev`, `just tf-validate`, `just tf-plan prod`, `just env dev`. Run `just` to list them all, grouped by what they act on. Every recipe that takes `dev` or `prod` gets that environment's Azure names, env file and guards from [`scripts/envs.ts`](../scripts/envs.ts); `just env dev` (or `prod`) prints them. `just deploy-dev` still works as the old name for `just deploy dev`. ## Running Pixel today -**Azure `dev` is live** (`pixel-dev`, one replica). Prefer `just deploy-dev` for that bot. If `pixel-dev` is up, do **not** also `just dev` on the same Pixel Dev token (two bots would both answer). +**Azure `dev` is live** (`pixel-dev`, one replica). Prefer `just deploy dev` for that bot. If `pixel-dev` is up, do **not** also `just dev` on the same Pixel Dev token (two bots would both answer). To run Pixel from a laptop (your own bot, or the Pixel Dev bot only when the Azure app is stopped): @@ -211,7 +211,7 @@ Someone unlocked (or locked) a door who shouldn't have, a door moved when nobody **Who can apply:** Owner on subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`. Later changes: [`infra/bootstrap/README.md`](../infra/bootstrap/README.md). -**GitHub Environment `prod`** (reviewers, branch `main`) needs `ARM_CLIENT_ID`, `ARM_TENANT_ID` and `ARM_SUBSCRIPTION_ID` as **variables**, not secrets. Values: [`infra/bootstrap/README.md`](../infra/bootstrap/README.md). Azure `dev` uses local `az` (`just tf-apply dev`, `just deploy-dev`). Terraform plan on PRs uses Environment `dev` / `prod` `ARM_*` and **fails closed** if they are missing. The prod plan runs with `-refresh=false`: GitHub is not Key Vault Secrets User on `pixel-prod-kv` (ADR 0012; since ADR 0014 it may read the `discord-token` secret only), so refreshing the other secrets in state would fail with 403. It plans with the example tfvars, so it shows the Key Vault secrets being removed; that is expected and it never applies. Before applying prod, run `just tf-plan prod` locally for the real plan. Coding agents must not apply prod. +**GitHub Environment `prod`** (reviewers, branch `main`) needs `ARM_CLIENT_ID`, `ARM_TENANT_ID` and `ARM_SUBSCRIPTION_ID` as **variables**, not secrets. Values: [`infra/bootstrap/README.md`](../infra/bootstrap/README.md). Azure `dev` uses local `az` (`just tf-apply dev`, `just deploy dev`). Terraform plan on PRs uses Environment `dev` / `prod` `ARM_*` and **fails closed** if they are missing. The prod plan runs with `-refresh=false`: GitHub is not Key Vault Secrets User on `pixel-prod-kv` (ADR 0012; since ADR 0014 it may read the `discord-token` secret only), so refreshing the other secrets in state would fail with 403. It plans with the example tfvars, so it shows the Key Vault secrets being removed; that is expected and it never applies. Before applying prod, run `just tf-plan prod` locally for the real plan. Coding agents must not apply prod. **Do not** apply bootstrap from GitHub Actions, and do not run a second Pixel bot against the same Discord token. @@ -229,11 +229,11 @@ The first apply after #73 **moves** the Container App address (`azurerm_containe After apply, Discord still will not answer until Key Vault secrets exist (`write_secrets` or `az`), `members.yaml` is on the share, an image exists, commands are registered, and nothing else is using that bot token. -**On `dev`, the image is owned by `just deploy-dev`, not by apply** ([#73](https://github.com/pixelbar/pixel/issues/73), [ADR 0010](adr/0010-ghcr-cd.md)). First create uses `container_image` (default `:main`). After that, Terraform **ignores** the Pixel container image on the **dev** app only. A later `just tf-apply dev` still updates vault, volume, env and the replica pin; it does **not** roll the bot back to a stale SHA in gitignored `terraform.tfvars`. Do not apply Terraform from CD to "fix" that drift. Do not bump `container_image` expecting a **dev** apply to change what is running. +**On `dev`, the image is owned by `just deploy dev`, not by apply** ([#73](https://github.com/pixelbar/pixel/issues/73), [ADR 0010](adr/0010-ghcr-cd.md)). First create uses `container_image` (default `:main`). After that, Terraform **ignores** the Pixel container image on the **dev** app only. A later `just tf-apply dev` still updates vault, volume, env and the replica pin; it does **not** roll the bot back to a stale SHA in gitignored `terraform.tfvars`. Do not apply Terraform from CD to "fix" that drift. Do not bump `container_image` expecting a **dev** apply to change what is running. **Prod** is `azurerm_container_app.managed`: apply **will** set `container_image`. Do not copy the dev ignore onto prod. Coding agents must not apply prod (`PIXEL_APPLY_PROD=1`). -- **Change the running `dev` image:** `just deploy-dev`, or `az containerapp update -g pixel-dev -n pixel-dev --image ghcr.io/pixelbar/pixel: --min-replicas 1 --max-replicas 1`. +- **Change the running `dev` image:** `just deploy dev`, or `az containerapp update -g pixel-dev -n pixel-dev --image ghcr.io/pixelbar/pixel: --min-replicas 1 --max-replicas 1`. - **See what is running:** `az containerapp show -g pixel-dev -n pixel-dev --query "properties.template.containers[0].image" -o tsv` - Keep min=max replicas = 1. @@ -251,7 +251,7 @@ That restarts the running revision (`az containerapp revision restart`); it does **Rough cost:** about €40–55/month for always-on `dev` without Tailscale, plus about €15 with the sidecar. West Europe. Not a quote. -**Prod** (`infra/envs/prod`, #12): a maintainer applies with `PIXEL_APPLY_PROD=1 just tf-apply prod` after a **separate** Discord application, prod Key Vault secrets, and access lists exist. Coding agents must not apply prod. `just deploy-dev` still rolls only `pixel-dev`. Merges to `main` publish GHCR only. A [Release](../.github/workflows/release.yml) cuts the version and, after Environment `prod` approval, rolls `pixel-prod` (`az`, min=max=1, no Terraform apply). The Release then registers prod commands (ADR 0014) once a maintainer has applied `github_register_principal_id`; until then, `just register prod` (`.env.prod`), never the Mini Pixel Dev token. Steps: [`infra/envs/prod/README.md`](../infra/envs/prod/README.md). +**Prod** (`infra/envs/prod`, #12): a maintainer applies with `PIXEL_APPLY_PROD=1 just tf-apply prod` after a **separate** Discord application, prod Key Vault secrets, and access lists exist. Coding agents must not apply prod. `just deploy dev` still rolls only `pixel-dev`. Merges to `main` publish GHCR only. A [Release](../.github/workflows/release.yml) cuts the version and, after Environment `prod` approval, rolls `pixel-prod` (`az`, min=max=1, no Terraform apply). The Release then registers prod commands (ADR 0014) once a maintainer has applied `github_register_principal_id`; until then, `just register prod` (`.env.prod`), never the Mini Pixel Dev token. Steps: [`infra/envs/prod/README.md`](../infra/envs/prod/README.md). ## Scheduled posts @@ -424,20 +424,20 @@ All of them are gitignored. The share files are **read-only mirrors**: edit the **Roll back to the previous version:** start the previous image tag instead (keep the last few tags, and don't prune them). Rolling back is safe for Pixel's own data: `members.yaml` and the other files stay as they are. If the older version doesn't know a capability that is in `members.yaml`, it logs a warning and ignores it. After a rollback that changes which commands exist, run `just register dev` or `just register prod` so Discord matches. -**After a deploy that adds, removes or renames commands:** a Release registers prod and `just deploy-dev` registers dev. Otherwise run `just register dev` or `just register prod` against that environment's guild. Topic changes to `/info` need it too. +**After a deploy that adds, removes or renames commands:** a Release registers prod and `just deploy dev` registers dev. Otherwise run `just register dev` or `just register prod` against that environment's guild. Topic changes to `/info` need it too. **Deploys must stop the old instance before starting the new one,** so two never answer at once. Expect a short gap while it restarts. Overlap during a revision swap is still #11. **Azure CD ([ADR 0010](adr/0010-ghcr-cd.md), [ADR 0013](adr/0013-release-workflow.md)):** -- **`dev`:** from this tree, `just deploy-dev`. Builds **linux/amd64** (Azure cannot run Mini arm64 images), pushes `ghcr.io/pixelbar/pixel:` (or `dev-dirty-*` if the tree is dirty) and `:dev`, then `az containerapp update` on `pixel-dev` only (min=max=1). Then `just register dev` against the Pixel Dev guild from `.env.dev` (or `.env`), then `just sentry-release` (skipped without org/token; a Sentry failure still fails the recipe but after commands are registered). Needs `gh auth refresh --scopes write:packages,read:packages,repo`, Docker, `az` on subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, and `.env` for register. Does not start Pixel. CI does not deploy pull requests (so a local SHA is not overwritten by another CI head). -- **`prod`:** not on merge. A maintainer runs **Actions β†’ Release β†’ Run workflow** on `main` ([`.github/workflows/release.yml`](../.github/workflows/release.yml)). That cuts Unreleased, tags `vX.Y.Z`, opens a GitHub Release, publishes `:` / `:vX.Y.Z` / `:main`, uploads Sentry source maps, publishes the docs site, then GitHub Environment `prod` (reviewers, branch `main`) rolls `pixel-prod` to **that same digest**. No Terraform apply. Fails closed without `ARM_*` or if `pixel-prod` does not exist yet. Never copy `pixel-dev-kv` or the Mini local Discord token into prod. After the roll, a second Environment `prod` approval runs **Register prod slash commands**: it reads the app ID, guild ID and `discord-token` reference from `pixel-prod` itself, refuses unless `PIXEL_ENV=prod`, and runs `just register-ci prod` (example access lists, throwaway data dir). It fails closed until `github_register_principal_id` is applied in `infra/envs/prod` ([ADR 0014](adr/0014-release-registers-commands.md)); then **Re-run failed jobs**, or `just register prod` with `.env.prod`. Merges to `main` ([`.github/workflows/cd.yml`](../.github/workflows/cd.yml)) publish the merge SHA and `:main` and upload Sentry maps when an image path changed; they do not log in to Azure. +- **`dev`:** from this tree, `just deploy dev`. Builds **linux/amd64** (Azure cannot run Mini arm64 images), pushes `ghcr.io/pixelbar/pixel:` (or `dev-dirty-*` if the tree is dirty) and `:dev`, then `az containerapp update` on `pixel-dev` only (min=max=1). Then `just register dev` against the Pixel Dev guild from `.env.dev` (or `.env`), then `just sentry-release` (skipped without org/token; a Sentry failure still fails the recipe but after commands are registered). Needs `gh auth refresh --scopes write:packages,read:packages,repo`, Docker, `az` on subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, and `.env` for register. Does not start Pixel. CI does not deploy pull requests (so a local SHA is not overwritten by another CI head). +- **`prod`:** not on merge. `just deploy prod` refuses. A maintainer runs **Actions β†’ Release β†’ Run workflow** on `main` ([`.github/workflows/release.yml`](../.github/workflows/release.yml)). That cuts Unreleased, tags `vX.Y.Z`, opens a GitHub Release, publishes `:` / `:vX.Y.Z` / `:main`, uploads Sentry source maps, publishes the docs site, then GitHub Environment `prod` (reviewers, branch `main`) rolls `pixel-prod` to **that same digest**. No Terraform apply. Fails closed without `ARM_*` or if `pixel-prod` does not exist yet. Never copy `pixel-dev-kv` or the Mini local Discord token into prod. After the roll, a second Environment `prod` approval runs **Register prod slash commands**: it reads the app ID, guild ID and `discord-token` reference from `pixel-prod` itself, refuses unless `PIXEL_ENV=prod`, and runs `just register-ci prod` (example access lists, throwaway data dir). It fails closed until `github_register_principal_id` is applied in `infra/envs/prod` ([ADR 0014](adr/0014-release-registers-commands.md)); then **Re-run failed jobs**, or `just register prod` with `.env.prod`. Merges to `main` ([`.github/workflows/cd.yml`](../.github/workflows/cd.yml)) publish the merge SHA and `:main` and upload Sentry maps when an image path changed; they do not log in to Azure. - GHCR: CI uses `GITHUB_TOKEN` (`packages: write` on the publish job only). Local uses `gh`. The package is **public** (no secrets in the image) so Azure can pull. - Prod Azure login is GitHub Environment OIDC (`ARM_*` variables on Environment `prod`). `dev` uses local `az`. Terraform plan on PRs uses Environment `dev` / `prod` `ARM_*` and fails closed if they are missing. -- Neither path applies Terraform. After first create, **dev** apply **ignores** the Pixel container image, so `just tf-apply dev` cannot silently replace the SHA `just deploy-dev` pinned. Prod apply sets `container_image`; a later Release roll moves it again. Dev rollback is `az`, not a tfvars bump. +- Neither path applies Terraform. After first create, **dev** apply **ignores** the Pixel container image, so `just tf-apply dev` cannot silently replace the SHA `just deploy dev` pinned. Prod apply sets `container_image`; a later Release roll moves it again. Dev rollback is `az`, not a tfvars bump. - Rollback: `az containerapp update -g pixel-dev -n pixel-dev --image ghcr.io/pixelbar/pixel:` (same for `pixel-prod` with a previous SHA or `:vX.Y.Z`). Confirm one replica: `az containerapp replica list -g pixel-dev -n pixel-dev -o table`. After a rollback that changes commands, `just register dev` or `just register prod` so Discord matches. - If `pixel-dev` is up, do not start the Mini bot (`just dev`) on the same Pixel Dev token. -- **Sentry releases:** `just sentry-release` / the CD job / the Release job. Needs secret `SENTRY_AUTH_TOKEN` and variables `SENTRY_ORG`, `SENTRY_PROJECT`. Those jobs **fail closed** if any are missing. Local `just sentry-release` still skips so `just deploy-dev` can roll without them. +- **Sentry releases:** `just sentry-release` / the CD job / the Release job. Needs secret `SENTRY_AUTH_TOKEN` and variables `SENTRY_ORG`, `SENTRY_PROJECT`. Those jobs **fail closed** if any are missing. Local `just sentry-release` still skips so `just deploy dev` can roll without them. ## CI and CD diff --git a/infra/README.md b/infra/README.md index 679e996..2169240 100644 --- a/infra/README.md +++ b/infra/README.md @@ -18,4 +18,4 @@ Region is West Europe. The Azure subscription is in bootstrap / env variables. - Prod: [`envs/prod/README.md`](envs/prod/README.md) - Runbook: [Terraform bootstrap](../docs/runbook.md#terraform-bootstrap), [Azure Container App (`dev`)](../docs/runbook.md#azure-container-app-dev), [Adding Pixel to a Discord server](../docs/runbook.md#adding-pixel-to-a-discord-server) -`just tf-validate`, `just tf-plan dev`, `just tf-apply dev`. Prod: `just tf-plan prod`, `PIXEL_APPLY_PROD=1 just tf-apply prod`. Images: `just deploy-dev` for Azure `dev`; a Release after Environment `prod` approval for `pixel-prod`. After first create, **dev** apply ignores the Pixel container image (#73); prod apply sets `container_image`. CD does not apply Terraform. +`just tf-validate`, `just tf-plan dev`, `just tf-apply dev`. Prod: `just tf-plan prod`, `PIXEL_APPLY_PROD=1 just tf-apply prod`. Images: `just deploy dev` for Azure `dev`; a Release after Environment `prod` approval for `pixel-prod`. After first create, **dev** apply ignores the Pixel container image (#73); prod apply sets `container_image`. CD does not apply Terraform. diff --git a/infra/bootstrap/README.md b/infra/bootstrap/README.md index c98fd83..098ada3 100644 --- a/infra/bootstrap/README.md +++ b/infra/bootstrap/README.md @@ -68,7 +68,7 @@ In each GitHub Environment (`dev`, `prod`), set **variables** (not secrets). App Create Environment `prod` if it is missing (reviewers, deploying branch `main`). Terraform plan on PRs uses Environment `dev` / `prod` and **fails closed** without `ARM_*`. Do not store an Azure client secret. -Azure `dev` image rollouts are `just deploy-dev` with local `az`, not these variables. [CD](../../.github/workflows/cd.yml) on `main` publishes GHCR only. The [Release](../../.github/workflows/release.yml) workflow rolls `pixel-prod` after Environment `prod` approval. Neither applies Terraform. +Azure `dev` image rollouts are `just deploy dev` with local `az`, not these variables. [CD](../../.github/workflows/cd.yml) on `main` publishes GHCR only. The [Release](../../.github/workflows/release.yml) workflow rolls `pixel-prod` after Environment `prod` approval. Neither applies Terraform. ## Recover @@ -79,5 +79,5 @@ Azure `dev` image rollouts are `just deploy-dev` with local `az`, not these vari ## What this is not - Not the Container App, volume, Key Vault, or Tailscale sidecar (`infra/envs/dev`, #9 / #43). -- Not the Container App image rollout (`just deploy-dev` for `dev`; [Release](../../.github/workflows/release.yml) after Environment `prod` approval for `pixel-prod`). +- Not the Container App image rollout (`just deploy dev` for `dev`; [Release](../../.github/workflows/release.yml) after Environment `prod` approval for `pixel-prod`). - Not Postgres. Pixel’s state is files on a volume. diff --git a/infra/envs/dev/README.md b/infra/envs/dev/README.md index e75af9a..bd468f6 100644 --- a/infra/envs/dev/README.md +++ b/infra/envs/dev/README.md @@ -51,7 +51,7 @@ just tf-apply dev The Container App references Key Vault secrets by URI. Values are either **ephemeral apply inputs** (`write_secrets = true`, not stored in state) or set later with `az` / the portal. `terraform.example.tfvars` leaves `write_secrets` off so CI plan cannot clobber a real vault. -`container_image` is the **first-create** pin for this **dev** stack only. After that, Terraform ignores the Pixel container image so a later apply cannot roll `just deploy-dev` backward (#73). Change the running image with `just deploy-dev` or `az containerapp update`, not a tfvars bump. Do not apply Terraform from CD to "fix" that drift. Prod (#12) apply will set `container_image` (no ignore on that resource). +`container_image` is the **first-create** pin for this **dev** stack only. After that, Terraform ignores the Pixel container image so a later apply cannot roll `just deploy dev` backward (#73). Change the running image with `just deploy dev` or `az containerapp update`, not a tfvars bump. Do not apply Terraform from CD to "fix" that drift. Prod (#12) apply will set `container_image` (no ignore on that resource). Do not run Pixel locally against the same Discord token while this app is up. @@ -129,7 +129,7 @@ Dev must not control the real doors. Use a test HA, or leave HA off. 1. Key Vault secrets exist (at least `discord-token` and `admins-yaml`), via `write_secrets` or `az`. 2. `members.yaml` (and HA files if used) are on the share. 3. The image exists (`ghcr.io/pixelbar/pixel:`). If that GHCR package is still **private**, set `container_registry_server` / `container_registry_username` and create Key Vault `ghcr-pull-token` (a `read:packages` token; Azure otherwise gets 401). -4. `just register dev` has been run against the Dev guild (or `just deploy-dev` does it). +4. `just register dev` has been run against the Dev guild (or `just deploy dev` does it). 5. Nobody else is connected with that bot token. Then: `az containerapp revision list -g pixel-dev -n pixel-dev -o table` should show one replica. `/ping` in the test guild. @@ -148,7 +148,7 @@ About **€40–55/month** for this always-on 0.5 vCPU / 1 Gi replica, Log Analy ## What this is not -- Not GHCR publish or prod CD. `just deploy-dev` rolls this app's image; **dev** apply leaves that pin alone after first create (#73). Prod apply sets `container_image`. +- Not GHCR publish or prod CD. `just deploy dev` rolls this app's image; **dev** apply leaves that pin alone after first create (#73). Prod apply sets `container_image`. - Not stop-then-start during deploys (#11). This stack only pins min=max replicas to 1 and `revision_mode = Single`. - Not `prod` (see [`infra/envs/prod`](../prod/)). - Not Postgres. diff --git a/infra/envs/prod/README.md b/infra/envs/prod/README.md index 5186ac1..c75779b 100644 --- a/infra/envs/prod/README.md +++ b/infra/envs/prod/README.md @@ -77,7 +77,7 @@ Then: `az containerapp replica list -g pixel-prod -n pixel-prod -o table` must s ## What this is not -- Not `just deploy-dev` (that recipe refuses prod names). +- Not `just deploy` (it refuses prod: prod ships from the Release workflow). - Not a clone of `pixel-dev-kv` or the Pixel Dev Discord application. - Not stop-then-start during deploys (#11). - Not Postgres. diff --git a/justfile b/justfile index f29e862..51f17c4 100644 --- a/justfile +++ b/justfile @@ -5,49 +5,62 @@ default: @just --list # Install dependencies +[group('develop')] install: pnpm install # Run Pixel locally with hot reload (also restarts when access lists change) +[group('develop')] dev: pnpm exec tsx watch --include 'config/*.yaml' --env-file=.env --import ./src/instrument.ts src/index.ts # Compile TypeScript to dist/ +[group('develop')] build: rm -rf dist pnpm exec tsc -p tsconfig.build.json # Run the compiled build (after `just build`) +[group('develop')] start: node --env-file=.env --import ./dist/instrument.js dist/index.js # Run tests (pass extra args through, e.g. `just test --watch`) +[group('develop')] test *args: pnpm exec vitest run {{args}} # Run tests with coverage; fails below the thresholds in vitest.config.ts +[group('develop')] coverage: pnpm exec vitest run --coverage # Lint and check formatting +[group('develop')] lint: pnpm exec biome check . # Fix formatting and safe lint issues +[group('develop')] fmt: pnpm exec biome check --write . -terraform fmt -recursive infra # Type-check everything, including tests and scripts +[group('develop')] typecheck: pnpm exec tsc --noEmit # Everything CI runs for the Node app: lint, types, tests with coverage thresholds. # GitHub runs this only when a check-path matches (.github/path-filters.yml). +[group('develop')] +[doc("Everything CI runs for the Node app: lint, types, tests with coverage thresholds")] check: lint typecheck coverage # Fail unless this branch updates CHANGELOG.md vs the base (default origin/main). # CI sets SKIP_CHANGELOG when the PR has the skip-changelog label. +[group('release')] +[doc("Fail unless this branch updates CHANGELOG.md vs the base (default origin/main)")] check-changelog *args: pnpm exec tsx scripts/check-changelog.ts {{args}} @@ -55,10 +68,13 @@ check-changelog *args: # Added β†’ minor; Fixed/Changed β†’ patch. Never major before 1.0. # `--commit` also creates the release commit and tag (used by the Release workflow). # `--notes-file PATH` writes the GitHub Release body for the cut (or current) version. +[group('release')] +[doc("Cut [Unreleased] into a dated version and bump package.json")] bump *args: pnpm exec tsx scripts/bump-version.ts {{args}} # Format-check and validate bootstrap + envs/dev + envs/prod (needs Terraform 1.11+; CI runs this when infra/ changes) +[group('terraform')] tf-validate: terraform fmt -check -recursive infra terraform -chdir=infra/bootstrap init -backend=false -input=false @@ -69,7 +85,9 @@ tf-validate: terraform -chdir=infra/envs/prod validate # Plan an env stack (dev or prod). Needs Azure CLI signed in. Does not apply. +[group('terraform')] tf-plan env: + @pnpm exec tsx scripts/env.ts shell {{env}} >/dev/null terraform -chdir=infra/envs/{{env}} init -backend-config=backend.azurerm.example.hcl -input=false terraform -chdir=infra/envs/{{env}} plan @@ -78,63 +96,35 @@ tf-plan env: # does not change the Pixel container image (just deploy-dev / az owns that # pin). Prod apply still sets container_image. Coding agents must not apply # prod: PIXEL_APPLY_PROD=1 is required. +[group('terraform')] +[doc("Apply an env stack (maintainers only; prod needs PIXEL_APPLY_PROD=1)")] tf-apply env: #!/usr/bin/env bash set -euo pipefail - if [ "{{env}}" = "prod" ] && [ "${PIXEL_APPLY_PROD:-}" != "1" ]; then - echo "Refusing just tf-apply prod. Coding agents must not apply prod or write prod secrets." - echo "A maintainer runs: PIXEL_APPLY_PROD=1 just tf-apply prod" - exit 1 - fi + pnpm exec tsx scripts/env.ts apply {{env}} terraform -chdir=infra/envs/{{env}} init -backend-config=backend.azurerm.example.hcl -input=false terraform -chdir=infra/envs/{{env}} apply # Validate the access list files and the Home Assistant devices file +[group('config')] validate-config: pnpm exec tsx scripts/validate-config.ts "${PIXEL_ADMINS_FILE:-config/admins.yaml}" "${PIXEL_MEMBERS_FILE:-config/members.yaml}" "${PIXEL_HOME_ASSISTANT_DIR:-config/home-assistant}" # Validate infra/secrets.manifest.yaml (names only; no Azure login, no values) +[group('config')] validate-secrets: pnpm exec tsx scripts/validate-secrets.ts -# Load `.env.dev` or `.env.prod` for a Discord laptop script. just dotenv-load -# would otherwise let `.env` win over Node `--env-file`. Dev falls back to `.env`. -[private] -discord-env env script: - #!/usr/bin/env bash - set -euo pipefail - if [ "{{env}}" != "dev" ] && [ "{{env}}" != "prod" ]; then - echo "Usage: just register|command-access|ha-devices dev|prod" - exit 1 - fi - file=".env.{{env}}" - if [ "{{env}}" = "dev" ] && [ ! -f "${file}" ] && [ -f .env ]; then - file=".env" - echo "Using .env (no .env.dev). Copy .env.example to .env.dev to keep Pixel Dev separate from a local bot." - fi - if [ ! -f "${file}" ]; then - echo "Missing ${file}." - if [ "{{env}}" = "prod" ]; then - echo "Copy .env.example to .env.prod and set PIXEL_ENV=prod plus the production Discord application. Never copy .env or .env.dev." - else - echo "Copy .env.example to .env.dev and fill in the Pixel Dev / test Discord application." - fi - exit 1 - fi - # Node --env-file does not override existing variables. Drop anything - # dotenv-load took from .env so it cannot leak into the other bot. - for src in .env .env.dev .env.prod; do - if [ -f "${src}" ]; then - while IFS= read -r key; do - unset "${key}" || true - done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "${src}" | cut -d= -f1) - fi - done - pnpm exec tsx --env-file="${file}" {{script}} "{{env}}" +# Names only, never a secret. Every laptop tool reads these from scripts/envs.ts. +# Show where an env lives (env file, subscription, resource group, app, vault, share): `just env dev` +[group('environments')] +env env: + @pnpm exec tsx scripts/env.ts show {{env}} # Register slash commands: `just register dev` or `just register prod` +[group('environments')] register env: - @just discord-env {{env}} scripts/register-commands.ts + pnpm exec tsx scripts/register-commands.ts {{env}} # The Release workflow's register step (ADR 0014). Reads DISCORD_* and PIXEL_ENV # from the job, not an env file. The command list doesn't depend on who is in @@ -152,6 +142,7 @@ register-ci env: PIXEL_ADMINS_FILE=config/admins.example.yaml \ PIXEL_MEMBERS_FILE=config/members.example.yaml \ PIXEL_DATA_DIR="${data}" \ + PIXEL_ENV_SOURCE=process \ pnpm exec tsx scripts/register-commands.ts "{{env}}" # Create a Sentry release and upload dist/ maps. Skip (do not fail) if @@ -159,6 +150,8 @@ register-ci env: # an org. Pixel itself does not read these; they are for this recipe and CD. # `version` must match PIXEL_VERSION in the running image (git SHA on main). # Pass environment=dev after a real deploy; omit it for publish-only. +[group('release')] +[doc("Create a Sentry release and upload dist/ source maps (skips without SENTRY_* settings)")] sentry-release version="" environment="": #!/usr/bin/env bash set -euo pipefail @@ -187,57 +180,55 @@ sentry-release version="" environment="": echo "Sentry release ${release}." # Show admin commands: `just command-access dev` or `just command-access prod` +[group('environments')] command-access env: - @just discord-env {{env}} scripts/command-access.ts + pnpm exec tsx scripts/command-access.ts {{env}} # Add Home Assistant devices to an env's devices.yaml on Azure: `just ha-devices dev|prod`. # Pulls the remote file (the truth), mirrors it to config/home-assistant//, # lets you pick from what Home Assistant has, validates, and pushes on a yes. # Needs az login, and HOME_ASSISTANT_URL/TOKEN in that env's file. Then /admin reload. +[group('environments')] +[doc("Pick Home Assistant devices and push an env's devices.yaml: `just ha-devices dev|prod`")] ha-devices env: - @just discord-env {{env}} scripts/ha-devices.ts + pnpm exec tsx scripts/ha-devices.ts {{env}} # Pulls terraform-tfvars into infra/envs//terraform.tfvars, the app secrets (as # secrets-pull), and read-only mirrors of members.yaml, devices.yaml and announcement-routes.yaml. # Pull everything for an env (tfvars, secrets, share config) to edit, plan or sync: `just pull dev|prod` +[group('environments')] pull env: pnpm exec tsx scripts/secrets-sync.ts {{env}} pull --all # Diffs against the live vault and pushes on a yes. Refuses tfvars that hold a secret or # are for the other env. The share mirrors never go back up (Pixel writes those). # Push an env's tfvars and app secrets after showing the diff: `just push dev|prod` +[group('environments')] push env: pnpm exec tsx scripts/secrets-sync.ts {{env}} push --all # Which secrets: the `app` group in infra/secrets.manifest.yaml. Never prints a value. # Asks before overwriting a local edit. Needs az login. See docs/runbook.md#secrets. # Pull an env's Key Vault secrets into .env. + config/admins..yaml to edit them +[group('environments')] secrets-pull env: pnpm exec tsx scripts/secrets-sync.ts {{env}} pull # Reads the vault (the live values), shows what would change by name and length, pushes # on a yes if the vault hasn't changed meanwhile, reads it back, then offers `just restart`. # Push local secrets to an env's Key Vault after showing the diff: `just secrets-push dev|prod` +[group('environments')] secrets-push env: pnpm exec tsx scripts/secrets-sync.ts {{env}} push # Restarts the running revision in place (one replica); it doesn't deploy anything. # Restart an env's Container App so it reads its secrets again: `just restart dev|prod` +[group('environments')] restart env: #!/usr/bin/env bash set -euo pipefail - if [ "{{env}}" != "dev" ] && [ "{{env}}" != "prod" ]; then - echo "Usage: just restart dev|prod" - exit 1 - fi - subscription="d150e252-e2f0-47fb-8a4a-c3f29e9aebd4" - resource_group="pixel-{{env}}" - app_name="pixel-{{env}}" - current_sub="$(az account show --query id -o tsv)" - if [ "${current_sub}" != "${subscription}" ]; then - echo "az is not on the Pixel subscription (${subscription}). Run: az account set --subscription ${subscription}" - exit 1 - fi + eval "$(pnpm exec tsx scripts/env.ts shell {{env}})" + pnpm exec tsx scripts/env.ts check-az {{env}} revision="$(az containerapp show -g "${resource_group}" -n "${app_name}" --query properties.latestRevisionName -o tsv)" echo "Restarting ${revision} (${app_name})…" az containerapp revision restart -g "${resource_group}" -n "${app_name}" --revision "${revision}" --only-show-errors --output none @@ -245,6 +236,8 @@ restart env: # Build the container image for Azure (linux/amd64). Mini is arm64; Container # Apps reject arm64 images. +[group('release')] +[doc("Build the container image for Azure (linux/amd64)")] docker-build tag="pixel:local": docker build \ --platform linux/amd64 \ @@ -254,6 +247,7 @@ docker-build tag="pixel:local": -t {{tag}} . # Log in to GHCR. Needs `gh auth refresh --scopes write:packages,read:packages,repo`. +[group('release')] docker-login-ghcr: #!/usr/bin/env bash set -euo pipefail @@ -261,22 +255,21 @@ docker-login-ghcr: echo "$(gh auth token)" | docker login ghcr.io -u "${user}" --password-stdin # Does not start Pixel. Needs docker, gh (write:packages), and az on the Pixel subscription. -# If pixel-dev is up, do not also run `just dev` on the same Discord token. -# Build this tree, push to GHCR, roll onto Azure pixel-dev. Never prod. -deploy-dev: docker-login-ghcr +# If pixel-dev is up, do not also run `just dev` on the same Discord token. Prod ships +# only from the Release workflow, so `just deploy prod` refuses. +# Build this tree, push to GHCR, roll it onto an env's Container App: `just deploy dev` +[group('environments')] +deploy env: #!/usr/bin/env bash set -euo pipefail + pnpm exec tsx scripts/env.ts deploy {{env}} + eval "$(pnpm exec tsx scripts/env.ts shell {{env}})" + pnpm exec tsx scripts/env.ts check-az {{env}} + just docker-login-ghcr image_name="ghcr.io/pixelbar/pixel" sha="$(git rev-parse HEAD)" short="$(git rev-parse --short HEAD)" branch="$(git rev-parse --abbrev-ref HEAD)" - subscription="d150e252-e2f0-47fb-8a4a-c3f29e9aebd4" - resource_group="pixel-dev" - app_name="pixel-dev" - if [ "${resource_group}" != "pixel-dev" ] || [ "${app_name}" != "pixel-dev" ]; then - echo "Refusing to deploy: this recipe only updates pixel-dev, never prod." - exit 1 - fi version="${short}" pin="${image_name}:${sha}" if [ -n "$(git status --porcelain)" ]; then @@ -284,27 +277,22 @@ deploy-dev: docker-login-ghcr pin="${image_name}:dev-dirty-$(date -u +%Y%m%dT%H%M%SZ)" echo "Working tree is dirty; tagging ${pin} instead of ${sha}." fi - current_sub="$(az account show --query id -o tsv)" - if [ "${current_sub}" != "${subscription}" ]; then - echo "az is not on the Pixel subscription (${subscription}). Run: az account set --subscription ${subscription}" - exit 1 - fi docker build \ --platform linux/amd64 \ --build-arg PIXEL_VERSION="${version}" \ --build-arg PIXEL_GIT_SHA="${sha}" \ --build-arg PIXEL_GIT_BRANCH="${branch}" \ -t "${pin}" \ - -t "${image_name}:dev" \ + -t "${image_name}:{{env}}" \ . if docker run --rm --platform linux/amd64 --entrypoint sh "${pin}" -c 'test -e /app/.env || test -e /app/config/admins.yaml || test -e /app/config/members.yaml'; then echo "Image ${pin} contains .env or access lists. Not pushing." exit 1 fi docker push "${pin}" - docker push "${image_name}:dev" + docker push "${image_name}:{{env}}" if ! az containerapp show -g "${resource_group}" -n "${app_name}" >/dev/null 2>&1; then - echo "Container App ${app_name} is not in ${resource_group} yet (#9). Image ${pin} was pushed. Apply infra/envs/dev, then re-run. This recipe does not apply Terraform." + echo "Container App ${app_name} is not in ${resource_group} yet (#9). Image ${pin} was pushed. Apply infra/envs/{{env}}, then re-run. This recipe does not apply Terraform." exit 1 fi az containerapp update \ @@ -321,38 +309,45 @@ deploy-dev: docker-login-ghcr echo "Replica count is not 1 (min=${min} max=${max}). Two bots on one token would answer twice." exit 1 fi - if [ "${PIXEL_ENV:-local}" = "prod" ]; then - echo "Refusing just register: PIXEL_ENV=prod. deploy-dev is the Pixel Dev guild only. Never copy a prod token into this path." - exit 1 - fi # Register before Sentry: the app is already live. A Sentry outage must not # skip Discord command updates. sentry-release still fails the recipe. - just register dev + # just register reads {{env}}'s own env file and refuses one for the other bot. + just register {{env}} just build - if ! just sentry-release "${version}" dev; then - echo "Sentry release failed after Discord commands were registered. pixel-dev is already on ${pin}." + if ! just sentry-release "${version}" {{env}}; then + echo "Sentry release failed after Discord commands were registered. ${app_name} is already on ${pin}." exit 1 fi - echo "Do not run just dev against the Pixel Dev token while this app is up." + echo "Do not run just dev against the ${env_file} Discord token while this app is up." + +# The old name for `just deploy dev`; kept for a release (#116). +[group('environments')] +deploy-dev: (deploy "dev") # Install the website's dependencies (docs/ is its own project with its own lockfile) +[group('docs')] docs-install: pnpm --dir docs install # Run the website locally with hot reload (http://localhost:5173) +[group('docs')] docs-dev: pnpm --dir docs run dev # Build the website into docs/.vitepress/dist +[group('docs')] docs-build: pnpm --dir docs run build # Serve the built website (after `just docs-build`) +[group('docs')] docs-preview: pnpm --dir docs run preview # Build the website and share it on your tailnet with `tailscale serve` (never Funnel: # only devices on your tailnet can open it). Runs until Ctrl-C, which also stops sharing. +[group('docs')] +[doc("Build the website and share it on your tailnet (tailscale serve, never Funnel)")] docs-tailnet port="4173": #!/usr/bin/env bash set -euo pipefail @@ -377,6 +372,8 @@ docs-tailnet port="4173": # Run the website with hot reload and share it on your tailnet with `tailscale serve` # (never Funnel). Edits show up on the remote device as you save. Ctrl-C stops both. +[group('docs')] +[doc("Run the website with hot reload and share it on your tailnet (never Funnel)")] docs-tailnet-dev port="5173" https="443": #!/usr/bin/env bash set -euo pipefail diff --git a/scripts/command-access.ts b/scripts/command-access.ts index 9e8474f..0adef2d 100644 --- a/scripts/command-access.ts +++ b/scripts/command-access.ts @@ -26,15 +26,14 @@ import { buildCore } from "../src/app.ts"; import { loadConfig } from "../src/config.ts"; import { silentLogger } from "../src/core/logger.ts"; import { nullErrorReporter } from "../src/core/ports/error-reporter.ts"; -import { assertRegisterEnv, parseRegisterTarget } from "./register-env.ts"; +import { loadTarget } from "./target.ts"; const PORT = 53682; const REDIRECT_URI = `http://localhost:${PORT}/callback`; const TIMEOUT_MS = 5 * 60_000; -const target = parseRegisterTarget(process.argv.slice(2)); -const config = loadConfig(); -assertRegisterEnv(target, config.env); +const { target, vars } = loadTarget(process.argv.slice(2), "just command-access dev|prod"); +const config = loadConfig(vars); const { registry, access } = buildCore(config, silentLogger, nullErrorReporter); const names = hiddenCommandNames(registry.all().map((c) => c.definition)); const overrides = adminOverrides(access.view.discord); diff --git a/scripts/env.ts b/scripts/env.ts new file mode 100644 index 0000000..de250f8 --- /dev/null +++ b/scripts/env.ts @@ -0,0 +1,80 @@ +/** + * What `just` recipes ask about an environment, so none of them spells an Azure + * name or a guard itself. Definitions: `scripts/envs.ts`. + * + * just env dev print dev's settings (names only, never a secret) + * tsx scripts/env.ts shell shell assignments for a recipe to eval + * tsx scripts/env.ts check-az fail unless az is signed in to the Pixel subscription + * tsx scripts/env.ts apply fail on prod unless PIXEL_APPLY_PROD=1 + * tsx scripts/env.ts deploy fail on prod (it ships from the Release workflow) + */ +import { execFileSync } from "node:child_process"; +import { + applyProblem, + deployProblem, + ENVS, + EnvError, + parseTarget, + SUBSCRIPTION, + shellAssignments, + subscriptionProblem, +} from "./envs.ts"; + +const USAGE = "scripts/env.ts show|shell|check-az|apply|deploy dev|prod"; + +function currentSubscription(): string { + try { + return execFileSync("az", ["account", "show", "--query", "id", "-o", "tsv"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + throw new EnvError("az isn't signed in. Run: az login"); + } +} + +function main(argv: readonly string[]): void { + const [command, ...rest] = argv; + const target = parseTarget(rest, USAGE); + const env = ENVS[target]; + let problem: string | undefined; + switch (command) { + case "show": + process.stdout.write( + [ + `${target}${env.prod ? " (production)" : ""}`, + ` env file ${env.envFile}`, + ` subscription ${SUBSCRIPTION}`, + ` resource group ${env.resourceGroup}`, + ` container app ${env.app}`, + ` key vault ${env.vault}`, + ` file share ${env.storageAccount}/${env.share}`, + "", + ].join("\n"), + ); + return; + case "shell": + process.stdout.write(shellAssignments(target)); + return; + case "check-az": + problem = subscriptionProblem(currentSubscription()); + break; + case "apply": + problem = applyProblem(target, process.env.PIXEL_APPLY_PROD); + break; + case "deploy": + problem = deployProblem(target); + break; + default: + throw new EnvError(`Usage: ${USAGE}`); + } + if (problem) throw new EnvError(problem); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + if (!(error instanceof EnvError)) throw error; + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +} diff --git a/scripts/envs.test.ts b/scripts/envs.test.ts new file mode 100644 index 0000000..2e3c3c8 --- /dev/null +++ b/scripts/envs.test.ts @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + applyProblem, + assertPixelEnv, + chooseEnvFile, + deployProblem, + ENVS, + parseTarget, + pixelEnvOf, + SUBSCRIPTION, + shellAssignments, + subscriptionProblem, + TARGETS, + targetEnvironment, +} from "./envs.ts"; + +describe("parseTarget", () => { + it("accepts dev and prod", () => { + expect(parseTarget(["dev"])).toBe("dev"); + expect(parseTarget(["prod"])).toBe("prod"); + }); + + it("refuses a missing or unknown target, with the usage", () => { + expect(() => parseTarget([])).toThrow(/Expected dev or prod/); + expect(() => parseTarget(["local"], "just register dev|prod")).toThrow( + /just register dev\|prod/, + ); + expect(() => parseTarget(["dev", "prod"])).toThrow(/Expected dev or prod/); + }); +}); + +describe("assertPixelEnv", () => { + it("allows local or dev files on dev", () => { + expect(() => assertPixelEnv("dev", "local")).not.toThrow(); + expect(() => assertPixelEnv("dev", "dev")).not.toThrow(); + }); + + it("allows only PIXEL_ENV=prod on prod", () => { + expect(() => assertPixelEnv("prod", "prod")).not.toThrow(); + }); + + it("refuses a prod file on dev", () => { + expect(() => assertPixelEnv("dev", "prod")).toThrow(/PIXEL_ENV=prod/); + }); + + it("refuses a non-prod file on prod", () => { + expect(() => assertPixelEnv("prod", "local")).toThrow(/PIXEL_ENV is not prod/); + expect(() => assertPixelEnv("prod", "dev")).toThrow(/Never copy the Pixel Dev token/); + }); +}); + +describe("pixelEnvOf", () => { + it("reads dev and prod, and treats anything else as local", () => { + expect(pixelEnvOf({ PIXEL_ENV: "prod" })).toBe("prod"); + expect(pixelEnvOf({ PIXEL_ENV: "dev" })).toBe("dev"); + expect(pixelEnvOf({ PIXEL_ENV: "staging" })).toBe("local"); + expect(pixelEnvOf({})).toBe("local"); + }); +}); + +describe("chooseEnvFile", () => { + const has = + (...files: string[]) => + (file: string) => + files.includes(file); + + it("uses the env's own file", () => { + expect(chooseEnvFile("dev", has(".env.dev", ".env"))).toEqual({ file: ".env.dev" }); + expect(chooseEnvFile("prod", has(".env.prod"))).toEqual({ file: ".env.prod" }); + }); + + it("lets dev fall back to .env, with a note", () => { + const chosen = chooseEnvFile("dev", has(".env")); + expect(chosen.file).toBe(".env"); + expect(chosen.note).toMatch(/no \.env\.dev/); + }); + + it("never lets prod fall back", () => { + expect(() => chooseEnvFile("prod", has(".env", ".env.dev"))).toThrow( + /Missing \.env\.prod.*Never copy \.env or \.env\.dev/, + ); + }); + + it("says how to make a missing dev file", () => { + expect(() => chooseEnvFile("dev", has())).toThrow(/Missing \.env\.dev/); + }); +}); + +describe("targetEnvironment", () => { + it("drops every key any env file sets, then applies the chosen file", () => { + const shell = { PATH: "/bin", DISCORD_TOKEN: "local-token", HOME_ASSISTANT_URL: "http://x" }; + const env = targetEnvironment( + shell, + ["DISCORD_TOKEN=local-token\nHOME_ASSISTANT_URL=http://x\n", "DISCORD_TOKEN=dev-token\n"], + "DISCORD_TOKEN=dev-token\nPIXEL_ENV=dev\n", + ); + expect(env).toEqual({ PATH: "/bin", DISCORD_TOKEN: "dev-token", PIXEL_ENV: "dev" }); + // The shell's own environment is left alone. + expect(shell.DISCORD_TOKEN).toBe("local-token"); + }); +}); + +describe("guards", () => { + it("only lets az act on the Pixel subscription", () => { + expect(subscriptionProblem(`${SUBSCRIPTION}\n`)).toBeUndefined(); + expect(subscriptionProblem("00000000-0000-0000-0000-000000000000")).toMatch( + /az account set --subscription/, + ); + }); + + it("needs PIXEL_APPLY_PROD=1 to apply prod, and nothing for dev", () => { + expect(applyProblem("dev", undefined)).toBeUndefined(); + expect(applyProblem("prod", "1")).toBeUndefined(); + expect(applyProblem("prod", undefined)).toMatch(/PIXEL_APPLY_PROD=1 just tf-apply prod/); + expect(applyProblem("prod", "yes")).toMatch(/Refusing/); + }); + + it("never deploys prod from a laptop", () => { + expect(deployProblem("dev")).toBeUndefined(); + expect(deployProblem("prod")).toMatch(/Release workflow/); + }); +}); + +describe("shellAssignments", () => { + it("gives a recipe plain names to eval", () => { + const lines = shellAssignments("prod").trim().split("\n"); + expect(lines).toContain("resource_group=pixel-prod"); + expect(lines).toContain("app_name=pixel-prod"); + expect(lines).toContain("is_prod=1"); + // Nothing a shell would expand or split. + for (const line of lines) expect(line).toMatch(/^[a-z_]+=[A-Za-z0-9._-]+$/); + }); +}); + +describe("ENVS matches the Terraform stacks", () => { + for (const target of TARGETS) { + it(`${target} uses the names in infra/envs/${target}`, () => { + const tfvars = readFileSync(`infra/envs/${target}/terraform.example.tfvars`, "utf8"); + const value = (key: string) => + tfvars.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, "m"))?.[1]; + const env = ENVS[target]; + expect(env.target).toBe(target); + expect(value("subscription_id")).toBe(SUBSCRIPTION); + expect(value("key_vault_name")).toBe(env.vault); + expect(value("data_storage_account_name")).toBe(env.storageAccount); + expect(env.resourceGroup).toBe(`pixel-${target}`); + expect(env.prod).toBe(target === "prod"); + }); + } +}); + +describe("the justfile", () => { + it("takes Azure names from scripts/env.ts instead of spelling them", () => { + const justfile = readFileSync("justfile", "utf8"); + expect(justfile).not.toContain(SUBSCRIPTION); + for (const env of Object.values(ENVS)) { + expect(justfile).not.toContain(env.vault); + expect(justfile).not.toContain(env.storageAccount); + expect(justfile).not.toMatch(new RegExp(`["=]${env.resourceGroup}\\b`)); + } + }); +}); diff --git a/scripts/envs.ts b/scripts/envs.ts new file mode 100644 index 0000000..2e2cd2a --- /dev/null +++ b/scripts/envs.ts @@ -0,0 +1,177 @@ +/** + * The one definition of Pixel's environments for the laptop tools (#116): which + * env file each reads, where it lives on Azure, and the guards every tool shares. + * Scripts and `just` recipes take these from here and never spell a resource + * group, vault, storage account or subscription themselves (recipes read them + * with `scripts/env.ts shell `). + * + * Pure: nothing here reads a file, the environment or Azure, so it can be tested. + * `scripts/env.ts` does the reading. + */ +import { parseEnv } from "node:util"; + +/** An environment that runs on Azure. `local` is `just dev` on a laptop and has none of this. */ +export type Target = "dev" | "prod"; + +export const TARGETS: readonly Target[] = ["dev", "prod"]; + +/** The Azure subscription both environments live in. */ +export const SUBSCRIPTION = "d150e252-e2f0-47fb-8a4a-c3f29e9aebd4"; + +export type PixelEnv = { + target: Target; + /** Resource group, Container App and the name in `PIXEL_ENV`. */ + resourceGroup: string; + app: string; + /** Key Vault holding the app secrets and the shared tfvars. */ + vault: string; + /** Azure Files account and share Pixel mounts at `/app/persist`. */ + storageAccount: string; + share: string; + /** The laptop env file for this env's Discord application. */ + envFile: string; + /** Changes here reach the production bot, so tools ask harder before writing. */ + prod: boolean; +}; + +export const ENVS: Readonly> = { + dev: { + target: "dev", + resourceGroup: "pixel-dev", + app: "pixel-dev", + vault: "pixel-dev-kv", + storageAccount: "pixeldevdata", + share: "pixel", + envFile: ".env.dev", + prod: false, + }, + prod: { + target: "prod", + resourceGroup: "pixel-prod", + app: "pixel-prod", + vault: "pixel-prod-kv", + storageAccount: "pixelproddata", + share: "pixel", + envFile: ".env.prod", + prod: true, + }, +}; + +/** Every env file a laptop may have. `.env` is the local bot (`just dev`). */ +export const ENV_FILES = [".env", ".env.dev", ".env.prod"] as const; + +export class EnvError extends Error { + override name = "EnvError"; +} + +export function isTarget(value: unknown): value is Target { + return value === "dev" || value === "prod"; +} + +/** The env a tool was given: exactly one argument, `dev` or `prod`. */ +export function parseTarget(argv: readonly string[], usage = "just dev|prod"): Target { + const [target] = argv; + if (isTarget(target) && argv.length === 1) return target; + throw new EnvError(`Expected dev or prod (${usage})`); +} + +/** + * Stops a prod token being used on the Pixel Dev guild, and the reverse. `pixelEnv` + * is what the env file says in `PIXEL_ENV`. + */ +export function assertPixelEnv(target: Target, pixelEnv: "local" | "dev" | "prod"): void { + if (target === "prod" && pixelEnv !== "prod") { + throw new EnvError( + "Refusing prod: PIXEL_ENV is not prod. .env.prod must set PIXEL_ENV=prod. Never copy the Pixel Dev token.", + ); + } + if (target === "dev" && pixelEnv === "prod") { + throw new EnvError("Refusing dev: PIXEL_ENV=prod. dev is the Pixel Dev / test guild only."); + } +} + +/** `PIXEL_ENV` as an env file (or a parsed environment) sets it. */ +export function pixelEnvOf(env: Record): "local" | "dev" | "prod" { + const value = env.PIXEL_ENV; + return value === "dev" || value === "prod" ? value : "local"; +} + +/** + * Which env file a Discord tool (`register`, `command-access`, `ha-devices`) reads. + * Dev falls back to `.env` (with a note); prod never does. + */ +export function chooseEnvFile( + target: Target, + exists: (file: string) => boolean, +): { file: string; note?: string } { + const { envFile } = ENVS[target]; + if (exists(envFile)) return { file: envFile }; + if (target === "dev" && exists(".env")) { + return { + file: ".env", + note: "Using .env (no .env.dev). Copy .env.example to .env.dev to keep Pixel Dev separate from a local bot.", + }; + } + throw new EnvError( + target === "prod" + ? "Missing .env.prod. Copy .env.example to .env.prod and set PIXEL_ENV=prod plus the production Discord application. Never copy .env or .env.dev." + : "Missing .env.dev. Copy .env.example to .env.dev and fill in the Pixel Dev / test Discord application.", + ); +} + +/** + * The environment a tool runs with: what the shell has, minus every key any env file + * sets (`just` loads `.env` into every recipe, which must not leak into another bot), + * plus the chosen file. + */ +export function targetEnvironment( + shell: Record, + envFiles: readonly string[], + chosen: string, +): Record { + const env = { ...shell }; + for (const text of envFiles) { + for (const key of Object.keys(parseEnv(text))) delete env[key]; + } + return { ...env, ...parseEnv(chosen) }; +} + +/** Why `az` can't act on this env's Container App, or undefined when it can. */ +export function subscriptionProblem(current: string): string | undefined { + if (current.trim() === SUBSCRIPTION) return undefined; + return `az is not on the Pixel subscription (${SUBSCRIPTION}). Run: az account set --subscription ${SUBSCRIPTION}`; +} + +/** + * Terraform applies to prod need a maintainer to say so (`PIXEL_APPLY_PROD=1`). + * Coding agents must not apply prod or write prod secrets. + */ +export function applyProblem(target: Target, approved: string | undefined): string | undefined { + if (target !== "prod" || approved === "1") return undefined; + return [ + "Refusing just tf-apply prod. Coding agents must not apply prod or write prod secrets.", + "A maintainer runs: PIXEL_APPLY_PROD=1 just tf-apply prod", + ].join("\n"); +} + +/** `deploy` rolls a locally built image. Prod only ships from the Release workflow. */ +export function deployProblem(target: Target): string | undefined { + if (target === "dev") return undefined; + return "Refusing just deploy prod: prod ships from the Release workflow (Actions β†’ Release on main), never from a laptop."; +} + +/** Shell assignments for a `just` recipe to `eval`. Every value is a plain name. */ +export function shellAssignments(target: Target): string { + const env = ENVS[target]; + return [ + `subscription=${SUBSCRIPTION}`, + `resource_group=${env.resourceGroup}`, + `app_name=${env.app}`, + `key_vault=${env.vault}`, + `storage_account=${env.storageAccount}`, + `share=${env.share}`, + `env_file=${env.envFile}`, + `is_prod=${env.prod ? 1 : 0}`, + "", + ].join("\n"); +} diff --git a/scripts/ha-devices-lib.test.ts b/scripts/ha-devices-lib.test.ts index 8984283..0794521 100644 --- a/scripts/ha-devices-lib.test.ts +++ b/scripts/ha-devices-lib.test.ts @@ -4,17 +4,15 @@ import { HomeDevicesError } from "../src/services/home-devices.ts"; import { actionChoices, appendDevices, - azDownloadArgs, - azUploadArgs, checkName, choiceLabel, compareMirror, + DEVICES_PATH, type DeviceChoice, defaultDescription, deviceChoices, mirrorDir, planChanges, - REMOTES, removeDevices, tiersFor, validateDevices, @@ -55,25 +53,11 @@ const entries: InventoryEntry[] = [ { name: "back-door", entity: "lock.back_door", kind: "door", friendlyName: "Back door" }, ]; -describe("remotes", () => { - it("points each env at its own share and the file Pixel reads", () => { - expect(REMOTES.dev.account).toBe("pixeldevdata"); - expect(REMOTES.prod.account).toBe("pixelproddata"); - expect(REMOTES.prod.path).toBe("home-assistant/devices.yaml"); +describe("devices file", () => { + it("is the file Pixel reads, mirrored per env", () => { + expect(DEVICES_PATH).toBe("home-assistant/devices.yaml"); expect(mirrorDir("prod")).toBe("config/home-assistant/prod"); }); - - it("builds az arguments with login auth and no shell", () => { - const down = azDownloadArgs(REMOTES.dev, "/tmp/x.yaml"); - expect(down.slice(0, 3)).toEqual(["storage", "file", "download"]); - expect(down).toContain("login"); - expect(down[down.indexOf("--account-name") + 1]).toBe("pixeldevdata"); - expect(down[down.indexOf("--dest") + 1]).toBe("/tmp/x.yaml"); - const up = azUploadArgs(REMOTES.prod, "/tmp/y.yaml"); - expect(up.slice(0, 3)).toEqual(["storage", "file", "upload"]); - expect(up[up.indexOf("--source") + 1]).toBe("/tmp/y.yaml"); - expect(up[up.indexOf("--path") + 1]).toBe("home-assistant/devices.yaml"); - }); }); describe("compareMirror", () => { diff --git a/scripts/ha-devices-lib.ts b/scripts/ha-devices-lib.ts index 78cd7df..c326b38 100644 --- a/scripts/ha-devices-lib.ts +++ b/scripts/ha-devices-lib.ts @@ -13,69 +13,17 @@ import { type HomeDeviceTier, loadHomeDevices, } from "../src/services/home-devices.ts"; +import type { Target } from "./envs.ts"; import type { InventoryEntry } from "./ha-inventory.ts"; -import type { RegisterTarget } from "./register-env.ts"; -/** Where each env's `devices.yaml` lives: the Azure Files share Pixel mounts at `/app/persist`. */ -export type Remote = { account: string; share: string; path: string }; - -export const REMOTES: Record = { - dev: { account: "pixeldevdata", share: "pixel", path: `home-assistant/${DEVICES_FILE}` }, - prod: { account: "pixelproddata", share: "pixel", path: `home-assistant/${DEVICES_FILE}` }, -}; +/** Where `devices.yaml` sits on an env's file share (Pixel mounts it at `/app/persist`). */ +export const DEVICES_PATH = `home-assistant/${DEVICES_FILE}`; /** The local copy of an env's remote file. Gitignored, like every devices file. */ -export function mirrorDir(target: RegisterTarget): string { +export function mirrorDir(target: Target): string { return join("config", "home-assistant", target); } -const AZ_COMMON = [ - "--auth-mode", - "login", - "--enable-file-backup-request-intent", - "--only-show-errors", -]; - -export function azDownloadArgs(remote: Remote, dest: string): string[] { - return [ - "storage", - "file", - "download", - ...AZ_COMMON, - "--account-name", - remote.account, - "--share-name", - remote.share, - "--path", - remote.path, - "--dest", - dest, - "--no-progress", - "--output", - "none", - ]; -} - -export function azUploadArgs(remote: Remote, source: string): string[] { - return [ - "storage", - "file", - "upload", - ...AZ_COMMON, - "--account-name", - remote.account, - "--share-name", - remote.share, - "--path", - remote.path, - "--source", - source, - "--no-progress", - "--output", - "none", - ]; -} - /** * The remote is the truth and the local file only mirrors it. A local file that * differs has edits the remote never got, so the tool stops rather than guess. @@ -179,7 +127,7 @@ export function tiersFor(kind: HomeKind): HomeDeviceTier[] { */ export function actionChoices( kind: HomeKind, - target: RegisterTarget, + target: Target, ): { name: string; checked: boolean }[] { if (kind.name === "door" && target === "dev") return []; return kind.actions.map((action) => ({ name: action.name, checked: kind.name !== "door" })); diff --git a/scripts/ha-devices.ts b/scripts/ha-devices.ts index f9dcf82..64b9054 100644 --- a/scripts/ha-devices.ts +++ b/scripts/ha-devices.ts @@ -42,24 +42,23 @@ import { devicePicker } from "./device-picker.ts"; import { actionChoices, appendDevices, - azDownloadArgs, - azUploadArgs, checkName, choiceLabel, compareMirror, + DEVICES_PATH, type DeviceChoice, defaultDescription, deviceChoices, mirrorDir, type NewDevice, planChanges, - REMOTES, removeDevices, tiersFor, validateDevices, } from "./ha-devices-lib.ts"; import { buildInventory, type InventoryEntry } from "./ha-inventory.ts"; -import { assertRegisterEnv, parseRegisterTarget } from "./register-env.ts"; +import { azDownloadArgs, azUploadArgs, shareRemote } from "./share.ts"; +import { loadTarget } from "./target.ts"; const run = promisify(execFile); const CONNECT_TIMEOUT_MS = 15_000; @@ -70,10 +69,9 @@ class Stop extends Error { const say = (text = "") => process.stdout.write(`${text}\n`); -const target = parseRegisterTarget(process.argv.slice(2)); -const config = loadConfig(); -assertRegisterEnv(target, config.env); -const remote = REMOTES[target]; +const { target, vars } = loadTarget(process.argv.slice(2), "just ha-devices dev|prod"); +const config = loadConfig(vars); +const remote = shareRemote(target, DEVICES_PATH); const work = mkdtempSync(join(tmpdir(), "pixel-ha-devices-")); async function az(args: string[]): Promise { diff --git a/scripts/register-commands.ts b/scripts/register-commands.ts index 722c4c9..e70f363 100644 --- a/scripts/register-commands.ts +++ b/scripts/register-commands.ts @@ -8,11 +8,10 @@ import { buildCore } from "../src/app.ts"; import { loadConfig } from "../src/config.ts"; import { silentLogger } from "../src/core/logger.ts"; import { nullErrorReporter } from "../src/core/ports/error-reporter.ts"; -import { assertRegisterEnv, parseRegisterTarget } from "./register-env.ts"; +import { loadTarget } from "./target.ts"; -const target = parseRegisterTarget(process.argv.slice(2)); -const config = loadConfig(); -assertRegisterEnv(target, config.env); +const { target, vars } = loadTarget(process.argv.slice(2), "just register dev|prod"); +const config = loadConfig(vars); const { registry } = buildCore(config, silentLogger, nullErrorReporter); const body = registry.all().map(({ definition }) => toSlashCommand(definition)); diff --git a/scripts/register-env.test.ts b/scripts/register-env.test.ts deleted file mode 100644 index b1edb82..0000000 --- a/scripts/register-env.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { assertRegisterEnv, parseRegisterTarget } from "./register-env.ts"; - -describe("parseRegisterTarget", () => { - it("accepts dev and prod", () => { - expect(parseRegisterTarget(["dev"])).toBe("dev"); - expect(parseRegisterTarget(["prod"])).toBe("prod"); - }); - - it("refuses a missing or unknown target", () => { - expect(() => parseRegisterTarget([])).toThrow(/Expected dev or prod/); - expect(() => parseRegisterTarget(["local"])).toThrow(/just register dev\|prod/); - expect(() => parseRegisterTarget(["dev", "prod"])).toThrow(/Expected dev or prod/); - }); -}); - -describe("assertRegisterEnv", () => { - it("allows local or dev files on just register dev", () => { - expect(() => assertRegisterEnv("dev", "local")).not.toThrow(); - expect(() => assertRegisterEnv("dev", "dev")).not.toThrow(); - }); - - it("allows only PIXEL_ENV=prod on just register prod", () => { - expect(() => assertRegisterEnv("prod", "prod")).not.toThrow(); - }); - - it("refuses a prod file on just register dev", () => { - expect(() => assertRegisterEnv("dev", "prod")).toThrow(/PIXEL_ENV=prod/); - }); - - it("refuses a non-prod file on just register prod", () => { - expect(() => assertRegisterEnv("prod", "local")).toThrow(/PIXEL_ENV is not prod/); - expect(() => assertRegisterEnv("prod", "dev")).toThrow(/Never copy the Pixel Dev token/); - }); -}); diff --git a/scripts/register-env.ts b/scripts/register-env.ts deleted file mode 100644 index f1fd1b0..0000000 --- a/scripts/register-env.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Which env `just register`, `just command-access` and `just ha-devices` act on. - * The just recipe loads `.env.dev` or `.env.prod`; this check stops a prod - * token being used on the Pixel Dev guild (and the reverse). - */ -export type RegisterTarget = "dev" | "prod"; - -export function parseRegisterTarget(argv: readonly string[]): RegisterTarget { - const target = argv[0]; - if ((target === "dev" || target === "prod") && argv.length === 1) return target; - throw new Error( - "Expected dev or prod (just register dev|prod, just command-access dev|prod, just ha-devices dev|prod)", - ); -} - -export function assertRegisterEnv( - target: RegisterTarget, - pixelEnv: "local" | "dev" | "prod", -): void { - if (target === "prod" && pixelEnv !== "prod") { - throw new Error( - "Refusing prod: PIXEL_ENV is not prod. .env.prod must set PIXEL_ENV=prod. Never copy the Pixel Dev token.", - ); - } - if (target === "dev" && pixelEnv === "prod") { - throw new Error("Refusing dev: PIXEL_ENV=prod. dev is the Pixel Dev / test guild only."); - } -} diff --git a/scripts/secrets-sync-lib.ts b/scripts/secrets-sync-lib.ts index 8351b00..bcb6277 100644 --- a/scripts/secrets-sync-lib.ts +++ b/scripts/secrets-sync-lib.ts @@ -7,40 +7,39 @@ */ import { join } from "node:path"; import { parseEnv } from "node:util"; -import { mirrorDir, REMOTES } from "./ha-devices-lib.ts"; -import type { RegisterTarget } from "./register-env.ts"; +import { ENVS, type Target } from "./envs.ts"; +import { mirrorDir } from "./ha-devices-lib.ts"; import type { SecretEntry, SecretsManifest } from "./secrets-manifest.ts"; /** Where each env's secrets live on Azure, and the Container App that reads them. */ export type Vault = { vault: string; resourceGroup: string; app: string }; -export const VAULTS: Record = { - dev: { vault: "pixel-dev-kv", resourceGroup: "pixel-dev", app: "pixel-dev" }, - prod: { vault: "pixel-prod-kv", resourceGroup: "pixel-prod", app: "pixel-prod" }, -}; +const vaultOf = ({ vault, resourceGroup, app }: Vault): Vault => ({ vault, resourceGroup, app }); + +export const VAULTS: Record = { dev: vaultOf(ENVS.dev), prod: vaultOf(ENVS.prod) }; /** The env file a target's env secrets are mirrored in. Same files as `just register`. */ -export function envFile(target: RegisterTarget): string { - return `.env.${target}`; +export function envFile(target: Target): string { + return ENVS[target].envFile; } /** The local copy of a target's `admins-yaml` secret. Gitignored (`config/*.yaml`). */ -export function adminsFile(target: RegisterTarget): string { +export function adminsFile(target: Target): string { return join("config", `admins.${target}.yaml`); } /** The stack's settings, shared through the `terraform-tfvars` secret. Gitignored (`*.tfvars`). */ -export function tfvarsFile(target: RegisterTarget): string { +export function tfvarsFile(target: Target): string { return join("infra", "envs", target, "terraform.tfvars"); } /** Where Terraform's write-only inputs go instead: auto-loaded, gitignored, never synced. */ -export function secretTfvarsFile(target: RegisterTarget): string { +export function secretTfvarsFile(target: Target): string { return join("infra", "envs", target, "secrets.auto.tfvars"); } /** The local file behind each secret that is a whole file rather than one env variable. */ -const FILES: Record string> = { +const FILES: Record string> = { "admins-yaml": adminsFile, "terraform-tfvars": tfvarsFile, }; @@ -50,7 +49,7 @@ export function isFile(entry: SecretEntry): boolean { return entry.delivery !== "env"; } -export function localFile(entry: SecretEntry, target: RegisterTarget): string { +export function localFile(entry: SecretEntry, target: Target): string { const file = FILES[entry.vault]; if (!file) throw new Error(`${entry.vault} has no local file`); // syncedSecrets only returns known ones return file(target); @@ -75,7 +74,7 @@ export function syncedSecrets( } /** Where a secret lives locally, for messages. */ -export function localName(entry: SecretEntry, target: RegisterTarget): string { +export function localName(entry: SecretEntry, target: Target): string { return isFile(entry) ? localFile(entry, target) : `${entry.env} in ${envFile(target)}`; } @@ -141,7 +140,7 @@ const chars = (value: string | undefined) => * One line per secret, never the value. `push` reads left to right as local β†’ Key * Vault, `pull` as Key Vault β†’ local. */ -export function describe(row: Row, direction: "push" | "pull", target: RegisterTarget): string { +export function describe(row: Row, direction: "push" | "pull", target: Target): string { const name = `${row.entry.vault} (${localName(row.entry, target)})`; const [from, to] = direction === "push" ? [row.local, row.remote] : [row.remote, row.local]; switch (row.status) { @@ -201,7 +200,7 @@ export function setEnvValues(text: string, updates: ReadonlyMap) } /** The env file to start from when a pull finds none. */ -export function newEnvFile(target: RegisterTarget): string { +export function newEnvFile(target: Target): string { return [ `# ${envFile(target)}: written by just secrets-pull ${target}. Add the other settings from .env.example.`, ...(target === "prod" ? ["PIXEL_ENV=prod"] : ["PIXEL_ENV=dev"]), @@ -219,7 +218,7 @@ export function pixelEnvOf(envText: string | undefined): "local" | "dev" | "prod * The other env files whose Discord token must never reach this target's vault: the * Pixel Dev and Mini bots must never run as prod, and prod's token never belongs on dev. */ -export function otherEnvFiles(target: RegisterTarget): string[] { +export function otherEnvFiles(target: Target): string[] { return target === "prod" ? [".env.dev", ".env"] : [".env.prod"]; } @@ -319,7 +318,7 @@ function tfvarsString(text: string, key: string): string | undefined { */ export function tfvarsProblems( text: string, - target: RegisterTarget, + target: Target, variables: { all: ReadonlySet; sensitive: ReadonlySet }, ): string[] { const problems: string[] = []; @@ -340,9 +339,9 @@ export function tfvarsProblems( ); } const account = tfvarsString(text, "data_storage_account_name"); - if (account !== undefined && account !== REMOTES[target].account) { + if (account !== undefined && account !== ENVS[target].storageAccount) { problems.push( - `${file} says data_storage_account_name = "${account}", but ${target} is ${REMOTES[target].account}.`, + `${file} says data_storage_account_name = "${account}", but ${target} is ${ENVS[target].storageAccount}.`, ); } return problems; @@ -354,7 +353,7 @@ export function tfvarsProblems( */ export type ShareFile = { path: string; mirror: string }; -export function shareFiles(target: RegisterTarget): ShareFile[] { +export function shareFiles(target: Target): ShareFile[] { return [ { path: "members.yaml", mirror: join("config", target, "members.yaml") }, { path: "home-assistant/devices.yaml", mirror: join(mirrorDir(target), "devices.yaml") }, diff --git a/scripts/secrets-sync.ts b/scripts/secrets-sync.ts index 3d6f78b..90a084a 100644 --- a/scripts/secrets-sync.ts +++ b/scripts/secrets-sync.ts @@ -47,8 +47,7 @@ import { parseAdmins, parseMembers, } from "../src/services/access-config.ts"; -import { azDownloadArgs, REMOTES } from "./ha-devices-lib.ts"; -import { assertRegisterEnv, type RegisterTarget } from "./register-env.ts"; +import { assertPixelEnv, ENVS, EnvError, type Target } from "./envs.ts"; import { loadSecretsManifest, type SecretEntry, SecretsManifestError } from "./secrets-manifest.ts"; import { azSetArgs, @@ -76,6 +75,7 @@ import { toPush, VAULTS, } from "./secrets-sync-lib.ts"; +import { azDownloadArgs, shareRemote } from "./share.ts"; const run = promisify(execFile); @@ -88,7 +88,7 @@ const say = (text = "") => process.stdout.write(`${text}\n`); type Mode = "pull" | "push"; type Scope = "secrets" | "all"; -function parseArgs(argv: readonly string[]): { target: RegisterTarget; mode: Mode; scope: Scope } { +function parseArgs(argv: readonly string[]): { target: Target; mode: Mode; scope: Scope } { const [target, mode, flag] = argv; if ( (target === "dev" || target === "prod") && @@ -121,7 +121,7 @@ function azFailure(error: unknown): string { /** The local text of each file secret, by vault name. */ function localFiles( entries: readonly SecretEntry[], - target: RegisterTarget, + target: Target, ): Map { return new Map( entries.filter(isFile).map((e) => [e.vault, readText(localFile(e, target))] as const), @@ -130,7 +130,7 @@ function localFiles( /** Every managed secret the vault has. A missing one is simply not in the map. */ async function readVault( - target: RegisterTarget, + target: Target, entries: readonly SecretEntry[], ): Promise> { const vault = VAULTS[target]; @@ -174,25 +174,21 @@ async function showDiff( } /** Downloads a file from the env's share. Undefined when it isn't there. */ -async function download(target: RegisterTarget, path: string): Promise { +async function download(target: Target, path: string): Promise { // Downloads run in parallel, so each gets its own file. const dest = join(work, `download-${path.replaceAll("/", "_")}`); try { - await run("az", azDownloadArgs({ ...REMOTES[target], path }, dest)); + await run("az", azDownloadArgs(shareRemote(target, path), dest)); } catch (error) { const why = azFailure(error); if (isShareNotFound(why)) return undefined; - throw new Stop(`Couldn't read ${path} from ${REMOTES[target].account}: ${why}`); + throw new Stop(`Couldn't read ${path} from ${ENVS[target].storageAccount}: ${why}`); } return readFileSync(dest, "utf8"); } /** Checks admins.yaml against the env's members.yaml on the share, as Pixel does at startup. */ -async function checkAdmins( - target: RegisterTarget, - entry: SecretEntry, - text: string, -): Promise { +async function checkAdmins(target: Target, entry: SecretEntry, text: string): Promise { const members = await download(target, "members.yaml"); if (members === undefined) throw new Stop(`${target}'s share has no members.yaml to check against.`); @@ -205,7 +201,7 @@ async function checkAdmins( } /** Refuses a tfvars file that's for the other env, sets an unknown variable or holds a secret. */ -function checkTfvars(target: RegisterTarget, text: string): void { +function checkTfvars(target: Target, text: string): void { const problems = tfvarsProblems( text, target, @@ -214,14 +210,14 @@ function checkTfvars(target: RegisterTarget, text: string): void { if (problems.length > 0) throw new Stop(`${problems.join("\n")}\nNothing was pushed.`); } -function summary(rows: readonly Row[], mode: Mode, target: RegisterTarget): void { +function summary(rows: readonly Row[], mode: Mode, target: Target): void { for (const row of rows) say(describe(row, mode, target)); say(); } /** Mirrors the share's config files, read-only, asking before replacing one that differs. */ -async function pullShare(target: RegisterTarget): Promise { - say(`Reading config files from ${REMOTES[target].account} (${target})…`); +async function pullShare(target: Target): Promise { + say(`Reading config files from ${ENVS[target].storageAccount} (${target})…`); const files = await Promise.all( shareFiles(target).map(async (file) => ({ ...file, @@ -252,15 +248,11 @@ async function pullShare(target: RegisterTarget): Promise { for (const f of files) if (f.remote !== undefined) writePrivate(f.mirror, f.remote); } -async function pull( - target: RegisterTarget, - entries: readonly SecretEntry[], - scope: Scope, -): Promise { +async function pull(target: Target, entries: readonly SecretEntry[], scope: Scope): Promise { const vault = VAULTS[target]; const envPath = envFile(target); const envText = readText(envPath); - assertRegisterEnv(target, envText === undefined ? target : pixelEnvOf(envText)); + assertPixelEnv(target, envText === undefined ? target : pixelEnvOf(envText)); say(`Reading ${entries.length} secrets from ${vault.vault} (${target})…`); const remote = await readVault(target, entries); @@ -309,11 +301,7 @@ async function pull( if (scope === "all") say(`For Terraform: just tf-plan ${target}`); } -async function push( - target: RegisterTarget, - entries: readonly SecretEntry[], - scope: Scope, -): Promise { +async function push(target: Target, entries: readonly SecretEntry[], scope: Scope): Promise { const vault = VAULTS[target]; const envPath = envFile(target); const envText = readText(envPath); @@ -322,7 +310,7 @@ async function push( `Missing ${envPath}. Run just ${scope === "all" ? "pull" : "secrets-pull"} ${target} first.`, ); } - assertRegisterEnv(target, pixelEnvOf(envText)); + assertPixelEnv(target, pixelEnvOf(envText)); const local = localValues(entries, envText, localFiles(entries, target)); const others = otherEnvFiles(target).flatMap((file) => { @@ -449,8 +437,8 @@ try { } else if (error instanceof Error && error.name === "ExitPromptError") { process.stderr.write("Cancelled. Nothing more was written.\n"); process.exitCode = 130; - } else if (error instanceof Error && error.message.startsWith("Refusing")) { - // assertRegisterEnv: the env file is for the other bot. + } else if (error instanceof EnvError) { + // assertPixelEnv: the env file is for the other bot. process.stderr.write(`${error.message}\n`); process.exitCode = 1; } else { diff --git a/scripts/share.test.ts b/scripts/share.test.ts new file mode 100644 index 0000000..706d3f3 --- /dev/null +++ b/scripts/share.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { azDownloadArgs, azUploadArgs, shareRemote } from "./share.ts"; + +describe("shareRemote", () => { + it("points each env at its own share", () => { + expect(shareRemote("dev", "members.yaml")).toEqual({ + account: "pixeldevdata", + share: "pixel", + path: "members.yaml", + }); + expect(shareRemote("prod", "data/x.yaml").account).toBe("pixelproddata"); + }); +}); + +describe("az arguments", () => { + it("use login auth and no shell", () => { + const down = azDownloadArgs(shareRemote("dev", "home-assistant/devices.yaml"), "/tmp/x.yaml"); + expect(down.slice(0, 3)).toEqual(["storage", "file", "download"]); + expect(down).toContain("login"); + expect(down[down.indexOf("--account-name") + 1]).toBe("pixeldevdata"); + expect(down[down.indexOf("--dest") + 1]).toBe("/tmp/x.yaml"); + const up = azUploadArgs(shareRemote("prod", "home-assistant/devices.yaml"), "/tmp/y.yaml"); + expect(up.slice(0, 3)).toEqual(["storage", "file", "upload"]); + expect(up[up.indexOf("--source") + 1]).toBe("/tmp/y.yaml"); + expect(up[up.indexOf("--path") + 1]).toBe("home-assistant/devices.yaml"); + }); +}); diff --git a/scripts/share.ts b/scripts/share.ts new file mode 100644 index 0000000..115a7c1 --- /dev/null +++ b/scripts/share.ts @@ -0,0 +1,60 @@ +/** + * Files on an env's Azure Files share (mounted at `/app/persist`), and the `az` + * arguments to read and write them. Names come from `scripts/envs.ts`. + */ +import { ENVS, type Target } from "./envs.ts"; + +/** One file on one env's share. */ +export type Remote = { account: string; share: string; path: string }; + +export function shareRemote(target: Target, path: string): Remote { + const env = ENVS[target]; + return { account: env.storageAccount, share: env.share, path }; +} + +const AZ_COMMON = [ + "--auth-mode", + "login", + "--enable-file-backup-request-intent", + "--only-show-errors", +]; + +export function azDownloadArgs(remote: Remote, dest: string): string[] { + return [ + "storage", + "file", + "download", + ...AZ_COMMON, + "--account-name", + remote.account, + "--share-name", + remote.share, + "--path", + remote.path, + "--dest", + dest, + "--no-progress", + "--output", + "none", + ]; +} + +export function azUploadArgs(remote: Remote, source: string): string[] { + return [ + "storage", + "file", + "upload", + ...AZ_COMMON, + "--account-name", + remote.account, + "--share-name", + remote.share, + "--path", + remote.path, + "--source", + source, + "--no-progress", + "--output", + "none", + ]; +} diff --git a/scripts/target.ts b/scripts/target.ts new file mode 100644 index 0000000..deda630 --- /dev/null +++ b/scripts/target.ts @@ -0,0 +1,55 @@ +/** + * Loads the env a laptop tool acts on: `dev` or `prod` from the command line, and + * that env's file (`.env.dev`, falling back to `.env`, or `.env.prod`) without + * letting anything `just` loaded from another env file leak in. Then checks the + * file's `PIXEL_ENV` matches, so a prod token never reaches the Pixel Dev guild. + * + * In GitHub Actions the Release workflow passes the env through the job instead + * (`PIXEL_ENV_SOURCE=process`, ADR 0014), so no file is read. + */ +import { existsSync, readFileSync } from "node:fs"; +import { + assertPixelEnv, + chooseEnvFile, + ENV_FILES, + ENVS, + EnvError, + type PixelEnv, + parseTarget, + pixelEnvOf, + type Target, + targetEnvironment, +} from "./envs.ts"; + +export type LoadedTarget = { + target: Target; + env: PixelEnv; + /** The environment to hand to `loadConfig`. */ + vars: Record; +}; + +/** Like `readTarget`, but prints the problem and exits when there is one. */ +export function loadTarget(argv: readonly string[], usage: string): LoadedTarget { + try { + return readTarget(argv, usage); + } catch (error) { + if (!(error instanceof EnvError)) throw error; + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} + +export function readTarget(argv: readonly string[], usage: string): LoadedTarget { + const target = parseTarget(argv, usage); + let vars: Record; + if (process.env.PIXEL_ENV_SOURCE === "process" && process.env.GITHUB_ACTIONS === "true") { + vars = process.env; + } else { + const { file, note } = chooseEnvFile(target, existsSync); + if (note) process.stdout.write(`${note}\n`); + const others = ENV_FILES.filter(existsSync).map((f) => readFileSync(f, "utf8")); + vars = targetEnvironment(process.env, others, readFileSync(file, "utf8")); + } + assertPixelEnv(target, pixelEnvOf(vars)); + return { target, env: ENVS[target], vars }; +}