From c3df8f49c62ec71f9544f43367f35c0a39a88624 Mon Sep 17 00:00:00 2001 From: Ryazanov Alexander <45588152+RyazanovAlexander@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:20:50 -0500 Subject: [PATCH] First deploy, checked: identity wait, service address, usage, checklist (T12, part 1) --- .github/workflows/deploy.yml | 9 ++ CLAUDE.md | 2 +- README.md | 12 +- RUN.md | 92 +++++++++++++ docs/decisions.md | 2 +- docs/live/01-first-deploy.md | 117 +++++++++++++++++ docs/self-hosting.md | 65 +++++++++- docs/stack.html | 2 +- infra/terraform/logging.tf | 13 +- infra/terraform/outputs.tf | 16 ++- .../terraform/tests/configuration.tftest.hcl | 42 ++++++ justfile | 122 ++++++++++++++++++ 12 files changed, 475 insertions(+), 19 deletions(-) create mode 100644 docs/live/01-first-deploy.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 49009fb..8e681f1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -235,6 +235,10 @@ jobs: workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ needs.infra.outputs.deploy_service_account }} - uses: ./.github/actions/gcloud + # The binding that lends this identity may be seconds old: the apply of + # this very delivery makes it when the identity is new. + - name: The identity can sign in + run: just ci-identity-ready # Read into a variable first: inside the echo, a push that failed would # leave the step green, and the next one would roll out an empty # reference. @@ -292,6 +296,11 @@ jobs: with: workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ needs.infra.outputs.hosting_service_account }} + # gcloud only to ask for a token, as the job above does: the binding that + # lends this identity may be seconds old too. + - uses: ./.github/actions/gcloud + - name: The identity can sign in + run: just ci-identity-ready - uses: ./.github/actions/firebase - name: Publish the SPA env: diff --git a/CLAUDE.md b/CLAUDE.md index 0415c06..257b5c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -310,7 +310,7 @@ Inside the container: - **`gh`.** What a change looks like on github.com is otherwise invisible from in here, and two parts of it are asked for by name: the alerts of the code scan, which a task closes before the next one starts, and whether the delivery that carried the change ran. `gh auth login` is answered once: the credentials live in the `canvas-api-gh` volume, as gcloud's live in `canvas-api-gcloud`, where Terraform reads them. - **Ports.** 8080 (the API) and 5173 (Vite's dev server for the SPA) are forwarded to the host. - **Just recipes.** `just --list` shows all of them. - - The everyday ones are `just fmt`, `just test`, `just lint`, `just build` and `just run`, and for the SPA `just web-dev`, which serves it on port 5173, `just web-fmt` and `just web-test`, and for the cloud `just tf-fmt`. `just web-build` builds the SPA into `web/dist`; `just build` and the image leave it out (R10). + - The everyday ones are `just fmt`, `just test`, `just lint`, `just build` and `just run`, and for the SPA `just web-dev`, which serves it on port 5173, `just web-fmt` and `just web-test`, and for the cloud `just tf-fmt` and `just usage`, which sets what the deployed service used of Cloud Run against a month's free tier. `just web-build` builds the SPA into `web/dist`; `just build` and the image leave it out (R10). - The ones that must be green before a task is done are **`just ci-lint`** (formatting and lint), **`just ci-test`** (the race detector and coverage), **`just ci-mocks-check`**, **`just ci-licenses`**, **`just ci-vuln`**, **`just ci-secrets`** (gitleaks over the whole history and over what is not committed yet), **`just ci-image-smoke`** (the runtime image starts, and its `/health` names this build), **`just ci-image-scan`** (Trivy over that image, HIGH and CRITICAL with a fix out), **`just release-version`** (the version the next release takes, from `RELEASE_LINE` in `release.yml` and the tags), **`just release-artifacts`** (what a release holds: the server for amd64 and arm64, the SPA's archive and `SHA256SUMS`, in `dist/`), **`just tf-check`** (the Terraform files formatted, the configuration valid against the providers its lock file names, and its tests on mock providers, with no credential) and **`just ci-workflows`** (actionlint, with shellcheck over the scripts of the workflows' steps but not of the composite actions': what GitHub would refuse or misread in the workflows, the call from `release.yml` to `deploy.yml` among it), and they are what CI runs (`.github/workflows/ci.yml`). `ci-licenses` holds every Go module and npm package to the license list and `THIRD_PARTY_LICENSES` to the dependency graph — `just licenses` rewrites it. `ci-vuln` runs govulncheck and `npm audit` (R48). A false alarm of the secret scan is waived in `.gitleaks.toml`, by a rule, a path and the shape of a value together, a vulnerability only in `.trivyignore`, and a finding of actionlint only in `.github/actionlint.yaml`; each with its reason. - A change to `web/` must also pass **`just ci-web`**: Biome, the types, the tests with their coverage, and a build. - gofmt never walks into `reference/`: the copy is the sample's, read-only. diff --git a/README.md b/README.md index f56f8a0..8f51a4e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ MathTrail Canvas is a math tutor for children in grades 1–6. A child solves a The picture is a concept from the design: it shows where the product is heading, further than the MVP goes. +## Design + +Where the design differs from the documents, the documents win. + +- [The olympiad coach](https://mathtrail.app/en/coach/) — live on mathtrail.app. + ## The MathTrail core [mathtrail-standalone](https://github.com/MathTrail/mathtrail-standalone) is the core of MathTrail: a free, open-source app for Claude and ChatGPT that turns an adult's own chat into an olympiad maths trainer for grades 1–6, with checked tasks in any language, a diagnosis of the child's mistake and a memory of how they are progressing. Canvas takes its service skeleton, development environment, CI/CD and Terraform, but runs on its own: it shares neither tasks nor data with the core. @@ -37,9 +43,3 @@ The documents are in Russian. - [docs/self-hosting.md](docs/self-hosting.md) — how it is deployed: the one-time bootstrap, the delivery of every release, rollback and return. - [docs/stack.html](docs/stack.html) — the technology stack, layer by layer, with versions and what is still to be chosen. Open the file from a checkout in a browser: GitHub shows only its source. - [RUN.md](RUN.md) — the implementation plan, in epics: the current epic broken into small tasks, run and reviewed one at a time, and the later epics. - -## Design - -It shows where the product is heading; where it differs from the documents, the documents win. - -- [The olympiad coach](https://mathtrail.app/en/coach/) — live on mathtrail.app. diff --git a/RUN.md b/RUN.md index e094ca4..b0a086d 100644 --- a/RUN.md +++ b/RUN.md @@ -261,6 +261,7 @@ | Д11 | Черновик T35 пишет, что API Agent Platform включила T10. Его включит задача, которая выключает кеш входов Gemini (privacy 5.2) | T10 | Ничем, пока черновик не стал задачей | При разбиении E2 | | Д12 | shellcheck скриптов composite actions в `.github/actions/` — в CI. actionlint берёт у action только объявленные входы, а скрипты не читает | T11 | Ошибка в скрипте `deployment-target`, `gcloud`, `firebase` или `docker-mirror` проявится только в прогоне после мержа. Сейчас их проверяют shellcheck руками, при правке | Со следующей правкой этих action | | Д13 | actionlint закреплён руками, 1.7.12 — последняя на 2026-10-14; Dependabot образы из `justfile` не читает. Новая версия, которая знает `queue`, снимет исключение в `.github/actionlint.yaml` | T11 | Новые проверки actionlint не приходят, а исключение может пережить свою причину | При выходе actionlint новее 1.7.12 | +| Д14 | Выкладка ждёт только токен нового аккаунта (`ci-identity-ready`), но не роли, которые тот же apply ему выдаёт: запись в реестр, роль деплоя на сервисе, роли Hosting. В задании SPA Firebase CLI получает токен заново, не тот, что дождался gcloud | T12 | На свежем развёртывании или с новым аккаунтом push, выкатка или публикация могут получить 403, пока IAM не разошёлся по своим серверам. Лечит «Re-run failed jobs» | Если повтор понадобится снова: проверять права шага через `testIamPermissions`, прежде чем шаг их потребует | --- @@ -1405,6 +1406,97 @@ - **Стоимость:** $0 в бесплатных лимитах. - **Риски:** проект и бакет состояния не пересоздавать. - **Разделы:** spec 15; R32, R45; standalone: R169. +- **Решено автором (2026-10-14, в планировании):** + - первая выкладка уже прошла — мерж T11. Вторая выкладка — мерж этой задачи, поэтому задача в две части: до мержа — код, документы и отчёт о первой выкладке; после — очередь `cd`, откат и возврат, затем второй PR с итогом; + - задержку IAM, на которой упала первая выкладка, чинить здесь: задания ждут, пока их аккаунт сможет войти; + - очередь `cd` проверить кнопкой Deploy во время второй выкладки; + - откат и возврат выполняет Claude по узкому разрешению. Перед этим автор пересобирает контейнер ради `firebase`. +- **Сделано (2026-10-14) — часть 1, до мержа:** + - **по правилу 3:** + - SonarCloud — только три находки из R51, хотспотов нет; + - code scanning — только алерты Scorecard из R51; + - Dependabot alerts — 0; + - выкладка T11 прошла со второй попытки (ниже); + - **первая выкладка** — отчёт `docs/live/01-first-deploy.md`, ход по UTC: + - релиз v0.1.3, apply на 31 ресурс; + - 403 у аккаунта деплоя через 5 с после его привязки; + - «Re-run failed jobs» по разрешению автора, и выкладка зелёная; + - **шаги автора:** условий Firebase принимать не понадобилось; своего домена нет. `TF_VAR_operator_email` — до мержа, тогда алерт создаст вторая выкладка; + - **ожидание входа** — рецепт `ci-identity-ready`: + - повторяет `gcloud auth print-access-token` до 8 минут по часам: и отказ, пока привязка не вошла в силу, и минутный сбой. Сразу показывает только то, что отвергает сам обмен токена GitHub: нет пула или провайдера, токен не проходит условие пула. Аккаунт, которого нет, получает тот же отказ, что ещё не одолженный, и ждётся вместе с ним; + - в `deploy.yml` стоит после входа в заданиях `deploy` и `hosting`. Задание SPA ради него ставит gcloud: в задании деплоя это занимает 17 с; + - ждёт только привязку, которая одалживает аккаунт, а роли, которые тот же apply выдаёт новому аккаунту, — нет. Это долг Д14; + - сроки — по документации IAM: обычно 2 минуты, иногда 7 и дольше (прочитана 2026-10-14); + - R58, «Следствие», поправлен; + - **адрес сервиса** — выход `service_url` берёт из `urls` адрес по номеру проекта, как его называет R55, а не адрес со случайной частью из `uri`. Тест `the_service_url_is_the_one_by_project_number` подставляет оба адреса через `override_resource` с `override_during = plan` (документация Terraform о моках, прочитана 2026-10-14). Если Cloud Run такого адреса не сообщит, предусловие выхода остановит apply с объяснением, а не отдаст выкладке пустой адрес; тест `a_service_without_its_regional_address_is_refused`; + - **sink `_Default`** — `unique_writer_identity = true`: + - пустой план после выкладки, проверка из «Открыто» T10, нашёл вечную правку `true → false`; + - провайдер 8.3.0 читает sink без идентичности как уникальный (исходник), а у sink с бакетом в своём проекте идентичности не бывает (справочник Cloud Logging API, прочитан 2026-10-14); + - тест держит значение. С исправлением план не меняет ни одного ресурса, только выход `service_url`; + - вывод T10 «`false` ничего не меняет» был верен для apply, но не для плана; + - **`just usage [since] [service]`** — перенос рецепта образца; проект он читает из `prod.auto.tfvars` сам, без помощника образца `_project`, у которого был бы один потребитель: + - метрики Cloud Run из Monitoring API v3 через `curl` и токен `gcloud`; токен идёт заголовком из дескриптора; + - лимит месяца при оплате за запросы — 180 000 vCPU-с, 360 000 ГиБ-с, 2 млн запросов (Google Cloud Free Tier, прочитана 2026-10-14); + - вид, единицы и задержка метрик (120 с) — по их дескрипторам из самого API, прочитаны 2026-10-14; + - сверх образца: + - окно не больше месяца: за месяц по минутам выходит 44 640 точек, а страница ответа держит 100 000 (справочник `timeSeries.list`); + - имя сервиса проверяется до того, как встанет в фильтр; + - точка без значения считается нулём, а не роняет рецепт; + - заготовке `REPLACE…` вместо проекта рецепт отказывает, как bootstrap; + - числа пишутся с точкой при любой локали (`LC_ALL=C`): `printf` в локали с запятой не принял бы ответ jq. Доказать выключением здесь нельзя — таких локалей в контейнере нет; + - вложенный `just` — через `just_executable()`; + - **чек-лист** «Первая выкладка: чек-лист» в `docs/self-hosting.md` — восемь пунктов с командами. Каждая команда прогнана на v0.1.3 ровно как записана. ID платёжного аккаунта и адрес канала алерта в вывод не попадают; + - **README** — раздел Design перед The MathTrail core, отдельная просьба автора. Прошёл ревью вместе с T12. Его первая фраза повторяла подпись к картинке выше, поэтому осталось только правило: «Where the design differs from the documents, the documents win.»; + - `CLAUDE.md` (`just usage`), `docs/stack.html`; + - **живые чтения на v0.1.3** — ≈ $0, только чтение. Все пункты чек-листа, кроме алерта (адреса ещё нет), `ci-tf-plan` вручную и `just usage 1d`: 12,8 vCPU-с — 0,007 % лимита месяца; + - **выключенные проверки** — 14. Каждая выключена в копии, и её плохой случай проходит: + - адрес сервиса — 3 поломки: `uri`, первый адрес из `urls`, без предусловия; + - sink — `false`; + - ожидание входа — 2 поломки: без повтора, без быстрого отказа на чужую ошибку; + - `usage` — 8 поломок: окно, месяц, имя сервиса, `int64Value` строкой, точка без значения, сумма вместо максимума экземпляров, токен в аргументах `curl`, заготовка `REPLACE…`. + + Проверки Terraform — выбор адреса, предусловие и sink — держат тесты `terraform test`. Проверки рецептов доказаны руками, на поддельных `gcloud` и `curl`: тестов рецептов в репозитории нет, как и в T08–T11. Fuzz нет, флейков не было. + - **ревью `/code-review high`:** + - первый круг, 9 находок. Исправлено: + - ожидание входа считало попытки, а не время — теперь 8 минут по часам; + - оно же пережидало любой отказ — теперь сразу показывает отказ обмена токена; + - выход `service_url` мог стать пустым — теперь предусловие; + - `_project` не отказывал заготовке `REPLACE…`; + - точка без значения роняла `usage`; + - запись о проверках выдавала ручные доказательства за тесты; + - запись о README говорила, что ревью не было, а фраза раздела Design повторяла подпись к картинке; + - документы обещали, что выкладка ждёт все новые привязки, — теперь только токен, а роли записаны долгом Д14. + + Отброшено: ставка gcloud в задании SPA (17 с замера) — тот же закреплённый SDK Google, что в задании деплоя. + - второй круг, 9 находок. Исправлено: + - быстрый отказ касался и минутных сбоев 5xx — теперь сразу показывается только отказ самого обмена токена (`invalid_grant`, `invalid_target`, `unauthorized_client` и подобные), остальное пережидается до срока; + - комментарий обещал быстрый отказ для аккаунта, которого нет, — его отказ неотличим от ещё не одолженного; + - `_project` с одним потребителем встроен в `usage`; + - ошибка `gcloud billing budgets` в чек-листе назвала бы платёжный аккаунт — теперь вывод маскируется; + - `usage` в локали с десятичной запятой писал бы неверные числа — `LC_ALL=C`; + - Firebase CLI в задании SPA получает токен заново — дописано в Д14. + + Отброшено: + - номер проекта будто бы не назван в `infra/` — он в `infra/ci.env`; + - ставка gcloud в задании SPA — повтор первого круга; + - пять запросов `usage` по очереди — рецепт запускают руками, а параллельные запросы усложнили бы разбор ошибок. + + Исправления второго круга прошли проверки, третьего ревью нет. +- **Открыто — часть 2, после мержа:** + - шаги автора до неё: + - секрет `TF_VAR_operator_email`; + - мерж — вторая выкладка; + - Rebuild Container; + - узкое разрешение в `.claude/settings.local.json` на `gh workflow run deploy.yml --ref main`, `gcloud run services update-traffic` и `firebase hosting:clone`; + - **очередь `cd`:** кнопка Deploy во время выкладки релиза должна ждать её конца. Затем она выкатывает тот же релиз, и её apply без изменений — проверка `ignore_changes` и `traffic`; + - **чек-лист на v0.1.4,** с алертом и каналом; + - **откат и возврат** по командам `docs/self-hosting.md`: ревизия и выпуск SPA v0.1.3, затем снова v0.1.4; + - **итог:** + - `just usage`; + - удалить файл разрешения; + - отчёт со статусом «закрыто»; + - T12 `[x]`; + - второй PR. > **Точка проверки эпика E1.** > - Правила работы и версии сверены с образцом от 2026-10-11. diff --git a/docs/decisions.md b/docs/decisions.md index 4ceff7c..9ad2ea7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -556,7 +556,7 @@ Centrifugo сохраняет роль из R23. Событие `verdict` ухо - сертификат в бандле релиза несёт SHA коммита: v0.1.2 проходит с 701cb47 и отказывает с 8b1fe03. Для `workflow_run` это SHA `main` в момент события. У релиза, который доходит до выкладки, он равен коммиту релиза: иначе `main` ушёл бы дальше и `newest` был бы ложен. — *Следствие:* -- первая выкладка может потребовать «Re-run failed jobs»: свежие API Firebase и привязки, созданные за секунды до использования; +- задания `deploy` и `hosting` после входа ждут, пока их аккаунт даст токен (`just ci-identity-ready`, до 8 минут): привязку нового аккаунта apply этой же выкладки создаёт за секунды до входа, а IAM вводит её в силу обычно за 2 минуты, иногда за 7 и дольше. Первая выкладка без этого ожидания получила 403 и прошла со второй попытки (T12, `docs/live/01-first-deploy.md`). «Re-run failed jobs» остаётся для только что включённых API и для ролей, которые тот же apply выдаёт новому аккаунту: их выкладка не ждёт (долг Д14 в RUN.md); - новая роль проекта для аккаунтов сначала попадает в список условия bootstrap, а bootstrap повторяется до мержа («Когда меняется bootstrap» в `docs/self-hosting.md`); - аккаунт Terraform достижим из PR этого репозитория через `deploy.yml`. PR может поменять задание плана и сделать им всё, что может этот аккаунт, и правило окружения `prod` его не держит. Открыть PR из ветки репозитория может только автор. Задания CI на PR не входят в облако только благодаря условию пула: их subject тоже `:pull_request`. Отдельный аккаунт плана, только для чтения, автор отложил (2026-10-14): он — в таблице технического долга RUN.md; - трафик переходит на новую ревизию до smoke, и упавший smoke оставляет её в работе до отката по `docs/self-hosting.md`. До трафика её `/health` проверен на том же образе перед push, а startup probe не даёт трафик ревизии, которая не отвечает. Выкладку ревизии без трафика, со smoke по адресу её тега, автор отложил (2026-10-14): она — в той же таблице; diff --git a/docs/live/01-first-deploy.md b/docs/live/01-first-deploy.md new file mode 100644 index 0000000..bee0072 --- /dev/null +++ b/docs/live/01-first-deploy.md @@ -0,0 +1,117 @@ +# Первая выкладка (T12) + +**Статус: идёт.** Первая выкладка прошла 2026-10-14 со второй попытки. Релиз v0.1.3 работает на своих адресах, и разворачивал его пайплайн. Откат и возврат проверяются на второй выкладке — это мерж T12; её итог допишется ниже. + +> Репозиторий публичный, поэтому в отчёте нет ни почты, ни ID платёжного аккаунта, ни токенов. Проект, его номер, адреса и имена ревизий и так названы в `infra/` и в ответах сервиса. + +## Что выкатывалось + +- Релиз `v0.1.3`, коммит `0bf1483` — мерж PR #15 (T11). Это и называет `/health`. +- API — `https://canvas-api-796865465273.us-east4.run.app`. Cloud Run отвечает и по второму адресу, со случайной частью. +- SPA — `https://mathtrail-canvas-prod.web.app`. + +## Роли + +- **Автор:** + - создал окружения `prod` и `prod-hosting` и секрет `TF_VAR_billing_account`; + - повторил bootstrap; + - внёс «Release build», «Terraform» и «Workflows» в обязательные проверки; + - смержил; + - разрешил повтор выкладки. +- **Claude:** + - сверил окружения и привязки только чтением; + - следил за прогоном и разобрал причину сбоя; + - перезапустил упавшие задания по разрешению автора; + - прошёл чек-лист и написал этот отчёт. + +## Стоимость + +- **Оценка до прогона:** ≈ $0, всё в бесплатных лимитах. +- **Факт:** `just usage 1d` в 19:21 UTC: + + | vCPU-секунды | ГиБ-секунды | Запросы | Оплачиваемые секунды экземпляров | Больше всего экземпляров в минуту | + |---:|---:|---:|---:|---:| + | 12,8 (0,007 % лимита месяца) | 4,5 (0,001 %) | 5 (0,000 %) | 3,4 | 1 | + + Писем бюджета не было. + +## Ход прогона + +Время — UTC, 2026-10-14. + +1. **18:31:02.** Мерж PR #15, коммит `0bf1483`. +2. **18:31:05–18:34:08.** CI на `main`: все 13 заданий зелёные. +3. **18:34:14–18:35:11.** Сборка, подпись и публикация. Релиз `v0.1.3` вышел в 18:35:08 и стал Latest. +4. **18:35:13–18:35:19.** «Which release»: `v0.1.3`, `main` всё ещё на его коммите. +5. **18:35:24–18:36:54.** `terraform apply`: `Apply complete! Resources: 31 added, 0 changed, 0 destroyed`. Принимать условия Firebase не понадобилось. Своего домена нет, DNS не нужен. +6. **18:36:59–18:37:42.** «The server of the release» упало. + - Подпись `SHA256SUMS` сверена. + - Образ собран из подписанного бинарника и до push ответил `v0.1.3 at 0bf1483`. + - Вход аккаунтом деплоя получил 403 `iam.serviceAccounts.getAccessToken`. Привязку WIF этого аккаунта apply создал в 18:36:54, за 5 секунд до входа, а IAM вводит новые привязки в силу обычно за 2 минуты, иногда за 7 и дольше (документация IAM, прочитана 2026-10-14). + - Без токена `docker push` получил отказ, и SPA пропущена. +7. **Сверка только чтением.** + - `canvas-api-deploy` одалживается subject `:environment:prod`, `canvas-api-hosting` — subject `:environment:prod-hosting`. + - `main` всё ещё на `0bf1483`. + - Автор разрешил повтор. +8. **18:41:42.** «Re-run failed jobs», попытка 2. +9. **18:41:49–18:42:56.** Сервис: ревизия `canvas-api-00002-p84` (18:42:41) получила весь трафик, smoke — `v0.1.3 at 0bf1483`. +10. **18:43:01–18:43:34.** SPA: выпуск Hosting в 18:43:18 с сообщением `v0.1.3`. Страница сайта совпала с архивом релиза и отдаётся с `no-cache`. + +От мержа до зелёной выкладки — 12,5 минуты, из них около четырёх ушло на решение о повторе. + +## Проверки + +Чек-лист «Первая выкладка» из `docs/self-hosting.md`, пройден 2026-10-14 на `v0.1.3`. + +| Пункт | Результат | +|---|---| +| `/health` называет релиз и коммит | Да: `v0.1.3 at 0bf1483`, по обоим адресам, с `Cache-Control: no-store` | +| Строка старта называет версию и коммит | Да: 18:42:45 и 18:42:47, ревизия `canvas-api-00002-p84`, `v0.1.3`, `0bf1483` | +| Автоочистка Artifact Registry действует | Да: `keep-recent` (5 версий) и `delete-old` (2592000 с, любые теги). Поля `cleanupPolicyDryRun` нет — пробного режима нет. Реестр — 3,7 МБ | +| Бюджет есть и смотрит на этот проект | Да: `canvas-api spend`, 1 USD, `projects/796865465273`, месяц, пороги 0.5, 0.9 и 1.0 | +| Алерт нагрузки и его канал | Нет, и так и ожидалось: адрес оператора ещё не задан | +| SPA без кеша и называет релиз | Да: `no-cache`, скрипт `index-e6yTJfJc.js` несёт `v0.1.3`; `/__/firebase/init.json` отвечает 200 | +| Конфигурация совпадает с облаком | Нет, исправлено в T12. План после первого apply хотел менять sink `_Default` (ниже). С исправлением план не меняет ни одного ресурса, только выход `service_url` | +| Расход в бесплатных лимитах | Да, «Стоимость» выше | + +## Что нашёл прогон + +1. **Новый аккаунт не может войти сразу.** + - Задание входит аккаунтом, привязку которого apply этой же выкладки создал секундами раньше. + - Исправлено в T12: задания `deploy` и `hosting` после входа ждут, пока аккаунт даст токен — `just ci-identity-ready`, до 8 минут. + - Без нового аккаунта ожидание занимает одну попытку. +2. **Sink `_Default` менялся в каждом плане.** + - Конфигурация просила `unique_writer_identity = false`. Провайдер 8.3.0 читает sink без идентичности как sink с уникальной и показывал `true → false` в каждом плане и apply. + - У sink, чей бакет в его же проекте, идентичности не бывает (справочник Cloud Logging API, прочитан 2026-10-14). + - Теперь в конфигурации `true`, и тест держит это значение. +3. **Адрес сервиса.** + - Выход `service_url` давал адрес со случайной частью (`…-uk.a.run.app`), а R55 и документы называют адрес по номеру проекта. + - Теперь выход — адрес по номеру проекта, из атрибута `urls`; тест на это есть. + - По нему же идут smoke выкладки и ссылка окружения `prod`. + +## Открыто + +- **Вторая выкладка — мерж T12.** После неё дописываются: + - очередь `cd` при выкладке, которую вызывает `release.yml`; + - откат и возврат по командам `docs/self-hosting.md`; + - алерт с адресом оператора; + - чек-лист на `v0.1.4`. +- **Из «Как проверить» T11 вживую не проверено:** + - план на PR Dependabot — такого PR, который трогал бы `infra/` или `deploy.yml`, ещё не было; + - коммит с красным CI; + - кнопка на другой ветке; + - перезапуск CI старого коммита. + + Их держат условия workflow и проверки рецептов без облака (T11). + +## Как повторить + +Развёртывание с нуля описывает `docs/self-hosting.md`: bootstrap, секреты, окружения, затем мерж в `main`. + +```bash +gh run watch <прогон «Release and deploy»> # выкладка после мержа +gh run rerun <прогон> --failed # если упало задание выкладки +just usage 1d # расход против бесплатного лимита +``` + +Остальное — пункты раздела «Первая выкладка: чек-лист» в `docs/self-hosting.md`, по порядку. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index ed83636..bd88e7f 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,6 +1,6 @@ # Развёртывание canvas-api -T10 — облако как код и разовый bootstrap, T11 — выкладка пайплайном, откат и возврат. Первый деплой и его чек-лист добавит T12, полностью документ допишет T57. Решения, на которых всё держится, — R54–R58 в [decisions.md](decisions.md). +T10 — облако как код и разовый bootstrap, T11 — выкладка пайплайном, откат и возврат, T12 — чек-лист первой выкладки и расход сервиса (`just usage`). Полностью документ допишет T57. Решения, на которых всё держится, — R54–R58 в [decisions.md](decisions.md). ## Что делается руками @@ -93,9 +93,10 @@ TF_VAR_billing_account=… just ci-tf-plan 2. **The cloud, as the release describes it** — окружение `prod`: `just ci-tf-apply` из коммита тега. 3. **The server of the release** — окружение `prod`: - сервер amd64 из релиза, подпись `SHA256SUMS` проверена (`just ci-release-fetch`); + - вход аккаунтом деплоя, и ожидание, пока он даст токен (`ci-identity-ready`, до 8 минут): привязку нового аккаунта создаёт apply этой же выкладки, а IAM вводит её в силу обычно за 2 минуты, иногда за 7 и дольше (документация IAM, прочитана 2026-10-14); - образ вокруг него, проверка `/health` до push, push, ревизия по digest и трафик на неё (`ci-image-push`, `ci-deploy`); - smoke: `/health` по адресу сервиса называет релиз и коммит (`ci-smoke`). -4. **The SPA of the release** — окружение `prod-hosting`, свой аккаунт: архив SPA из релиза, `firebase deploy`, затем страница сайта сверяется с архивом (`ci-hosting-deploy`, `ci-hosting-check`). +4. **The SPA of the release** — окружение `prod-hosting`, свой аккаунт и то же ожидание его входа: архив SPA из релиза, `firebase deploy`, затем страница сайта сверяется с архивом (`ci-hosting-deploy`, `ci-hosting-check`). Кнопка — Actions → Deploy → Run workflow на `main`: выкатывает релиз на вершине `main`, а если его нет — ничего. На другой ветке она падает в первом задании. Упавшую выкладку повторяют «Re-run failed jobs» в прогоне «Release and deploy»; повторная попытка сначала проверяет, что `main` всё ещё стоит на коммите релиза. @@ -108,7 +109,63 @@ TF_VAR_billing_account=… just ci-tf-plan - бюджет и, если задан адрес, алерт; - бакет журналов в регионе и перенаправление `_Default`, место хранения трасс. -Первая выкладка может потребовать одного повтора — «Re-run failed jobs»: только что включённые API и только что созданные аккаунты и привязки видны не сразу. +Привязку, которая одалживает аккаунт, задания выкладки ждут сами. Роли, которые тот же apply выдаёт новому аккаунту, обычно успевают за это время; если какая-то не успела, шаг, которому она нужна, получает 403. Тогда, как и после отказа только что включённого API, выкладку повторяют «Re-run failed jobs». Первая выкладка этого развёртывания прошла со второй попытки, ещё без ожидания: аккаунт деплоя входил через 5 секунд после создания своей привязки и получил 403 (`docs/live/01-first-deploy.md`). + +## Первая выкладка: чек-лист + +Выкладка закончена, когда каждый пункт ниже ответил «да». Команды — из devcontainer на x86_64 или из Cloud Shell, с учётными данными человека, у которого есть права на проект. ID платёжного аккаунта берётся из переменной окружения и на экран не попадает. + +1. **`/health` называет выкаченный релиз и коммит.** Рецепт сверяет, что короткий коммит в ответе — начало полного коммита тега: + + ```bash + just ci-smoke https://canvas-api-796865465273.us-east4.run.app <версия> "$(git rev-parse '<версия>^{commit}')" + ``` + +2. **Строка старта в журнале называет ту же версию и коммит.** Её пишет только сервер, выкаченный CD; начальный образ `hello` её не пишет: + + ```bash + gcloud logging read 'resource.type="cloud_run_revision" AND jsonPayload.message="startup"' \ + --project=mathtrail-canvas-prod --bucket=default-us-east4 --location=us-east4 --view=_AllLogs \ + --freshness=1d --limit=5 \ + --format='value(timestamp,resource.labels.revision_name,jsonPayload.version,jsonPayload.commit)' + ``` + +3. **Автоочистка Artifact Registry действует:** политики `keep-recent` (5 версий) и `delete-old` (30 дней), без пробного режима. Поля `cleanupPolicyDryRun` в ответе нет, пока оно не `true`: + + ```bash + gcloud artifacts repositories describe canvas-api --location=us-east4 --project=mathtrail-canvas-prod \ + --format='yaml(cleanupPolicies,cleanupPolicyDryRun)' + ``` + +4. **Бюджет есть и смотрит на этот проект:** `canvas-api spend`, 1 USD в месяц, `projects/<номер проекта>`, пороги 0.5, 0.9 и 1.0. На платёжном аккаунте могут быть бюджеты и других проектов — нужна строка с номером этого. Поле `name` в вывод не берётся: в нём ID платёжного аккаунта. Ошибка gcloud тоже называет аккаунт, поэтому её вывод маскируется: + + ```bash + gcloud billing budgets list --billing-account="$TF_VAR_billing_account" \ + --billing-project=mathtrail-canvas-prod \ + --format='table(displayName,amount.specifiedAmount.units,amount.specifiedAmount.currencyCode,budgetFilter.projects.list(),thresholdRules.thresholdPercent.list())' \ + 2>&1 | sed "s/${TF_VAR_billing_account}//g" + ``` + +5. **Алерт нагрузки и его канал есть**, если задан `TF_VAR_operator_email`. Без адреса их нет, и это тоже ответ. Адрес канала не печатается: маска `fields` берёт только имя, вид и то, включён ли канал. + + ```bash + gcloud monitoring policies list --project=mathtrail-canvas-prod \ + --format='value(displayName,enabled,notificationChannels.len())' + curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$(gcloud auth print-access-token)") \ + "https://monitoring.googleapis.com/v3/projects/mathtrail-canvas-prod/notificationChannels?fields=notificationChannels(displayName,type,enabled)" + ``` + +6. **SPA отдаёт страницу без кеша, и её скрипт называет релиз:** + + ```bash + curl -fsS -D - -o /dev/null https://mathtrail-canvas-prod.web.app/ | grep -i '^cache-control' + script=$(curl -fsS https://mathtrail-canvas-prod.web.app/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1) + curl -fsS "https://mathtrail-canvas-prod.web.app${script}" | grep -c '<версия>' + ``` + +7. **Конфигурация совпадает с облаком.** Выкладка, которой нечего менять, пишет `Apply complete! Resources: 0 added, 0 changed, 0 destroyed`. План вручную (выше) не печатает строки `Plan:`: меняться могут только выходы. + +8. **Расход — в бесплатных лимитах:** `just usage 1d` — vCPU-секунды, ГиБ-секунды и запросы сервиса против бесплатного лимита месяца. Лимит — платёжного аккаунта, общий для всех его проектов, поэтому свободного остатка может быть меньше, чем показано. ## Откат и возврат @@ -187,7 +244,7 @@ gcloud logging read 'resource.type="cloud_run_revision"' --project=mathtrail-can Ожидается $0 в бесплатных лимитах. Бюджет $1 пишет на 50, 90 и 100 %, но трат не останавливает. - **Бакет состояния** в `us-east4` — доли цента в месяц: регион вне бесплатных у Cloud Storage. -- **Cloud Run** — сверх бесплатных лимитов. Они считаются на платёжный аккаунт и делятся со всеми его проектами (spec 14.4). `us-east4` — Tier 1. +- **Cloud Run** — сверх бесплатных лимитов. Они считаются на платёжный аккаунт и делятся со всеми его проектами (spec 14.4). `us-east4` — Tier 1. Что сервис израсходовал против лимита месяца, показывает `just usage <окно>`. - **Artifact Registry** — сверх 0,5 ГБ. Автоочистка держит 5 последних образов и удаляет старше 30 дней. Container Scanning API не включается: он стоит $0.26 за каждый образ. - **Cloud Logging** — сверх 50 ГиБ в месяц на проект и при хранении дольше 30 дней. - **Cloud Trace** — сверх 2,5 млн спанов в месяц. diff --git a/docs/stack.html b/docs/stack.html index cfa6123..41e1164 100644 --- a/docs/stack.html +++ b/docs/stack.html @@ -527,7 +527,7 @@

После MVP: платформа MathTrai { id: "obs", name: "Наблюдаемость", path: "internal/telemetry/", note: "в логах только UUID и коды", rows: [ { n: "OpenTelemetry Go", p: "otel · otlptracehttp · otlpmetrichttp · GCP detector", v: "v1.46.0", r: "Трассы и метрики; экспорт на telemetry.googleapis.com, сброс до ответа", s: "sample", ref: "spec 16 · E2" }, { n: "Cloud Trace", v: "хранение 30 дней", r: "Спаны: запрос, OpenFGA, картинки, Neon, Gemini, Centrifugo; хранятся в us-east4", s: "spec", ref: "spec 16 · R57 · E2" }, - { n: "Cloud Monitoring", v: "", r: "Метрики canvas_* и алерты: 5xx, p95 до вердикта, сбои чтения, доля запасных подсказок; алерт на число запросов — alert.tf", s: "spec", ref: "spec 16 · R56 · T10, E2" }, + { n: "Cloud Monitoring", v: "", r: "Метрики canvas_* и алерты: 5xx, p95 до вердикта, сбои чтения, доля запасных подсказок; алерт на число запросов — alert.tf; just usage читает метрики Cloud Run против бесплатного лимита", s: "spec", ref: "spec 16 · R56 · T10, T12, E2" }, { n: "Cloud Logging", v: "", r: "JSON-логи zap, связанные с трассой; только UUID, коды и тайминги — без картинок и прочитанного ответа; бакет default-us-east4, 30 дней", s: "spec", ref: "spec 16 · R57 · E2" } ]}, { id: "quality", name: "Качество кода", path: "CI · .githooks/", note: "блокируют: патч Codecov, CodeQL, Trivy", rows: [ diff --git a/infra/terraform/logging.tf b/infra/terraform/logging.tf index 2360225..0c83979 100644 --- a/infra/terraform/logging.tf +++ b/infra/terraform/logging.tf @@ -18,10 +18,13 @@ resource "google_logging_project_bucket_config" "logs" { # The `_Default` sink, sent to the bucket above with the filter it was created # with, word for word: everything the `_Required` sink does not take. The # configuration takes the sink over rather than creating one, and a destroy -# leaves it where it is. The bucket is in the sink's own project, so the sink -# writes with no identity of its own, as the project's `_Default` does now (seen -# 2026-10-14); asking for a unique one would only make an identity nobody -# needs. +# leaves it where it is. +# +# The bucket is in the sink's own project, and such a sink has no writer +# identity whatever it asks for (Cloud Logging's API reference, read +# 2026-10-14). The provider reads a sink with no writer identity as one with a +# unique identity, so that is what is written here: written false, every plan +# would show a change that no apply makes (the provider's source, 8.3.0). resource "google_logging_project_sink" "default" { name = "_Default" destination = "logging.googleapis.com/projects/${var.project_id}/locations/${var.region}/buckets/${google_logging_project_bucket_config.logs.bucket_id}" @@ -33,7 +36,7 @@ resource "google_logging_project_sink" "default" { "NOT LOG_ID(\"cloudaudit.googleapis.com/access_transparency\")", "NOT LOG_ID(\"externalaudit.googleapis.com/access_transparency\")", ]) - unique_writer_identity = false + unique_writer_identity = true } # Traces are kept in the bucket `_Trace`, which the platform makes with the diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 4384886..a90b258 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -5,9 +5,23 @@ output "project_id" { value = var.project_id } +# Cloud Run answers at two addresses: one made of the service's name, the +# project's number and the region, which can be written down before the service +# exists, and one with a random part, which is what uri holds. The first is the +# address everything else names, and a service that does not report it stops +# the apply here rather than leaving a delivery an empty address to check. +locals { + regional_urls = [for url in google_cloud_run_v2_service.api.urls : url if endswith(url, ".${var.region}.run.app")] +} + output "service_url" { description = "The service's own address on run.app, the one the SPA calls." - value = google_cloud_run_v2_service.api.uri + value = one(local.regional_urls) + + precondition { + condition = length(local.regional_urls) == 1 + error_message = "Cloud Run reports no address of the service made of its name, the project's number and the region." + } } output "hosting_url" { diff --git a/infra/terraform/tests/configuration.tftest.hcl b/infra/terraform/tests/configuration.tftest.hcl index d70e266..31d9686 100644 --- a/infra/terraform/tests/configuration.tftest.hcl +++ b/infra/terraform/tests/configuration.tftest.hcl @@ -76,6 +76,41 @@ run "the_service_size_and_start_stay_as_chosen" { } } +# The address a delivery checks and shows is the one made of the service's name, +# the project's number and the region, not the one with a random part. +run "the_service_url_is_the_one_by_project_number" { + command = plan + + override_resource { + target = google_cloud_run_v2_service.api + override_during = plan + values = { + uri = "https://canvas-api-a1b2c3d4e5-uk.a.run.app" + urls = ["https://canvas-api-a1b2c3d4e5-uk.a.run.app", "https://canvas-api-123456789012.us-east4.run.app"] + } + } + + assert { + condition = output.service_url == "https://canvas-api-123456789012.us-east4.run.app" + error_message = "The service's address is the one made of its name, the project's number and the region." + } +} + +run "a_service_without_its_regional_address_is_refused" { + command = plan + + override_resource { + target = google_cloud_run_v2_service.api + override_during = plan + values = { + uri = "https://canvas-api-a1b2c3d4e5-uk.a.run.app" + urls = ["https://canvas-api-a1b2c3d4e5-uk.a.run.app"] + } + } + + expect_failures = [output.service_url] +} + run "memory_in_mebibytes_counts_too" { command = plan @@ -113,6 +148,13 @@ run "the_default_sink_keeps_its_filter" { condition = google_logging_project_sink.default.filter == "NOT LOG_ID(\"cloudaudit.googleapis.com/activity\") AND NOT LOG_ID(\"externalaudit.googleapis.com/activity\") AND NOT LOG_ID(\"cloudaudit.googleapis.com/system_event\") AND NOT LOG_ID(\"externalaudit.googleapis.com/system_event\") AND NOT LOG_ID(\"cloudaudit.googleapis.com/access_transparency\") AND NOT LOG_ID(\"externalaudit.googleapis.com/access_transparency\")" error_message = "The _Default sink keeps the filter Cloud Logging created it with, word for word." } + + # A sink whose bucket is in its own project has no writer identity, and the + # provider reads that as a unique one: anything else is a change in every plan. + assert { + condition = google_logging_project_sink.default.unique_writer_identity + error_message = "The _Default sink asks for a unique writer identity, which is how the provider reads a sink with none." + } } run "an_image_by_tag_is_refused" { diff --git a/justfile b/justfile index 97f96ad..6ae89ca 100644 --- a/justfile +++ b/justfile @@ -789,6 +789,94 @@ _tf-redact: printf '%s\n' "$line" done +# What Cloud Run counted of the deployed service within a window — 30m, 2h, 1d, +# a month at most — as Cloud Monitoring keeps it: the processor and the memory +# its instances were allocated, which is what the free tier is counted in, the +# requests that reached them, the time they were billed for, and the most +# instances that served in one minute. Each series is summed a minute at a +# time; the instances are taken on average over each minute and added up across +# revisions, so that counts taken at different moments of a minute never add +# up to more than ran at once. The window is set against a month's free tier +# for request-based billing: 180,000 vCPU-seconds, 360,000 GiB-seconds and two +# million requests (Google Cloud's Free Tier, read 2026-10-14). The free tier +# is the billing account's, shared by all its projects, so what is left of it +# can be less than this shows. +# +# A minute is shown up to two minutes after it ends, so a window that ends now +# may leave out its last minutes. A month of minutes is one page of the API's +# answer, which holds up to 100,000 points. The project is read from the +# configuration as infra/bootstrap.sh reads it, and refused, as there, while it +# is still a template's. The token rides in a header read from a file +# descriptor, never on the command line. Numbers are written with a decimal +# point whatever the locale, since jq hands them over so. +# What the deployed service used of Cloud Run within a window, against a month's free tier +usage since="1d" service="canvas-api": + #!/usr/bin/env bash + set -euo pipefail + export LC_ALL=C + if ! command -v gcloud > /dev/null; then + echo "usage: there is no gcloud here; the development image installs it on x86_64 only, and Cloud Shell has it" >&2 + exit 1 + fi + since={{ quote(since) }} + service={{ quote(service) }} + if [[ ! "$since" =~ ^[1-9][0-9]{0,4}[mhd]$ ]]; then + echo "usage: a window is whole minutes, hours or days, such as 30m, 2h or 1d, not ${since}" >&2 + exit 1 + fi + case "$since" in + *m) seconds=$(( ${since%m} * 60 )) ;; + *h) seconds=$(( ${since%h} * 3600 )) ;; + *d) seconds=$(( ${since%d} * 86400 )) ;; + esac + if (( seconds > 31 * 86400 )); then + echo "usage: a window is a month at most, 31d, not ${since}" >&2 + exit 1 + fi + if [[ ! "$service" =~ ^[a-z]([a-z0-9-]{0,47}[a-z0-9])?$ ]]; then + echo "usage: ${service} is not the name of a Cloud Run service" >&2 + exit 1 + fi + named={{ quote(TF_DIR / "prod.auto.tfvars") }} + project=$(sed -n 's/^[[:space:]]*project_id[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$named" | head -1) + if [[ -z "$project" || "$project" == REPLACE* ]]; then + echo "usage: project_id is not filled in yet in ${named}" >&2 + exit 1 + fi + token=$(gcloud auth print-access-token) + now=$(date -u +%s) + end=$(date -u -d "@${now}" +%Y-%m-%dT%H:%M:%SZ) + start=$(date -u -d "@$(( now - seconds ))" +%Y-%m-%dT%H:%M:%SZ) + series() { + curl -fsS --max-time 30 -G "https://monitoring.googleapis.com/v3/projects/${project}/timeSeries" \ + -H @<(printf 'Authorization: Bearer %s\n' "$token") \ + --data-urlencode "filter=metric.type=\"run.googleapis.com/$1\" AND resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"${service}\"${3:+ AND $3}" \ + --data-urlencode "interval.startTime=${start}" \ + --data-urlencode "interval.endTime=${end}" \ + --data-urlencode "aggregation.alignmentPeriod=60s" \ + --data-urlencode "aggregation.perSeriesAligner=$2" \ + --data-urlencode "aggregation.crossSeriesReducer=REDUCE_SUM" \ + --data-urlencode "view=FULL" + } + # A count arrives as a string, a measure as a number, and a point that + # carries neither counts as nothing. + values='[.timeSeries[]?.points[]?.value | (.doubleValue // ((.int64Value // "0") | tonumber))]' + vcpu=$(series container/cpu/allocation_time ALIGN_SUM | jq "${values} | add // 0") + gib=$(series container/memory/allocation_time ALIGN_SUM | jq "${values} | add // 0") + requests=$(series request_count ALIGN_SUM | jq "${values} | add // 0") + billed=$(series container/billable_instance_time ALIGN_SUM | jq "${values} | add // 0") + instances=$(series container/instance_count ALIGN_MEAN 'metric.labels.state="active"' | jq "${values} | max // 0") + share() { awk -v used="$1" -v free="$2" 'BEGIN { printf "%.3f %%", 100 * used / free }'; } + echo "Cloud Run, the service ${service} of ${project}, from ${start} to ${end}, as Cloud Monitoring counts it:" + echo + echo "| | Used | Of a month's free tier |" + echo "|---|---:|---:|" + echo "| vCPU-seconds | $(printf '%.1f' "$vcpu") | $(share "$vcpu" 180000) |" + echo "| GiB-seconds | $(printf '%.1f' "$gib") | $(share "$gib" 360000) |" + echo "| Requests | $(printf '%.0f' "$requests") | $(share "$requests" 2000000) |" + echo "| Instance-seconds billed | $(printf '%.1f' "$billed") | |" + echo "| Most instances that served in one minute | $(printf '%.1f' "$instances") | |" + # -- Delivery --------------------------------------------------------------- # A delivery rolls out a release, named by its tag: the server and the SPA that @@ -919,6 +1007,40 @@ ci-release-fetch version commit file dir: mkdir -p "$dir" mv "$scratch/$file" "$dir/$file" +# A job borrows its identity through the federated pool, and the binding that +# lends it may be seconds old: the apply of the same delivery makes it when the +# identity is new, and IAM takes typically two minutes, at times seven or more, +# to put a new binding in force (IAM's documentation, read 2026-10-14). Until +# then the identity's token is refused, and so would be the push or the publish +# after it. So the job asks for a token until it gets one, for eight minutes by +# the clock, and a refusal on the way — the binding not in force yet, an outage +# of a moment — is asked again. What the exchange of GitHub's token itself +# refuses — a pool or a provider that is not there, a token the pool's +# condition does not admit — is no matter of time, and is shown at once. An +# identity that does not exist is refused as one not lent yet is, and is waited +# out with it. +# Wait until the identity this job borrowed can get a token +ci-identity-ready: + #!/usr/bin/env bash + set -euo pipefail + deadline=$(( SECONDS + 480 )) + attempt=1 + while ! refusal=$(gcloud auth print-access-token 2>&1 > /dev/null); do + if [[ "$refusal" =~ invalid_(grant|target|request|client|scope)|unauthorized_client|unsupported_grant_type ]]; then + echo "$refusal" >&2 + exit 1 + fi + if (( SECONDS >= deadline )); then + echo "$refusal" >&2 + echo "ci-identity-ready: the identity still gets no token after eight minutes" >&2 + exit 1 + fi + echo "identity: no token yet (attempt ${attempt})" >&2 + attempt=$(( attempt + 1 )) + sleep 15 + done + echo "ci-identity-ready: the identity can sign in" + # The image of a release is the runtime around the server that release built # and signed, never one built again: what is rolled out is what was released, # byte for byte. It has to name the release before it is pushed, as the