From adac0ac66b3700e2cc5b5a53ef8030c4c53d75a1 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 19:23:46 +0000 Subject: [PATCH 01/26] test(deploy): guard against implicit image pulls on deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playbook run made for an unrelated change (token rotation, webhook_domain, a limit tweak) currently re-resolves the moving :latest tag and ships whatever landed on main since the last deploy. Nothing pins that down, so the pull directives can silently come back after being removed. Assert the invariant over the files that decide it — the shipped compose file, the role template and the role tasks — so a reintroduced `--pull always` or `pull_policy: always` fails `task tests`, the gate CI runs. Comment lines are skipped so the rationale may name the directive it forbids. This commit is intentionally red; the next one removes the directives. --- adapters/telegram/deploy_pull_test.go | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 adapters/telegram/deploy_pull_test.go diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go new file mode 100644 index 0000000..462c41c --- /dev/null +++ b/adapters/telegram/deploy_pull_test.go @@ -0,0 +1,56 @@ +package telegram_test + +// Guard for a DEPLOY invariant, not for Go code: moving a deployment to a new image +// must stay an explicit operator action. It lives in this package because every file +// it guards ships under adapters/telegram/ (the shipped compose file plus the Ansible +// role), and `go test` runs with the package directory as its working directory, so +// the paths below are stable relative paths. There is no deploy-only Go package to +// host it, and `task tests` is the only gate CI runs — a Go test is what gets executed. + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// Files that decide whether a playbook run may replace the running image. +var deployManifests = []string{ + "docker-compose.yml", + "deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2", + "deploy/roles/claude_tg_bot/tasks/main.yml", +} + +// Directives that make `docker compose up` re-resolve a moving tag (":latest") against +// the registry, so an unrelated playbook run silently ships whatever landed on main +// since the last deploy. Both spellings are matched loosely (quoting, "--pull=always") +// because they are the same instruction re-spelled. +var implicitPullDirectives = []*regexp.Regexp{ + regexp.MustCompile(`--pull[ =]+["']?always`), + regexp.MustCompile(`pull_policy:\s*["']?always`), +} + +// TestDeployNeverPullsImplicitly asserts no shipped compose file or Ansible task re-pulls +// the image on its own. Updating the bot is the operator's decision (an explicit +// `docker compose pull`), never a side effect of a playbook run made for something else +// — a token rotation or a limit change must not also roll the version forward. +func TestDeployNeverPullsImplicitly(t *testing.T) { + for _, name := range deployManifests { + raw, err := os.ReadFile(name) //nolint:gosec // G304: fixed repo-relative path, no input + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + for i, line := range strings.Split(string(raw), "\n") { + // Comments may name the directive to explain why it is absent. + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + for _, re := range implicitPullDirectives { + if re.MatchString(line) { + t.Errorf("%s:%d matches %q: %s\ndeploys must not pull implicitly — "+ + "moving to a new image is an explicit operator action", name, i+1, re, strings.TrimSpace(line)) + } + } + } + } +} From f6353516f9e99858320166a65ed45eef69715a46 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 19:24:45 +0000 Subject: [PATCH 02/26] fix(deploy): stop the playbook from silently updating the bot image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker compose up -d --pull always` plus `pull_policy: always` made every playbook run re-resolve the moving :latest tag against the registry. A run started for something unrelated — rotating a token, setting webhook_domain, flipping enable_dind, changing a limit — therefore also deployed every commit merged into main since the last run. Updates must be a decision, not a side effect of editing configuration. Drop both directives so compose reuses the image the host already has (it still fetches it on a first install, where nothing is present locally). To move a deployment forward the operator now runs `docker compose pull` and `up -d` explicitly; nothing is downgraded, since the installation stays on exactly the image it is running. --force-recreate stays: with the tag held still it only rebuilds the container, which is what makes a rendered .env take effect on compose versions older than v2.14 that do not detect that change themselves. --- .../telegram/deploy/roles/claude_tg_bot/tasks/main.yml | 9 +++++++-- .../roles/claude_tg_bot/templates/docker-compose.yml.j2 | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml index 3ef792e..74425dd 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml @@ -136,9 +136,14 @@ mode: "0600" no_log: true -- name: Pull image and (re)start the bot +# No --pull: moving to a new image is an explicit operator action (`docker compose pull`), +# not a side effect of running this playbook — a run made for a token rotation or a limit +# change must not also roll the version forward. --force-recreate stays: with the tag held +# still it only rebuilds the container (so a changed .env is picked up even on compose +# versions older than v2.14, which don't detect that themselves), never the image. +- name: (Re)start the bot on its current image ansible.builtin.command: - cmd: docker compose up -d --pull always --force-recreate + cmd: docker compose up -d --force-recreate chdir: "{{ app_dir }}" async: 1800 poll: 15 diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 index fbe2513..cc0fc8e 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 @@ -2,7 +2,10 @@ services: bot: image: {{ bot_image }} - pull_policy: always + # No pull policy override on purpose: compose keeps the image it already has, so a + # `docker compose up` (playbook run, host reboot recovery) cannot move the deployment + # to a newer build behind the operator's back — even though bot_image tracks a moving + # tag. Updating is a deliberate `docker compose pull` followed by `up -d`. container_name: {{ container_prefix }}-bot restart: unless-stopped # Graceful drain on deploy: give the bot up to 75s after SIGTERM to drain From bfb0b2410049155aca3de4e610f22531b55f8c39 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 19:30:21 +0000 Subject: [PATCH 03/26] ci: stamp the immutable build number into the image version label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Published images carried org.opencontainers.image.version="latest", which tells a reader nothing about which build a host is actually running — the one question the label exists to answer. docker/metadata-action sorts the tag list by priority and stamps the first entry into that label (src/tag.ts sorts, src/meta.ts keeps the first enabled value as version.main). Both raw entries share the default priority 200, so the listed order decided it and :latest came first. Put the immutable 0.1. ahead of :latest so the label names the exact build. The published tag set is unchanged (:latest is still emitted, as a partial version), and a vX.Y.Z tag push keeps priority since type=ref outranks raw. Kept both publish workflows mirrored; ci.yml's dry-run image jobs build without metadata-action, so nothing there can drift. --- .github/workflows/publish-telegram.yml | 10 ++++++++-- .github/workflows/publish-vk.yml | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-telegram.yml b/.github/workflows/publish-telegram.yml index f9080c5..82998ec 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -48,15 +48,21 @@ jobs: with: images: ghcr.io/duckbugio/flock-telegram # Tags produced per build: - # :latest — rolling, default branch only (what deploys track) # :0.1. — ordered, human-readable auto-version for every main # build (no manual step); the number increments per # publish run so images sort and read sanely + # :latest — rolling, default branch only (what deploys track) # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback + # The order is load-bearing: metadata-action sorts tags by priority (both raw + # entries share the default 200, so the listing below decides) and stamps the + # FIRST one into org.opencontainers.image.version. With :latest leading, every + # image carried version="latest" — worthless for telling which build a host is + # running — so the immutable 0.1. goes first. A vX.Y.Z tag push + # still overrides it (type=ref outranks raw) and the pushed tags are unchanged. tags: | - type=raw,value=latest,enable={{is_default_branch}} type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}} + type=raw,value=latest,enable={{is_default_branch}} type=ref,event=tag type=sha,format=short diff --git a/.github/workflows/publish-vk.yml b/.github/workflows/publish-vk.yml index 1ca8fa1..c92a3d7 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -48,15 +48,21 @@ jobs: with: images: ghcr.io/duckbugio/flock-vk # Tags produced per build: - # :latest — rolling, default branch only (what deploys track) # :0.1. — ordered, human-readable auto-version for every main # build (no manual step); the number increments per # publish run so images sort and read sanely + # :latest — rolling, default branch only (what deploys track) # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback + # The order is load-bearing: metadata-action sorts tags by priority (both raw + # entries share the default 200, so the listing below decides) and stamps the + # FIRST one into org.opencontainers.image.version. With :latest leading, every + # image carried version="latest" — worthless for telling which build a host is + # running — so the immutable 0.1. goes first. A vX.Y.Z tag push + # still overrides it (type=ref outranks raw) and the pushed tags are unchanged. tags: | - type=raw,value=latest,enable={{is_default_branch}} type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}} + type=raw,value=latest,enable={{is_default_branch}} type=ref,event=tag type=sha,format=short From de3dab2f73c3a355b1e65fc6b2f5f8d9b6b5eb50 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:01:45 +0000 Subject: [PATCH 04/26] feat(deploy): add an opt-in image update to the playbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the implicit pull left no supported way to move a deployment forward: a plain run now reuses the image the host already has, so an operator who wants a newer build sees a green, "changed" playbook and stays on the old version, with `docker compose pull` on the host as the only remaining path. Give the update back as a deliberate action instead: bot_image_pull (default false) gates a `docker compose pull bot` that runs before the (re)start, so `-e bot_image_pull=true` fetches the tag and the following --force-recreate puts the new image into service. Everything else keeps the current behaviour. Only the bot service is fetched — the switch is about the bot image, and the pinned sidecars stay on what the host has. It is a variable, not an Ansible tag: a tag would be a second, partly overlapping switch, and selecting only the pull task with --tags would skip the (re)start that makes the new image take effect. changed_when is false because the fetch alone changes nothing on the host; the (re)start task already reports whether the deployment actually moved. --- .../roles/claude_tg_bot/defaults/main.yml | 15 ++++++++++- .../deploy/roles/claude_tg_bot/tasks/main.yml | 25 +++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index ffc9c8e..6c52a94 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -15,9 +15,22 @@ container_prefix: claude-tg legacy_volume_project: "" # Prebuilt image — pulled, not built (agents/CLAUDE.md are baked in by CI). -# Pin a tag (e.g. :v1) for reproducibility, or :latest to track main. +# The tag is resolved against the registry ONCE, on the first install; afterwards the +# host keeps the image it already has. So :latest does NOT track main by itself — an +# ordinary playbook run (token rotation, a limit change) never moves the version. +# Pin a tag (e.g. :v1) to make the version explicit in the inventory; to move a +# deployment forward, re-run with -e bot_image_pull=true (see below). bot_image: "ghcr.io/duckbugio/flock-telegram:latest" +# Update switch. false (default) = the run reuses the image the host already has; +# true = fetch what {{ bot_image }} currently points to first, so the (re)start below +# lands on the new build. Updating is therefore a deliberate operator action: +# ansible-playbook -i inventories//inventory.ini playbook.yml -e bot_image_pull=true +# It is a variable rather than an Ansible tag on purpose — a tag would be a second, +# partly overlapping switch, and selecting only the pull task with --tags would skip the +# (re)start that actually puts the new image into service. +bot_image_pull: false + # Node major version for the bundled `claude` CLI node_major: 22 diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml index 74425dd..16cb9f8 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml @@ -136,11 +136,26 @@ mode: "0600" no_log: true -# No --pull: moving to a new image is an explicit operator action (`docker compose pull`), -# not a side effect of running this playbook — a run made for a token rotation or a limit -# change must not also roll the version forward. --force-recreate stays: with the tag held -# still it only rebuilds the container (so a changed .env is picked up even on compose -# versions older than v2.14, which don't detect that themselves), never the image. +# The update path, off unless the operator asks for it with -e bot_image_pull=true. +# Only the bot service is fetched: this switch is about the bot image, and the pinned +# sidecars (dind, caddy) stay on whatever the host already has. The (re)start below then +# recreates the container on the fetched image. changed_when: false because the fetch +# alone changes nothing on the host — the (re)start task reports whether the deployment +# actually moved. +- name: Fetch the bot image (opt-in update) + ansible.builtin.command: + cmd: docker compose pull bot + chdir: "{{ app_dir }}" + when: bot_image_pull | bool + async: 1800 + poll: 15 + changed_when: false + +# No --pull here: moving to a new image is the explicit action above, not a side effect of +# running this playbook — a run made for a token rotation or a limit change must not also +# roll the version forward. --force-recreate stays: with the tag held still it only rebuilds +# the container (so a changed .env is picked up even on compose versions older than v2.14, +# which don't detect that themselves), never the image. - name: (Re)start the bot on its current image ansible.builtin.command: cmd: docker compose up -d --force-recreate From 377598b168fdff0f00dee56dd2ff3b751af6ab96 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:02:17 +0000 Subject: [PATCH 05/26] docs: document how an Ansible deploy is updated "The role pulls the prebuilt image" described the old behaviour, where every run re-resolved the tag. It now installs the image once and keeps it, so a reader following the README has no way to learn why a re-run leaves the bot on the old version, nor how to move it forward. State both facts in all eight translations: the version is stable across ordinary runs, and -e bot_image_pull=true is the update. Pinning bot_image is unchanged. --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.de.md b/README.de.md index 1db73a6..f7949b0 100644 --- a/README.de.md +++ b/README.de.md @@ -107,7 +107,7 @@ Der **Poller** ist die empfohlene Methode, um auf Review-Kommentare zu reagieren - **Sprachnachrichten:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (oder `OPENAI_API_KEY`). Werden transkribiert und als Befehle ausgeführt. - **dind-Sidecar:** `docker compose --profile dind up -d` stellt dem Team dockerisierte Linter/Tests bereit (setze `DOCKER_HOST=tcp://dind:2375`). - **Isolation pro Chat:** Jeder Chat erhält `/workspace/chat_` (1:1 → privat; Gruppe → ein gemeinsamer Workspace); Chats sind vollständig isoliert und laufen parallel, begrenzt durch `MAX_CONCURRENT_CHAT_RUNS`. Setze in Gruppen `REQUIRE_GROUP_MENTION=true`, damit der Bot nur antwortet, wenn er per @mention erwähnt wird oder auf ihn geantwortet wird. -- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle zieht das vorgefertigte Image; setze `bot_image`, um einen Tag festzuhalten. +- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf ändert die laufende Version nicht. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen; setze `bot_image`, um einen Tag festzuhalten. ## Sicherheit diff --git a/README.es.md b/README.es.md index 9dd354e..481fe8f 100644 --- a/README.es.md +++ b/README.es.md @@ -107,7 +107,7 @@ El **poller** es la forma recomendada de reaccionar a los comentarios de revisi - **Mensajes de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, más `MISTRAL_API_KEY` (o `OPENAI_API_KEY`). Se transcriben y se ejecutan como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` le da al equipo linters/pruebas dockerizados (establece `DOCKER_HOST=tcp://dind:2375`). - **Aislamiento por chat:** cada chat obtiene `/workspace/chat_` (1:1 → privado; grupo → un espacio de trabajo compartido); los chats están totalmente aislados y se ejecutan en paralelo, limitado por `MAX_CONCURRENT_CHAT_RUNS`. En los grupos, establece `REQUIRE_GROUP_MENTION=true` para responder solo cuando se te @menciona o se te responde. -- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada; establece `bot_image` para fijar una etiqueta. +- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no cambia la versión en marcha. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`; establece `bot_image` para fijar una etiqueta. ## Seguridad diff --git a/README.fr.md b/README.fr.md index d5da514..9d187a2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -107,7 +107,7 @@ Le **poller** est le moyen recommandé pour réagir aux commentaires de revue - **Messages vocaux :** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcrits et exécutés comme des commandes. - **Sidecar dind :** `docker compose --profile dind up -d` fournit à l'équipe des linters/tests dockerisés (définissez `DOCKER_HOST=tcp://dind:2375`). - **Isolation par conversation :** chaque conversation reçoit `/workspace/chat_` (1:1 → privé ; groupe → un espace de travail partagé) ; les conversations sont totalement isolées et s'exécutent en parallèle, plafonnées par `MAX_CONCURRENT_CHAT_RUNS`. Dans les groupes, définissez `REQUIRE_GROUP_MENTION=true` pour ne répondre que lorsque le bot est @mentionné ou cité en réponse. -- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite ; définissez `bot_image` pour épingler un tag. +- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne change pas la version en cours. Pour mettre à jour, relancez avec `-e bot_image_pull=true` ; définissez `bot_image` pour épingler un tag. ## Sécurité diff --git a/README.ja.md b/README.ja.md index bcfdeed..cd23cf7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **音声メッセージ:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`、加えて `MISTRAL_API_KEY`(または `OPENAI_API_KEY`)。文字起こしされてコマンドとして実行されます。 - **dind サイドカー:** `docker compose --profile dind up -d` で、Docker 化されたリンター/テストをチームに提供します(`DOCKER_HOST=tcp://dind:2375` を設定)。 - **チャットごとの隔離:** 各チャットには `/workspace/chat_` が割り当てられます(1:1 → プライベート、グループ → 1 つの共有ワークスペース)。チャットは完全に隔離され、`MAX_CONCURRENT_CHAT_RUNS` を上限として並列実行されます。グループでは `REQUIRE_GROUP_MENTION=true` を設定すると、@メンションまたは返信されたときのみ応答します。 -- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールはビルド済みイメージを取得します。タグを固定するには `bot_image` を設定してください。 +- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行では稼働中のバージョンは変わりません。更新するには `-e bot_image_pull=true` を付けて再実行してください。タグを固定するには `bot_image` を設定します。 ## セキュリティ diff --git a/README.md b/README.md index 63c1243..22d5dd6 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The **poller** is the recommended way to react to review comments — it reaches - **Voice messages:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (or `OPENAI_API_KEY`). Transcribed and run as commands. - **dind sidecar:** `docker compose --profile dind up -d` gives the team dockerized linters/tests (set `DOCKER_HOST=tcp://dind:2375`). Ansible deploys enable dind by default and inject `DOCKER_HOST` automatically. - **Per-chat isolation:** each chat gets `/workspace/chat_` (1:1 → private; group → one shared workspace); chats are fully isolated and run in parallel, capped by `MAX_CONCURRENT_CHAT_RUNS`. In groups, set `REQUIRE_GROUP_MENTION=true` to respond only when @mentioned or replied to. -- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role pulls the prebuilt image; set `bot_image` to pin a tag. +- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run never changes the running version. To update, re-run with `-e bot_image_pull=true`; set `bot_image` to pin a tag. ## Security diff --git a/README.pt-BR.md b/README.pt-BR.md index 371ab19..6a7b8fb 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -107,7 +107,7 @@ O **poller** é a forma recomendada de reagir a comentários de revisão — ele - **Mensagens de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, mais `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcritas e executadas como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` dá ao time linters/testes dockerizados (defina `DOCKER_HOST=tcp://dind:2375`). - **Isolamento por chat:** cada chat recebe `/workspace/chat_` (1:1 → privado; grupo → um workspace compartilhado); os chats são totalmente isolados e rodam em paralelo, limitados por `MAX_CONCURRENT_CHAT_RUNS`. Em grupos, defina `REQUIRE_GROUP_MENTION=true` para responder apenas quando for @mencionado ou respondido. -- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída; defina `bot_image` para fixar uma tag. +- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não muda a versão em execução. Para atualizar, execute novamente com `-e bot_image_pull=true`; defina `bot_image` para fixar uma tag. ## Segurança diff --git a/README.ru.md b/README.ru.md index 57065ea..daa1314 100644 --- a/README.ru.md +++ b/README.ru.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **Голосовые сообщения:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, плюс `MISTRAL_API_KEY` (или `OPENAI_API_KEY`). Транскрибируются и выполняются как команды. - **Сайдкар dind:** `docker compose --profile dind up -d` даёт команде линтеры/тесты в Docker (задайте `DOCKER_HOST=tcp://dind:2375`). - **Изоляция по чатам:** каждый чат получает `/workspace/chat_` (1:1 → приватное; группа → одно общее рабочее пространство); чаты полностью изолированы и выполняются параллельно с ограничением `MAX_CONCURRENT_CHAT_RUNS`. В группах задайте `REQUIRE_GROUP_MENTION=true`, чтобы отвечать только при @упоминании или в ответ на сообщение. -- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль подтягивает готовый образ; задайте `bot_image`, чтобы зафиксировать тег. +- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не меняет запущенную версию. Чтобы обновиться, запустите с `-e bot_image_pull=true`; задайте `bot_image`, чтобы зафиксировать тег. ## Безопасность diff --git a/README.zh.md b/README.zh.md index ab504c7..baf6fba 100644 --- a/README.zh.md +++ b/README.zh.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **语音消息:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`,外加 `MISTRAL_API_KEY`(或 `OPENAI_API_KEY`)。语音会被转写并作为命令运行。 - **dind 边车容器:** `docker compose --profile dind up -d` 为团队提供容器化的 linters/测试(设置 `DOCKER_HOST=tcp://dind:2375`)。 - **按聊天隔离:** 每个聊天都会获得一个 `/workspace/chat_`(一对一 → 私有;群组 → 一个共享工作区);不同聊天彼此完全隔离并行运行,受 `MAX_CONCURRENT_CHAT_RUNS` 限制。在群组中,设置 `REQUIRE_GROUP_MENTION=true` 可让机器人仅在被 @提及或被回复时才响应。 -- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色会拉取预构建镜像;设置 `bot_image` 以固定某个标签。 +- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色只在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会改变正在运行的版本。要更新,请附带 `-e bot_image_pull=true` 重新运行;设置 `bot_image` 以固定某个标签。 ## 安全 From 82e334c063369fea4df60bd9ca5e86ed5b5eb49e Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:02:46 +0000 Subject: [PATCH 06/26] docs(deploy): correct three inaccurate role comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments in the role state things that are not true, and each one would mislead an operator deciding whether their rollback path exists. - The prune units claim a "recently pulled image stays available for rollback". `docker image prune --filter until=` compares the image's CREATION time, not when the host pulled it (docker docs: "only remove images created before given timestamp"), so an image CI built eight days ago is prunable the moment nothing runs it. Say so, and name the consequence: rolling back after an update usually means pulling that tag again. Images in use by a container are still exempt. - The compose template claims `docker compose up` "cannot" move the deployment to a newer build. It only means compose does not reach the registry itself: if the local tag was already moved, --force-recreate does recreate the container on the newer image — which is exactly how the opt-in update works. - The restart handler pointed at `docker compose up -d --build`, a command the role has never run, under a task name that no longer exists. --- .../deploy/roles/claude_tg_bot/defaults/main.yml | 9 ++++++--- .../deploy/roles/claude_tg_bot/handlers/main.yml | 5 +++-- .../claude_tg_bot/templates/docker-compose.yml.j2 | 11 +++++++---- .../claude_tg_bot/templates/docker-prune.service.j2 | 7 +++++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index 6c52a94..cf329da 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -128,9 +128,12 @@ gitea_poll_interval: 90 enable_dind: true dind_image: "docker:27-dind" -# Weekly reclaim of unused Docker images + build cache via a systemd timer. Only prunes -# things OLDER than docker_prune_keep_hours, so a recently-pulled image stays for rollback; -# images in use by a running container are never touched. Set enable_docker_prune: false to skip. +# Weekly reclaim of unused Docker images + build cache via a systemd timer. The age it +# filters on is the image's CREATION time (when CI built it), not when this host pulled it, +# so a just-pulled image is usually already past docker_prune_keep_hours and is reclaimed +# once nothing runs it — after an update, rolling back to the previous image normally means +# pulling that tag again. Images in use by a running container are never touched, so the +# version in service always survives. Set enable_docker_prune: false to skip. enable_docker_prune: true docker_prune_schedule: "weekly" # systemd OnCalendar (weekly | daily | "Sun *-*-* 04:00:00") docker_prune_keep_hours: 168 # keep anything newer than this many hours (168 = 7 days) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml index 78e82e9..e3e2237 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml @@ -1,6 +1,7 @@ --- -# Convenience handler (e.g. for `--tags restart`). The main converge task -# already runs `docker compose up -d --build`, which is idempotent. +# Convenience handler (e.g. for `--tags restart`). The converge task +# "(Re)start the bot on its current image" already runs +# `docker compose up -d --force-recreate`, which is idempotent. - name: Restart claude-tg-bot ansible.builtin.command: cmd: docker compose restart diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 index cc0fc8e..1f33584 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 @@ -2,10 +2,13 @@ services: bot: image: {{ bot_image }} - # No pull policy override on purpose: compose keeps the image it already has, so a - # `docker compose up` (playbook run, host reboot recovery) cannot move the deployment - # to a newer build behind the operator's back — even though bot_image tracks a moving - # tag. Updating is a deliberate `docker compose pull` followed by `up -d`. + # No pull policy override on purpose: compose reuses the image this host already has + # for the tag, so a `docker compose up` (playbook run, host reboot recovery) does not + # go to the registry on its own — even though bot_image tracks a moving tag. It does + # not freeze the version: if the local tag has since been moved (by the role's opt-in + # bot_image_pull task, or a `docker compose pull` on the host), the next `up -d + # --force-recreate` recreates the container on that newer image. Updating is thus a + # deliberate pull followed by `up -d`, never a side effect of the `up` alone. container_name: {{ container_prefix }}-bot restart: unless-stopped # Graceful drain on deploy: give the bot up to 75s after SIGTERM to drain diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 index adcfbd1..09127fc 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 @@ -1,6 +1,9 @@ # Managed by Ansible — weekly reclaim of unused Docker images + build cache. -# Only removes images/cache OLDER than {{ docker_prune_keep_hours }}h, so a recently -# pulled image stays available for rollback. Images in use by a container are never touched. +# `until=` compares the image's CREATION time (when CI built it), not when this host +# pulled it, so a freshly pulled image can already be older than {{ docker_prune_keep_hours }}h +# and is reclaimed as soon as nothing runs it: after an update the image you rolled off +# is normally gone by the next run, and rolling back means pulling that tag again. +# Images in use by a container are never touched, so the running version always survives. [Unit] Description=Prune unused Docker images and build cache From 63fd0de3867350f2693a671e7f59b545d5151b77 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:02:56 +0000 Subject: [PATCH 07/26] docs(ci): stop claiming deploys track :latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ansible role no longer re-resolves the tag on every run — it installs the image once and only fetches a newer one when the operator asks. ":latest — what deploys track" now describes behaviour that this branch removed, in the one place a reader looks to decide which tag to deploy. Describe what actually happens instead: the tag is resolved at install time and moves only on an explicit pull. Both publish workflows kept mirrored. --- .github/workflows/publish-telegram.yml | 3 ++- .github/workflows/publish-vk.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-telegram.yml b/.github/workflows/publish-telegram.yml index 82998ec..4adc0f2 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -51,7 +51,8 @@ jobs: # :0.1. — ordered, human-readable auto-version for every main # build (no manual step); the number increments per # publish run so images sort and read sanely - # :latest — rolling, default branch only (what deploys track) + # :latest — rolling, default branch only; a deploy resolves it + # once at install and moves only on an explicit pull # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback # The order is load-bearing: metadata-action sorts tags by priority (both raw diff --git a/.github/workflows/publish-vk.yml b/.github/workflows/publish-vk.yml index c92a3d7..10e7e41 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -51,7 +51,8 @@ jobs: # :0.1. — ordered, human-readable auto-version for every main # build (no manual step); the number increments per # publish run so images sort and read sanely - # :latest — rolling, default branch only (what deploys track) + # :latest — rolling, default branch only; a deploy resolves it + # once at install and moves only on an explicit pull # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback # The order is load-bearing: metadata-action sorts tags by priority (both raw From 4a1fe3665549fca5d470ce743f158562908e539a Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:03:22 +0000 Subject: [PATCH 08/26] ci: set the version-label tag priority explicitly The image version label was decided by the order of two lines in the tags list: both raw entries sat at the default priority 200, so metadata-action's sort left whichever was listed first. Nothing pins that. The action is tracked at the moving @v6 and gets bumped weekly, so any reformatting of this block would put :latest back on top and quietly restore version="latest", with no check to catch it. Give the immutable 0.1. priority=250 so it outranks :latest on its own (raw defaults to 200; type=ref stays at 600, so a vX.Y.Z tag push still wins) and shorten the comment that explained the ordering trick. Tag output is unchanged. --- .github/workflows/publish-telegram.yml | 15 ++++++++------- .github/workflows/publish-vk.yml | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-telegram.yml b/.github/workflows/publish-telegram.yml index 4adc0f2..349a911 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -55,14 +55,15 @@ jobs: # once at install and moves only on an explicit pull # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback - # The order is load-bearing: metadata-action sorts tags by priority (both raw - # entries share the default 200, so the listing below decides) and stamps the - # FIRST one into org.opencontainers.image.version. With :latest leading, every - # image carried version="latest" — worthless for telling which build a host is - # running — so the immutable 0.1. goes first. A vX.Y.Z tag push - # still overrides it (type=ref outranks raw) and the pushed tags are unchanged. + # metadata-action stamps the highest-priority tag into + # org.opencontainers.image.version. priority=250 lifts the immutable + # 0.1. above :latest (type=raw defaults to 200) so that label names + # the exact build instead of a worthless "latest". Stated explicitly rather than + # left to the listing order, which only breaks ties and would silently regress on + # a reshuffle. A vX.Y.Z tag push still wins (type=ref defaults to 600) and the + # pushed tag set is unchanged. tags: | - type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}} + type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}},priority=250 type=raw,value=latest,enable={{is_default_branch}} type=ref,event=tag type=sha,format=short diff --git a/.github/workflows/publish-vk.yml b/.github/workflows/publish-vk.yml index 10e7e41..d0fa8de 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -55,14 +55,15 @@ jobs: # once at install and moves only on an explicit pull # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback - # The order is load-bearing: metadata-action sorts tags by priority (both raw - # entries share the default 200, so the listing below decides) and stamps the - # FIRST one into org.opencontainers.image.version. With :latest leading, every - # image carried version="latest" — worthless for telling which build a host is - # running — so the immutable 0.1. goes first. A vX.Y.Z tag push - # still overrides it (type=ref outranks raw) and the pushed tags are unchanged. + # metadata-action stamps the highest-priority tag into + # org.opencontainers.image.version. priority=250 lifts the immutable + # 0.1. above :latest (type=raw defaults to 200) so that label names + # the exact build instead of a worthless "latest". Stated explicitly rather than + # left to the listing order, which only breaks ties and would silently regress on + # a reshuffle. A vX.Y.Z tag push still wins (type=ref defaults to 600) and the + # pushed tag set is unchanged. tags: | - type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}} + type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}},priority=250 type=raw,value=latest,enable={{is_default_branch}} type=ref,event=tag type=sha,format=short From 5f35e56e1640d738a590b036ccc7aa05f2b34db4 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:22:51 +0000 Subject: [PATCH 09/26] test(deploy): widen the implicit-pull guard to every spelling and file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard promised more than it checked: it only knew `--pull always` in one spelling and `pull_policy: always`, over three files. A plain `docker compose pull`, `docker pull`, the community.docker.docker_image modules or the argv form ["docker", "compose", "up", "-d", "--pull", "always"] all walked past it, as did anything added to deploy/playbook.yml or the role's handlers. State the invariant precisely instead: an UNCONDITIONAL pull is forbidden, while a pull gated by its own task-level `when:` is the opt-in update path and passes. Compose files have no such gate, so any directive there still fails. The gate must sit on the pull task itself — a condition inherited from an enclosing `block:` does not count, since a reader of the task cannot see it. Two supporting checks: a table pins the spellings the patterns must match (and the legitimate lines they must not), so a narrowed pattern fails loudly rather than silently; and bot_image_pull is asserted to default to false, because a default of true turns that `when:` into an unconditional pull wearing a gate. Reading a manifest now reports and continues instead of aborting the whole test, so one unreadable file cannot hide a pull in the others. --- adapters/telegram/deploy_pull_test.go | 173 ++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 26 deletions(-) diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go index 462c41c..178d632 100644 --- a/adapters/telegram/deploy_pull_test.go +++ b/adapters/telegram/deploy_pull_test.go @@ -14,43 +14,164 @@ import ( "testing" ) -// Files that decide whether a playbook run may replace the running image. -var deployManifests = []string{ - "docker-compose.yml", - "deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2", - "deploy/roles/claude_tg_bot/tasks/main.yml", -} - -// Directives that make `docker compose up` re-resolve a moving tag (":latest") against -// the registry, so an unrelated playbook run silently ships whatever landed on main -// since the last deploy. Both spellings are matched loosely (quoting, "--pull=always") -// because they are the same instruction re-spelled. -var implicitPullDirectives = []*regexp.Regexp{ - regexp.MustCompile(`--pull[ =]+["']?always`), +// deployManifest is a shipped file that can make a deploy fetch an image. +type deployManifest struct { + path string + // ansible marks a file whose tasks can carry a `when:`. A pull gated by one is the + // opt-in update path (bot_image_pull) and is allowed. A compose file has no such + // gate — a directive there fires on every `up`, so none is allowed. + ansible bool +} + +// Every file that decides whether a deploy may replace the running image. +var deployManifests = []deployManifest{ + {path: "docker-compose.yml"}, + {path: "deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2"}, + {path: "deploy/playbook.yml", ansible: true}, + {path: "deploy/roles/claude_tg_bot/tasks/main.yml", ansible: true}, + {path: "deploy/roles/claude_tg_bot/handlers/main.yml", ansible: true}, +} + +// The spellings that fetch an image during a deploy: compose's pull flag and pull policy +// (both re-resolve a moving tag on every `up`), an outright `docker [compose] pull`, and +// the Ansible modules that pull one. Matched loosely on purpose — quoting, `--pull=always` +// and the argv form ["docker", "compose", "up", "-d", "--pull", "always"] are the same +// instruction re-spelled. Matching is per line, which is how every one of these is written +// in practice; a directive split across several YAML lines is out of reach. +var pullDirectives = []*regexp.Regexp{ + regexp.MustCompile(`--pull["']?[\s,=]*["']?always`), regexp.MustCompile(`pull_policy:\s*["']?always`), + regexp.MustCompile(`docker(?:[\s,"']+compose|-compose)?[\s,"']+pull\b`), + regexp.MustCompile(`\b(?:community\.docker\.)?docker_image(?:_pull)?\s*:`), } -// TestDeployNeverPullsImplicitly asserts no shipped compose file or Ansible task re-pulls -// the image on its own. Updating the bot is the operator's decision (an explicit -// `docker compose pull`), never a side effect of a playbook run made for something else -// — a token rotation or a limit change must not also roll the version forward. +var ( + yamlListItem = regexp.MustCompile(`^(\s*)-\s`) + yamlWhenKey = regexp.MustCompile(`^\s*when:`) +) + +// TestDeployNeverPullsImplicitly asserts that no shipped compose file and no Ansible task +// fetches an image on its own. Updating the bot is the operator's decision — a run with +// `-e bot_image_pull=true`, or an explicit pull on the host — never a side effect of a +// playbook run made for something else, like a token rotation or a limit change. An +// UNCONDITIONAL pull fails; a pull that its own task's `when:` gates is that opt-in and +// passes, so the guard catches the return of the implicit behaviour, not the update path. func TestDeployNeverPullsImplicitly(t *testing.T) { - for _, name := range deployManifests { - raw, err := os.ReadFile(name) //nolint:gosec // G304: fixed repo-relative path, no input + for _, m := range deployManifests { + raw, err := os.ReadFile(m.path) if err != nil { - t.Fatalf("read %s: %v", name, err) + // Keep going: one unreadable manifest must not hide a pull in the others. + t.Errorf("read %s: %v", m.path, err) + continue } - for i, line := range strings.Split(string(raw), "\n") { - // Comments may name the directive to explain why it is absent. + lines := strings.Split(string(raw), "\n") + for i, line := range lines { + // Comments may name a directive to explain why it is absent. if strings.HasPrefix(strings.TrimSpace(line), "#") { continue } - for _, re := range implicitPullDirectives { - if re.MatchString(line) { - t.Errorf("%s:%d matches %q: %s\ndeploys must not pull implicitly — "+ - "moving to a new image is an explicit operator action", name, i+1, re, strings.TrimSpace(line)) + for _, re := range pullDirectives { + if !re.MatchString(line) { + continue } + if m.ansible && taskHasWhen(lines, i) { + continue // the opt-in update path + } + t.Errorf("%s:%d matches %q: %s\ndeploys must not pull implicitly — moving to a new "+ + "image is an explicit operator action, so a pull has to sit in a task gated by "+ + "its own `when:` (see bot_image_pull)", m.path, i+1, re, strings.TrimSpace(line)) } } } } + +// taskHasWhen reports whether the Ansible task holding line i carries its own `when:`. The +// task spans from the list item opening it to the next list item at the same or shallower +// indentation. Deliberately strict: a condition inherited from an enclosing `block:` does +// not count, because someone reading the task cannot see that it is gated. +func taskHasWhen(lines []string, i int) bool { + start := i + for start >= 0 && !yamlListItem.MatchString(lines[start]) { + start-- + } + if start < 0 { + return false + } + indent := len(yamlListItem.FindStringSubmatch(lines[start])[1]) + for j := start + 1; j < len(lines); j++ { + if m := yamlListItem.FindStringSubmatch(lines[j]); m != nil && len(m[1]) <= indent { + return false // next task started + } + if yamlWhenKey.MatchString(lines[j]) { + return true + } + } + return false +} + +// TestPullDirectivesMatchKnownSpellings pins what the guard above promises to catch, so a +// pattern narrowed by a later edit fails here instead of silently letting a pull back in. +func TestPullDirectivesMatchKnownSpellings(t *testing.T) { + pulls := []string{ + ` cmd: docker compose up -d --pull always --force-recreate`, + ` cmd: docker compose up -d --pull=always`, + ` argv: ["docker", "compose", "up", "-d", "--pull", "always"]`, + ` pull_policy: always`, + ` pull_policy: "always"`, + ` cmd: docker compose pull`, + ` cmd: docker-compose pull bot`, + ` cmd: docker pull ghcr.io/duckbugio/flock-telegram:latest`, + ` argv: ["docker", "compose", "pull"]`, + ` community.docker.docker_image:`, + ` community.docker.docker_image_pull:`, + ` docker_image:`, + } + for _, line := range pulls { + if !matchesPullDirective(line) { + t.Errorf("no pattern matches a pull: %s", line) + } + } + + // Lines the role legitimately ships; a pattern that matches these would make the + // guard fire on unrelated edits and get deleted. + clean := []string{ + ` cmd: docker compose up -d --force-recreate`, + ` cmd: docker compose restart`, + ` image: {{ bot_image }}`, + `bot_image_pull: false`, + ` pull_policy: missing`, + } + for _, line := range clean { + if matchesPullDirective(line) { + t.Errorf("pattern fires on a line that pulls nothing: %s", line) + } + } +} + +func matchesPullDirective(line string) bool { + for _, re := range pullDirectives { + if re.MatchString(line) { + return true + } + } + return false +} + +// TestBotImagePullIsOptIn asserts the knob the pull task is gated on defaults to false. +// A default of true would make that `when:` always fire, which is an unconditional pull +// wearing a gate — exactly the behaviour the guard above exists to keep out. +func TestBotImagePullIsOptIn(t *testing.T) { + const path = "deploy/roles/claude_tg_bot/defaults/main.yml" + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + m := regexp.MustCompile(`(?m)^bot_image_pull:\s*(\S+)`).FindStringSubmatch(string(raw)) + if m == nil { + t.Fatalf("%s: no bot_image_pull default — the pull task's `when:` gate must have one", path) + } + if got := strings.Trim(m[1], `"'`); got != "false" { + t.Errorf("%s: bot_image_pull defaults to %q, want \"false\" — updating must stay opt-in, "+ + "otherwise every playbook run pulls again", path, got) + } +} From 924237a2e9ccd87a00da1b4ce07a5cad42864def Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 20:22:51 +0000 Subject: [PATCH 10/26] ci: keep Go test files out of the publish trigger and the build context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adapters/telegram/** is a publish trigger and the Dockerfile copies adapters/ into the build context, so editing a test that only guards the deploy — deploy_pull_test.go sits there because `go test` needs it next to the files it reads — kicks off the full multi-arch publish and invalidates the COPY layer, rebuilding a byte-identical image. `go build` ignores _test.go, so excluding them costs nothing: .dockerignore drops them from the context and both workflows negate them in paths (negations last, since the final matching pattern wins). Verified by building the compile stage of both images with the exclusion in place — green, and /src holds 72 .go files and no test. --- .dockerignore | 4 ++++ .github/workflows/publish-telegram.yml | 7 +++++-- .github/workflows/publish-vk.yml | 7 +++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 12063a5..747680f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,3 +16,7 @@ adapters/telegram/.env .gocache .cache flock-telegram + +# Go tests: `go build` ignores them, so they only add churn — a test-only edit would +# otherwise invalidate the COPY layers and force a full rebuild of an identical image. +**/*_test.go diff --git a/.github/workflows/publish-telegram.yml b/.github/workflows/publish-telegram.yml index 349a911..ba6dbbc 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -8,8 +8,10 @@ on: tags: ["v*"] # Only rebuild when something that actually lands in the image changes: the Go # sources (cmd/, internal/, core/, go.mod/go.sum) or the adapter build - # (Dockerfile/.dockerignore). Docs and the Ansible deploy dir don't affect the - # image, so they don't trigger a build. + # (Dockerfile/.dockerignore). Docs, the Ansible deploy dir and Go test files + # don't affect the image (.dockerignore keeps _test.go out of the build context), + # so they don't trigger a build. Negations come last: the final matching pattern + # wins. paths: - 'cmd/**' - 'internal/**' @@ -18,6 +20,7 @@ on: - 'go.sum' - 'adapters/telegram/**' - '!adapters/telegram/deploy/**' + - '!**/*_test.go' - '.dockerignore' - '.github/workflows/publish-telegram.yml' workflow_dispatch: diff --git a/.github/workflows/publish-vk.yml b/.github/workflows/publish-vk.yml index d0fa8de..b77104b 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -8,8 +8,10 @@ on: tags: ["v*"] # Only rebuild when something that actually lands in the image changes: the Go # sources (cmd/, internal/, core/, go.mod/go.sum) or the adapter build - # (Dockerfile/.dockerignore). Docs and the Ansible deploy dir don't affect the - # image, so they don't trigger a build. + # (Dockerfile/.dockerignore). Docs, the Ansible deploy dir and Go test files + # don't affect the image (.dockerignore keeps _test.go out of the build context), + # so they don't trigger a build. Negations come last: the final matching pattern + # wins. paths: - 'cmd/**' - 'internal/**' @@ -18,6 +20,7 @@ on: - 'go.sum' - 'adapters/vk/**' - '!adapters/vk/deploy/**' + - '!**/*_test.go' - '.dockerignore' - '.github/workflows/publish-vk.yml' workflow_dispatch: From 1316bb562e237ad470be88bedc482a4fd9cbd5f7 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:03:57 +0000 Subject: [PATCH 11/26] test(deploy): gate the pull guard on the opt-in knob, not on any when: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard accepted any task carrying a `when:`, and found it by scanning forward from the enclosing list item without regard for indentation. Two consequences: - The verdict depended on YAML key order. A pull inside a `block:` passed when the block's `when:` was written after the block and failed when it was written before it, although a mapping's key order carries no meaning — red CI on correct code, green CI on the same playbook re-spelled. - `when: true` passed. The guard asserted that a gate exists, not that it is the opt-in one, so an unconditional pull wearing a gate went through. A pull is now accepted only when the task that holds it carries its own `when:` (a key at that task's own indentation) naming bot_image_pull. Both block spellings are rejected identically: the gate has to be visible on the task that pulls. Also widened and tightened the matching around it: trailing comments are stripped before matching (a comment naming a directive no longer fails the build, and a directive no longer hides behind one), the compose module's own `pull: always` and the docker_compose(_v2) modules are recognised, and the opt-in default accepts every spelling Ansible reads as false (no/False/off/0). The docblock states what the guard does NOT check: it compares the gate's name, not its meaning, so `when: bot_image_pull | bool or true` would still pass. --- adapters/telegram/deploy_pull_test.go | 370 +++++++++++++++++++++++--- 1 file changed, 331 insertions(+), 39 deletions(-) diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go index 178d632..3fdfc51 100644 --- a/adapters/telegram/deploy_pull_test.go +++ b/adapters/telegram/deploy_pull_test.go @@ -17,9 +17,9 @@ import ( // deployManifest is a shipped file that can make a deploy fetch an image. type deployManifest struct { path string - // ansible marks a file whose tasks can carry a `when:`. A pull gated by one is the - // opt-in update path (bot_image_pull) and is allowed. A compose file has no such - // gate — a directive there fires on every `up`, so none is allowed. + // ansible marks a file whose tasks can carry a `when:`. A pull whose OWN task is + // gated on bot_image_pull is the opt-in update path and is allowed. A compose file + // has no such gate — a directive there fires on every `up`, so none is allowed. ansible bool } @@ -41,21 +41,42 @@ var deployManifests = []deployManifest{ var pullDirectives = []*regexp.Regexp{ regexp.MustCompile(`--pull["']?[\s,=]*["']?always`), regexp.MustCompile(`pull_policy:\s*["']?always`), + // The compose module's own fetch option. `\b` keeps this off `bot_image_pull:`, where + // the preceding `_` is a word character and so leaves no boundary. + regexp.MustCompile(`\bpull:\s*["']?always`), regexp.MustCompile(`docker(?:[\s,"']+compose|-compose)?[\s,"']+pull\b`), regexp.MustCompile(`\b(?:community\.docker\.)?docker_image(?:_pull)?\s*:`), + // The compose modules themselves: whether they fetch depends on their `pull:` option + // and on the compose file's own policy, so a switch to one has to be reviewed here + // (and gated, or this guard widened) rather than slipped in. + regexp.MustCompile(`\b(?:community\.docker\.)?docker_compose(?:_v2)?\s*:`), } var ( - yamlListItem = regexp.MustCompile(`^(\s*)-\s`) - yamlWhenKey = regexp.MustCompile(`^\s*when:`) + yamlListItem = regexp.MustCompile(`^(\s*)-(?:\s|$)`) + yamlKey = regexp.MustCompile(`^\s*([\w.-]+):(?:\s|$)`) + // The opt-in knob a pull's own condition has to name (see defaults/main.yml). + gateVariable = regexp.MustCompile(`\bbot_image_pull\b`) ) +// pullFinding is one line that fetches an image without the opt-in gate. +type pullFinding struct { + line int // 0-based index into the document's lines + re *regexp.Regexp + text string +} + // TestDeployNeverPullsImplicitly asserts that no shipped compose file and no Ansible task -// fetches an image on its own. Updating the bot is the operator's decision — a run with +// fetches an image on its own. Updating is the operator's decision — a run with // `-e bot_image_pull=true`, or an explicit pull on the host — never a side effect of a -// playbook run made for something else, like a token rotation or a limit change. An -// UNCONDITIONAL pull fails; a pull that its own task's `when:` gates is that opt-in and -// passes, so the guard catches the return of the implicit behaviour, not the update path. +// playbook run made for something else, like a token rotation or a limit change. +// +// What it checks, exactly: a pull is accepted only when it sits in an Ansible task whose +// OWN `when:` (a key at the task's own indentation, not one inherited from an enclosing +// `block:`) names bot_image_pull, whose default TestBotImagePullIsOptIn pins to false. +// What it does NOT check: that the condition is semantically equivalent to that knob — +// `when: bot_image_pull | bool or true` names it and would pass. It catches an ungated +// pull, and a pull gated on the wrong variable or on a constant; it is not an evaluator. func TestDeployNeverPullsImplicitly(t *testing.T) { for _, m := range deployManifests { raw, err := os.ReadFile(m.path) @@ -64,49 +85,148 @@ func TestDeployNeverPullsImplicitly(t *testing.T) { t.Errorf("read %s: %v", m.path, err) continue } - lines := strings.Split(string(raw), "\n") - for i, line := range lines { - // Comments may name a directive to explain why it is absent. - if strings.HasPrefix(strings.TrimSpace(line), "#") { + for _, f := range findUngatedPulls(string(raw), m.ansible) { + t.Errorf("%s:%d matches %q: %s\ndeploys must not pull implicitly — moving to a new "+ + "image is an explicit operator action, so a pull has to sit in a task whose own "+ + "`when:` gates it on bot_image_pull", m.path, f.line+1, f.re, f.text) + } + } +} + +// findUngatedPulls returns every line of a deploy manifest that fetches an image without +// the opt-in gate. ansible enables the gate: in a compose file nothing can gate a pull. +func findUngatedPulls(content string, ansible bool) []pullFinding { + lines := strings.Split(content, "\n") + var found []pullFinding + for i, raw := range lines { + // A comment may name a directive to explain why it is absent — whole-line or + // trailing — so drop comments before matching instead of only skipping lines + // that are entirely one. + line := stripComment(raw) + if strings.TrimSpace(line) == "" { + continue + } + for _, re := range pullDirectives { + if !re.MatchString(line) { continue } - for _, re := range pullDirectives { - if !re.MatchString(line) { - continue - } - if m.ansible && taskHasWhen(lines, i) { - continue // the opt-in update path - } - t.Errorf("%s:%d matches %q: %s\ndeploys must not pull implicitly — moving to a new "+ - "image is an explicit operator action, so a pull has to sit in a task gated by "+ - "its own `when:` (see bot_image_pull)", m.path, i+1, re, strings.TrimSpace(line)) + if ansible && pullIsGated(lines, i) { + continue // the opt-in update path } + found = append(found, pullFinding{line: i, re: re, text: strings.TrimSpace(line)}) } } + return found +} + +// pullIsGated reports whether the Ansible task holding line i is gated on the opt-in knob. +func pullIsGated(lines []string, i int) bool { + cond, ok := taskOwnCondition(lines, i) + return ok && gateVariable.MatchString(cond) } -// taskHasWhen reports whether the Ansible task holding line i carries its own `when:`. The -// task spans from the list item opening it to the next list item at the same or shallower -// indentation. Deliberately strict: a condition inherited from an enclosing `block:` does -// not count, because someone reading the task cannot see that it is gated. -func taskHasWhen(lines []string, i int) bool { +// taskOwnCondition returns the `when:` expression of the task that DIRECTLY contains line +// i. "Directly" is what makes the answer independent of key order: the task is the nearest +// list item at or above line i, and only keys at that item's own indentation are its own — +// so a `when:` on an enclosing `block:` is not picked up whether it is written before or +// after the block, and the module arguments (or a nested block's tasks) below the key line +// cannot smuggle one in. A condition inherited from a block deliberately does not count: +// someone reading the task cannot see that it is gated, and the gate is the invariant. +func taskOwnCondition(lines []string, i int) (string, bool) { start := i for start >= 0 && !yamlListItem.MatchString(lines[start]) { start-- } if start < 0 { - return false + return "", false + } + dash := len(yamlListItem.FindStringSubmatch(lines[start])[1]) + keyIndent, ok := taskKeyIndent(lines, start, dash) + if !ok { + return "", false } - indent := len(yamlListItem.FindStringSubmatch(lines[start])[1]) + + var cond []string + inWhen := false + for j := start; j < len(lines); j++ { + line := stripComment(lines[j]) + if j == start { + // Blank out the "-" so the item's first key is read like any other key line. + line = strings.Repeat(" ", dash+1) + line[dash+1:] + } + if strings.TrimSpace(line) == "" { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " \t")) + if m := yamlListItem.FindStringSubmatch(line); m != nil && len(m[1]) <= dash { + break // a sibling task (or a shallower list) starts + } + if indent < keyIndent { + break // back out to the enclosing mapping: this task ended + } + if indent > keyIndent { + if inWhen { + cond = append(cond, strings.TrimSpace(line)) // a block scalar or a list value + } + continue // module arguments, or a nested block's tasks + } + key := yamlKey.FindStringSubmatch(line) + if key == nil { + if inWhen { + cond = append(cond, strings.TrimSpace(line)) // a list value at key indentation + } + continue + } + if key[1] == "when" { + inWhen = true + _, value, _ := strings.Cut(line, ":") + cond = append(cond, strings.TrimSpace(value)) + continue + } + inWhen = false + } + if len(cond) == 0 { + return "", false + } + return strings.Join(cond, " "), true +} + +// taskKeyIndent returns the column at which the keys of the list item on line start begin. +func taskKeyIndent(lines []string, start, dash int) (int, bool) { + rest := lines[start][dash+1:] + if trimmed := strings.TrimLeft(rest, " \t"); trimmed != "" { + return dash + 1 + len(rest) - len(trimmed), true + } + // A bare "-": the item's keys start on the next non-blank line. for j := start + 1; j < len(lines); j++ { - if m := yamlListItem.FindStringSubmatch(lines[j]); m != nil && len(m[1]) <= indent { - return false // next task started + if strings.TrimSpace(lines[j]) == "" { + continue } - if yamlWhenKey.MatchString(lines[j]) { - return true + indent := len(lines[j]) - len(strings.TrimLeft(lines[j], " \t")) + return indent, indent > dash + } + return 0, false +} + +// stripComment removes a YAML end-of-line comment: a "#" that starts the line or follows +// whitespace, and is not inside a quoted scalar. Quote tracking keeps a "#" that is part +// of a value (e.g. a colour or a fragment URL in quotes) from truncating a real directive. +func stripComment(line string) string { + var quote byte + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case quote != 0: + if c == quote { + quote = 0 + } + case c == '\'' || c == '"': + quote = c + case c == '#' && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t'): + return line[:i] } } - return false + return line } // TestPullDirectivesMatchKnownSpellings pins what the guard above promises to catch, so a @@ -118,6 +238,8 @@ func TestPullDirectivesMatchKnownSpellings(t *testing.T) { ` argv: ["docker", "compose", "up", "-d", "--pull", "always"]`, ` pull_policy: always`, ` pull_policy: "always"`, + ` pull: always`, + ` pull: "always"`, ` cmd: docker compose pull`, ` cmd: docker-compose pull bot`, ` cmd: docker pull ghcr.io/duckbugio/flock-telegram:latest`, @@ -125,6 +247,8 @@ func TestPullDirectivesMatchKnownSpellings(t *testing.T) { ` community.docker.docker_image:`, ` community.docker.docker_image_pull:`, ` docker_image:`, + ` community.docker.docker_compose_v2:`, + ` docker_compose:`, } for _, line := range pulls { if !matchesPullDirective(line) { @@ -139,7 +263,9 @@ func TestPullDirectivesMatchKnownSpellings(t *testing.T) { ` cmd: docker compose restart`, ` image: {{ bot_image }}`, `bot_image_pull: false`, + ` when: bot_image_pull | bool`, ` pull_policy: missing`, + ` pull: never`, } for _, line := range clean { if matchesPullDirective(line) { @@ -157,9 +283,172 @@ func matchesPullDirective(line string) bool { return false } +// TestPullGateAcceptsOnlyTheOptInTask pins the gate itself: which spellings of a gated +// pull the guard accepts, and — the part that carries the invariant — which near-misses +// it still reports. Every case holds exactly one `docker compose pull`, so "no finding" +// means "accepted as the opt-in path". +func TestPullGateAcceptsOnlyTheOptInTask(t *testing.T) { + cases := []struct { + name string + doc string + accepted bool + }{ + { + name: "the role's own opt-in task", + doc: `--- +- name: Fetch the images (opt-in update) + ansible.builtin.command: + cmd: docker compose pull + chdir: "{{ app_dir }}" + when: bot_image_pull | bool + changed_when: false +`, + accepted: true, + }, + { + name: "when: written above the module keys", + doc: `--- +- name: Fetch the images (opt-in update) + when: bot_image_pull | bool + ansible.builtin.command: + cmd: docker compose pull +`, + accepted: true, // key order in a YAML mapping carries no meaning + }, + { + name: "when: as a list", + doc: `--- +- name: Fetch the images (opt-in update) + ansible.builtin.command: + cmd: docker compose pull + when: + - bot_image_pull | bool + - app_dir | length > 0 +`, + accepted: true, + }, + { + name: "gated by an enclosing block, when: after it", + doc: `--- +- name: Update + block: + - name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull + when: bot_image_pull | bool +`, + accepted: false, // the gate has to be visible on the task that pulls + }, + { + name: "gated by an enclosing block, when: before it", + doc: `--- +- name: Update + when: bot_image_pull | bool + block: + - name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull +`, + accepted: false, // same verdict as above: the answer cannot depend on order + }, + { + name: "gate on a constant", + doc: `--- +- name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull + when: true +`, + accepted: false, + }, + { + name: "gate on an unrelated variable", + doc: `--- +- name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull + when: enable_dind | bool +`, + accepted: false, + }, + { + name: "the knob named only in a comment", + doc: `--- +- name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull + when: enable_dind | bool # bot_image_pull +`, + accepted: false, + }, + { + name: "no condition at all", + doc: `--- +- name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull +`, + accepted: false, + }, + { + name: "the next task's gate does not reach back", + doc: `--- +- name: Fetch the images + ansible.builtin.command: + cmd: docker compose pull + +- name: Something else + ansible.builtin.command: + cmd: docker compose up -d + when: bot_image_pull | bool +`, + accepted: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + found := findUngatedPulls(tc.doc, true) + if tc.accepted && len(found) > 0 { + t.Errorf("gated pull reported as implicit: line %d, %s", found[0].line+1, found[0].text) + } + if !tc.accepted && len(found) == 0 { + t.Error("ungated pull accepted as the opt-in path") + } + }) + } +} + +// TestStripCommentKeepsDirectivesVisible pins the comment handling the guard relies on: a +// commented-out directive must not fail the build, and a comment appended to a real line +// must not hide the directive on it. +func TestStripCommentKeepsDirectivesVisible(t *testing.T) { + hidden := []string{ + `# No --pull always here: updating is opt-in`, + ` # cmd: docker compose pull`, + } + for _, line := range hidden { + if matchesPullDirective(stripComment(line)) { + t.Errorf("a comment naming a directive fails the guard: %s", line) + } + } + + visible := []string{ + ` cmd: docker compose pull # opt-in update`, + ` pull_policy: always # keep us current`, + ` cmd: echo "nothing # here" && docker compose pull`, + } + for _, line := range visible { + if !matchesPullDirective(stripComment(line)) { + t.Errorf("a trailing comment hides a directive: %s", line) + } + } +} + // TestBotImagePullIsOptIn asserts the knob the pull task is gated on defaults to false. // A default of true would make that `when:` always fire, which is an unconditional pull -// wearing a gate — exactly the behaviour the guard above exists to keep out. +// wearing a gate — exactly the behaviour the guard above exists to keep out. The guard +// checks that the gate NAMES this knob; this test is what makes naming it mean "off". func TestBotImagePullIsOptIn(t *testing.T) { const path = "deploy/roles/claude_tg_bot/defaults/main.yml" raw, err := os.ReadFile(path) @@ -170,8 +459,11 @@ func TestBotImagePullIsOptIn(t *testing.T) { if m == nil { t.Fatalf("%s: no bot_image_pull default — the pull task's `when:` gate must have one", path) } - if got := strings.Trim(m[1], `"'`); got != "false" { - t.Errorf("%s: bot_image_pull defaults to %q, want \"false\" — updating must stay opt-in, "+ - "otherwise every playbook run pulls again", path, got) + // Every spelling Ansible reads as false: the YAML 1.1 booleans in any case, plus 0, + // which `| bool` (how the task consumes it) also treats as false. + falsey := map[string]bool{"false": true, "no": true, "n": true, "off": true, "0": true} + if got := strings.Trim(m[1], `"'`); !falsey[strings.ToLower(got)] { + t.Errorf("%s: bot_image_pull defaults to %q, want a false value — updating must stay "+ + "opt-in, otherwise every playbook run pulls again", path, got) } } From 2053b125c91a62bcad7c3c5ae12e12478cb98d3a Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:04:06 +0000 Subject: [PATCH 12/26] fix(deploy): update the whole stack on an opt-in run and report what it fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulling only the bot left the sidecars with no update path at all. Their tags float within a major (docker:27-dind, caddy:2-alpine), and dind runs privileged: before this branch the per-run `--pull always` kept it current, and dropping that without replacing it would mean a privileged container that never takes an upstream fix. The opt-in fetch now covers every service in the stack. Nothing becomes automatic — the operator still decides when a run pulls. The `changed_when: false` rationale was also wrong: it claimed the (re)start task reports whether the deployment moved, but that task runs `--force-recreate` and therefore always reports changed. The fetch is registered instead and its own output is echoed verbatim in "Report status", so an operator can see whether a new image actually arrived rather than inferring it from a flag that never varies. Same correction in the comments: an ordinary run does not go to the registry and so cannot pick up a new build — it (re)starts on whatever the tag points to on that host. It does not freeze the version, and the defaults no longer promise it. --- .../roles/claude_tg_bot/defaults/main.yml | 22 +++++++--- .../deploy/roles/claude_tg_bot/tasks/main.yml | 43 +++++++++++++------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index cf329da..f9d5158 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -15,17 +15,24 @@ container_prefix: claude-tg legacy_volume_project: "" # Prebuilt image — pulled, not built (agents/CLAUDE.md are baked in by CI). -# The tag is resolved against the registry ONCE, on the first install; afterwards the -# host keeps the image it already has. So :latest does NOT track main by itself — an -# ordinary playbook run (token rotation, a limit change) never moves the version. +# The tag is resolved against the registry ONCE, on the first install. Afterwards an +# ordinary playbook run (token rotation, a limit change) does NOT contact the registry, so +# it cannot pick up a new build: it (re)starts the container on whatever this tag points to +# LOCALLY on that host, and :latest therefore does not track main by itself. (If the local +# tag has already been moved — by the opt-in fetch below whose (re)start did not complete, +# or by a pull run by hand on the host — the next ordinary run does land on that image. +# What a run never does is go looking for a newer one.) # Pin a tag (e.g. :v1) to make the version explicit in the inventory; to move a # deployment forward, re-run with -e bot_image_pull=true (see below). bot_image: "ghcr.io/duckbugio/flock-telegram:latest" -# Update switch. false (default) = the run reuses the image the host already has; -# true = fetch what {{ bot_image }} currently points to first, so the (re)start below -# lands on the new build. Updating is therefore a deliberate operator action: +# Update switch. false (default) = the run stays off the registry and the stack (re)starts +# on the images this host already has; true = fetch what the tags currently point to first, +# so the (re)start below lands on them. Updating is therefore a deliberate operator action: # ansible-playbook -i inventories//inventory.ini playbook.yml -e bot_image_pull=true +# The fetch covers the sidecars as well, not only {{ bot_image }}: their tags float within a +# major too and dind runs privileged, so this switch is their only update path — a +# privileged container has to have one. # It is a variable rather than an Ansible tag on purpose — a tag would be a second, # partly overlapping switch, and selecting only the pull task with --tags would skip the # (re)start that actually puts the new image into service. @@ -116,6 +123,7 @@ enable_pr_review: false # Webhook PR-monitoring (enabled when webhook_domain is set in group_vars/all/vars.yml) webhook_domain: "" notification_chat_ids: "" +# Floats within major 2; like dind it moves only on an -e bot_image_pull=true run. caddy_image: "caddy:2-alpine" # PR poller cadence (seconds). The bot polls Gitea for new PR comments — the reliable @@ -125,6 +133,8 @@ gitea_poll_interval: 90 # Docker-in-Docker sidecar so the team can run dockerized linters/tests. ON by # default; set to false on hosts that cannot run a privileged container. +# The tag floats within major 27, and this container is privileged: it takes upstream +# fixes only when a run with -e bot_image_pull=true fetches the stack. enable_dind: true dind_image: "docker:27-dind" diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml index 16cb9f8..6a0b4bf 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml @@ -137,25 +137,31 @@ no_log: true # The update path, off unless the operator asks for it with -e bot_image_pull=true. -# Only the bot service is fetched: this switch is about the bot image, and the pinned -# sidecars (dind, caddy) stay on whatever the host already has. The (re)start below then -# recreates the container on the fetched image. changed_when: false because the fetch -# alone changes nothing on the host — the (re)start task reports whether the deployment -# actually moved. -- name: Fetch the bot image (opt-in update) +# It fetches every service in the stack, not just the bot: the sidecar tags float within a +# major too (docker:27-dind, caddy:2-alpine), and dind is privileged — pulling only the bot +# would leave a privileged container with no path to a security fix at all. Nothing here is +# automatic; the operator still picks the moment. The (re)start below then recreates the +# containers on whatever was fetched. +# changed_when: false — a fetch on its own changes nothing that is in service. What it did +# is reported verbatim by "Report status" instead: the (re)start always reports changed +# (--force-recreate recreates the container on every run), so its flag cannot tell the +# operator whether a new image actually arrived. +- name: Fetch the current images (opt-in update) ansible.builtin.command: - cmd: docker compose pull bot + cmd: docker compose pull chdir: "{{ app_dir }}" when: bot_image_pull | bool + register: image_fetch async: 1800 poll: 15 changed_when: false -# No --pull here: moving to a new image is the explicit action above, not a side effect of -# running this playbook — a run made for a token rotation or a limit change must not also -# roll the version forward. --force-recreate stays: with the tag held still it only rebuilds -# the container (so a changed .env is picked up even on compose versions older than v2.14, -# which don't detect that themselves), never the image. +# No --pull here: this task never contacts the registry, so a run made for a token rotation +# or a limit change cannot pick up a new build — it recreates the container on whatever the +# tag points to on THIS host. Moving to a new image is the explicit fetch above (or the +# operator's own pull). --force-recreate stays: with the local tag held still it only +# rebuilds the container (so a changed .env is picked up even on compose versions older +# than v2.14, which don't detect that themselves), never the image. - name: (Re)start the bot on its current image ansible.builtin.command: cmd: docker compose up -d --force-recreate @@ -178,5 +184,16 @@ until: bot_state.stdout == "running" - name: Report status + vars: + # Verbatim and uninterpreted: the fetch's own output is the only honest answer to + # "did a new image arrive?", since the (re)start above always reports changed. + image_fetch_report: >- + {{ 'not requested (bot_image_pull=false) — the registry was not contacted' + if ((image_fetch is not defined) or (image_fetch.skipped | default(false))) + else (((image_fetch.stderr_lines | default([])) + (image_fetch.stdout_lines | default([]))) + | map('trim') | select | join(' | ') | default('no output', true)) }} ansible.builtin.debug: - msg: "{{ container_prefix }}-bot is {{ bot_state.stdout }} — check logs with: docker compose -f {{ app_dir }}/docker-compose.yml logs -f" + msg: + - "{{ container_prefix }}-bot is {{ bot_state.stdout }} on the local copy of {{ bot_image }}" + - "image fetch: {{ image_fetch_report }}" + - "logs: docker compose -f {{ app_dir }}/docker-compose.yml logs -f" From 73644154d43141bc3e1e171db8bc6d1f07f5ed30 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:04:14 +0000 Subject: [PATCH 13/26] docs: state what an ordinary deploy run actually guarantees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "An ordinary re-run never changes the running version" was an absolute the role cannot honour. If the local tag has already been moved — an opt-in run whose (re)start was interrupted, or an operator's own pull, which the role suggests for a rollback — the next ordinary run does land on that image. What the role really guarantees is narrower and still the point: an ordinary run does not contact the registry, so it cannot pick up a new build; it restarts the container on the image the tag already points to on that host. All eight translations now say that, each in its own language. --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.de.md b/README.de.md index f7949b0..cd79c5b 100644 --- a/README.de.md +++ b/README.de.md @@ -107,7 +107,7 @@ Der **Poller** ist die empfohlene Methode, um auf Review-Kommentare zu reagieren - **Sprachnachrichten:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (oder `OPENAI_API_KEY`). Werden transkribiert und als Befehle ausgeführt. - **dind-Sidecar:** `docker compose --profile dind up -d` stellt dem Team dockerisierte Linter/Tests bereit (setze `DOCKER_HOST=tcp://dind:2375`). - **Isolation pro Chat:** Jeder Chat erhält `/workspace/chat_` (1:1 → privat; Gruppe → ein gemeinsamer Workspace); Chats sind vollständig isoliert und laufen parallel, begrenzt durch `MAX_CONCURRENT_CHAT_RUNS`. Setze in Gruppen `REQUIRE_GROUP_MENTION=true`, damit der Bot nur antwortet, wenn er per @mention erwähnt wird oder auf ihn geantwortet wird. -- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf ändert die laufende Version nicht. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen; setze `bot_image`, um einen Tag festzuhalten. +- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf kontaktiert die Registry nicht und kann deshalb keinen neuen Build übernehmen — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen; setze `bot_image`, um einen Tag festzuhalten. ## Sicherheit diff --git a/README.es.md b/README.es.md index 481fe8f..8ca9f9f 100644 --- a/README.es.md +++ b/README.es.md @@ -107,7 +107,7 @@ El **poller** es la forma recomendada de reaccionar a los comentarios de revisi - **Mensajes de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, más `MISTRAL_API_KEY` (o `OPENAI_API_KEY`). Se transcriben y se ejecutan como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` le da al equipo linters/pruebas dockerizados (establece `DOCKER_HOST=tcp://dind:2375`). - **Aislamiento por chat:** cada chat obtiene `/workspace/chat_` (1:1 → privado; grupo → un espacio de trabajo compartido); los chats están totalmente aislados y se ejecutan en paralelo, limitado por `MAX_CONCURRENT_CHAT_RUNS`. En los grupos, establece `REQUIRE_GROUP_MENTION=true` para responder solo cuando se te @menciona o se te responde. -- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no cambia la versión en marcha. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`; establece `bot_image` para fijar una etiqueta. +- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal nunca contacta con el registro, así que no puede recoger una compilación nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`; establece `bot_image` para fijar una etiqueta. ## Seguridad diff --git a/README.fr.md b/README.fr.md index 9d187a2..63d5f96 100644 --- a/README.fr.md +++ b/README.fr.md @@ -107,7 +107,7 @@ Le **poller** est le moyen recommandé pour réagir aux commentaires de revue - **Messages vocaux :** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcrits et exécutés comme des commandes. - **Sidecar dind :** `docker compose --profile dind up -d` fournit à l'équipe des linters/tests dockerisés (définissez `DOCKER_HOST=tcp://dind:2375`). - **Isolation par conversation :** chaque conversation reçoit `/workspace/chat_` (1:1 → privé ; groupe → un espace de travail partagé) ; les conversations sont totalement isolées et s'exécutent en parallèle, plafonnées par `MAX_CONCURRENT_CHAT_RUNS`. Dans les groupes, définissez `REQUIRE_GROUP_MENTION=true` pour ne répondre que lorsque le bot est @mentionné ou cité en réponse. -- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne change pas la version en cours. Pour mettre à jour, relancez avec `-e bot_image_pull=true` ; définissez `bot_image` pour épingler un tag. +- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne contacte jamais le registre et ne peut donc pas récupérer une nouvelle build — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte. Pour mettre à jour, relancez avec `-e bot_image_pull=true` ; définissez `bot_image` pour épingler un tag. ## Sécurité diff --git a/README.ja.md b/README.ja.md index cd23cf7..98918b1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **音声メッセージ:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`、加えて `MISTRAL_API_KEY`(または `OPENAI_API_KEY`)。文字起こしされてコマンドとして実行されます。 - **dind サイドカー:** `docker compose --profile dind up -d` で、Docker 化されたリンター/テストをチームに提供します(`DOCKER_HOST=tcp://dind:2375` を設定)。 - **チャットごとの隔離:** 各チャットには `/workspace/chat_` が割り当てられます(1:1 → プライベート、グループ → 1 つの共有ワークスペース)。チャットは完全に隔離され、`MAX_CONCURRENT_CHAT_RUNS` を上限として並列実行されます。グループでは `REQUIRE_GROUP_MENTION=true` を設定すると、@メンションまたは返信されたときのみ応答します。 -- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行では稼働中のバージョンは変わりません。更新するには `-e bot_image_pull=true` を付けて再実行してください。タグを固定するには `bot_image` を設定します。 +- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行はレジストリに接続しないため、新しいビルドを取り込むことはありません。そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。更新するには `-e bot_image_pull=true` を付けて再実行してください。タグを固定するには `bot_image` を設定します。 ## セキュリティ diff --git a/README.md b/README.md index 22d5dd6..6ac58f2 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The **poller** is the recommended way to react to review comments — it reaches - **Voice messages:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (or `OPENAI_API_KEY`). Transcribed and run as commands. - **dind sidecar:** `docker compose --profile dind up -d` gives the team dockerized linters/tests (set `DOCKER_HOST=tcp://dind:2375`). Ansible deploys enable dind by default and inject `DOCKER_HOST` automatically. - **Per-chat isolation:** each chat gets `/workspace/chat_` (1:1 → private; group → one shared workspace); chats are fully isolated and run in parallel, capped by `MAX_CONCURRENT_CHAT_RUNS`. In groups, set `REQUIRE_GROUP_MENTION=true` to respond only when @mentioned or replied to. -- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run never changes the running version. To update, re-run with `-e bot_image_pull=true`; set `bot_image` to pin a tag. +- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run never contacts the registry, so it can't pick up a new build — it restarts the container on the image that tag already points to on that host. To update, re-run with `-e bot_image_pull=true`; set `bot_image` to pin a tag. ## Security diff --git a/README.pt-BR.md b/README.pt-BR.md index 6a7b8fb..5c76c35 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -107,7 +107,7 @@ O **poller** é a forma recomendada de reagir a comentários de revisão — ele - **Mensagens de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, mais `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcritas e executadas como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` dá ao time linters/testes dockerizados (defina `DOCKER_HOST=tcp://dind:2375`). - **Isolamento por chat:** cada chat recebe `/workspace/chat_` (1:1 → privado; grupo → um workspace compartilhado); os chats são totalmente isolados e rodam em paralelo, limitados por `MAX_CONCURRENT_CHAT_RUNS`. Em grupos, defina `REQUIRE_GROUP_MENTION=true` para responder apenas quando for @mencionado ou respondido. -- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não muda a versão em execução. Para atualizar, execute novamente com `-e bot_image_pull=true`; defina `bot_image` para fixar uma tag. +- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum nunca acessa o registro, então não pega uma build nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host. Para atualizar, execute novamente com `-e bot_image_pull=true`; defina `bot_image` para fixar uma tag. ## Segurança diff --git a/README.ru.md b/README.ru.md index daa1314..f79e4e2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **Голосовые сообщения:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, плюс `MISTRAL_API_KEY` (или `OPENAI_API_KEY`). Транскрибируются и выполняются как команды. - **Сайдкар dind:** `docker compose --profile dind up -d` даёт команде линтеры/тесты в Docker (задайте `DOCKER_HOST=tcp://dind:2375`). - **Изоляция по чатам:** каждый чат получает `/workspace/chat_` (1:1 → приватное; группа → одно общее рабочее пространство); чаты полностью изолированы и выполняются параллельно с ограничением `MAX_CONCURRENT_CHAT_RUNS`. В группах задайте `REQUIRE_GROUP_MENTION=true`, чтобы отвечать только при @упоминании или в ответ на сообщение. -- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не меняет запущенную версию. Чтобы обновиться, запустите с `-e bot_image_pull=true`; задайте `bot_image`, чтобы зафиксировать тег. +- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не обращается к реестру и потому не подхватит новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте. Чтобы обновиться, запустите с `-e bot_image_pull=true`; задайте `bot_image`, чтобы зафиксировать тег. ## Безопасность diff --git a/README.zh.md b/README.zh.md index baf6fba..c888694 100644 --- a/README.zh.md +++ b/README.zh.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **语音消息:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`,外加 `MISTRAL_API_KEY`(或 `OPENAI_API_KEY`)。语音会被转写并作为命令运行。 - **dind 边车容器:** `docker compose --profile dind up -d` 为团队提供容器化的 linters/测试(设置 `DOCKER_HOST=tcp://dind:2375`)。 - **按聊天隔离:** 每个聊天都会获得一个 `/workspace/chat_`(一对一 → 私有;群组 → 一个共享工作区);不同聊天彼此完全隔离并行运行,受 `MAX_CONCURRENT_CHAT_RUNS` 限制。在群组中,设置 `REQUIRE_GROUP_MENTION=true` 可让机器人仅在被 @提及或被回复时才响应。 -- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色只在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会改变正在运行的版本。要更新,请附带 `-e bot_image_pull=true` 重新运行;设置 `bot_image` 以固定某个标签。 +- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色只在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会访问镜像仓库,因此取不到新构建——它只是用该标签在这台主机本地已指向的镜像重启容器。要更新,请附带 `-e bot_image_pull=true` 重新运行;设置 `bot_image` 以固定某个标签。 ## 安全 From e224d2b713c861c26c7bbc860948a8ddcd1b4569 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:04:14 +0000 Subject: [PATCH 14/26] ci: correct two comments in the publish workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Negations come last" does not describe the file: two literal patterns follow the negations. The rule that matters is GitHub's — the last matching pattern wins — so a negation has to sit after the pattern it carves out of, which it does; the entries after them overlap with neither. The tag list said :latest "moves only on an explicit pull". The pull fetches the image; the deployment moves when the following (re)start recreates the container on it, so "only after an explicit pull" is the accurate phrasing. --- .github/workflows/publish-telegram.yml | 7 ++++--- .github/workflows/publish-vk.yml | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-telegram.yml b/.github/workflows/publish-telegram.yml index ba6dbbc..e3e54fc 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -10,8 +10,9 @@ on: # sources (cmd/, internal/, core/, go.mod/go.sum) or the adapter build # (Dockerfile/.dockerignore). Docs, the Ansible deploy dir and Go test files # don't affect the image (.dockerignore keeps _test.go out of the build context), - # so they don't trigger a build. Negations come last: the final matching pattern - # wins. + # so they don't trigger a build. The last matching pattern wins, so each negation + # has to sit after the pattern it carves out of; the entries below them overlap + # with neither negation. paths: - 'cmd/**' - 'internal/**' @@ -55,7 +56,7 @@ jobs: # build (no manual step); the number increments per # publish run so images sort and read sanely # :latest — rolling, default branch only; a deploy resolves it - # once at install and moves only on an explicit pull + # once at install and moves only after an explicit pull # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback # metadata-action stamps the highest-priority tag into diff --git a/.github/workflows/publish-vk.yml b/.github/workflows/publish-vk.yml index b77104b..532a17b 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -10,8 +10,9 @@ on: # sources (cmd/, internal/, core/, go.mod/go.sum) or the adapter build # (Dockerfile/.dockerignore). Docs, the Ansible deploy dir and Go test files # don't affect the image (.dockerignore keeps _test.go out of the build context), - # so they don't trigger a build. Negations come last: the final matching pattern - # wins. + # so they don't trigger a build. The last matching pattern wins, so each negation + # has to sit after the pattern it carves out of; the entries below them overlap + # with neither negation. paths: - 'cmd/**' - 'internal/**' @@ -55,7 +56,7 @@ jobs: # build (no manual step); the number increments per # publish run so images sort and read sanely # :latest — rolling, default branch only; a deploy resolves it - # once at install and moves only on an explicit pull + # once at install and moves only after an explicit pull # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback # metadata-action stamps the highest-priority tag into From 3147c59fcad6281ab76d23721a9e4df9243a5efd Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:15:49 +0000 Subject: [PATCH 15/26] docs(deploy): scope the sidecar update notes to what the role controls The two sidecar comments said their images move only on an -e bot_image_pull=true run. That is the role's only path, but not the host's: an operator can pull by hand, exactly as the rollback note suggests. Say "the role moves it only ..." so the comments claim what the role does rather than what can ever happen. --- .../telegram/deploy/roles/claude_tg_bot/defaults/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index f9d5158..b7aa72e 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -123,7 +123,7 @@ enable_pr_review: false # Webhook PR-monitoring (enabled when webhook_domain is set in group_vars/all/vars.yml) webhook_domain: "" notification_chat_ids: "" -# Floats within major 2; like dind it moves only on an -e bot_image_pull=true run. +# Floats within major 2; like dind, the role moves it only on an -e bot_image_pull=true run. caddy_image: "caddy:2-alpine" # PR poller cadence (seconds). The bot polls Gitea for new PR comments — the reliable @@ -133,8 +133,8 @@ gitea_poll_interval: 90 # Docker-in-Docker sidecar so the team can run dockerized linters/tests. ON by # default; set to false on hosts that cannot run a privileged container. -# The tag floats within major 27, and this container is privileged: it takes upstream -# fixes only when a run with -e bot_image_pull=true fetches the stack. +# The tag floats within major 27, and this container is privileged: the role moves it +# only when a run with -e bot_image_pull=true fetches the stack. enable_dind: true dind_image: "docker:27-dind" From 9903473c56a61db03bd844186b0bb6c20c45abd6 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:51:11 +0000 Subject: [PATCH 16/26] docs(deploy): name the cases where a run still fetches an image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role's notes claimed an ordinary run cannot reach the registry, but compose's default "missing" policy fetches an image the host does not have: on the first install, and after a `compose down` leaves the image unreferenced until the weekly prune reclaims it. That is exactly the silent version move this change set exists to prevent, so it has to be documented rather than denied — and defaults/main.yml contradicted itself about it two paragraphs apart. Say what the role does instead of what cannot happen, and list the cases that still land a deployment on another image next to every claim that used to exclude them. The sidecar note is scoped to the role, which is the boundary it can speak for; a pull run by hand on the host is outside it. --- .github/workflows/publish-telegram.yml | 5 ++- .github/workflows/publish-vk.yml | 5 ++- .../roles/claude_tg_bot/defaults/main.yml | 43 +++++++++++-------- .../deploy/roles/claude_tg_bot/tasks/main.yml | 14 +++--- .../templates/docker-compose.yml.j2 | 15 ++++--- .../templates/docker-prune.service.j2 | 4 +- 6 files changed, 51 insertions(+), 35 deletions(-) diff --git a/.github/workflows/publish-telegram.yml b/.github/workflows/publish-telegram.yml index e3e54fc..c2114e1 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -55,8 +55,9 @@ jobs: # :0.1. — ordered, human-readable auto-version for every main # build (no manual step); the number increments per # publish run so images sort and read sanely - # :latest — rolling, default branch only; a deploy resolves it - # once at install and moves only after an explicit pull + # :latest — rolling, default branch only; an Ansible deploy resolves + # it at install and follows it again on an operator's pull, + # or when the host has no copy of the tag left to start from # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback # metadata-action stamps the highest-priority tag into diff --git a/.github/workflows/publish-vk.yml b/.github/workflows/publish-vk.yml index 532a17b..0c537e2 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -55,8 +55,9 @@ jobs: # :0.1. — ordered, human-readable auto-version for every main # build (no manual step); the number increments per # publish run so images sort and read sanely - # :latest — rolling, default branch only; a deploy resolves it - # once at install and moves only after an explicit pull + # :latest — rolling, default branch only; an Ansible deploy resolves + # it at install and follows it again on an operator's pull, + # or when the host has no copy of the tag left to start from # :vX.Y.Z — milestone release: push a git tag vX.Y.Z and it lands # :sha- — exact, immutable pin for traceability/rollback # metadata-action stamps the highest-priority tag into diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index b7aa72e..862c8c5 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -15,24 +15,29 @@ container_prefix: claude-tg legacy_volume_project: "" # Prebuilt image — pulled, not built (agents/CLAUDE.md are baked in by CI). -# The tag is resolved against the registry ONCE, on the first install. Afterwards an -# ordinary playbook run (token rotation, a limit change) does NOT contact the registry, so -# it cannot pick up a new build: it (re)starts the container on whatever this tag points to -# LOCALLY on that host, and :latest therefore does not track main by itself. (If the local -# tag has already been moved — by the opt-in fetch below whose (re)start did not complete, -# or by a pull run by hand on the host — the next ordinary run does land on that image. -# What a run never does is go looking for a newer one.) +# The tag is resolved against the registry on the first install. Afterwards an ordinary +# playbook run (token rotation, a limit change) does not go looking for a newer build: it +# (re)starts the container on whatever this tag points to LOCALLY on that host, so :latest +# does not track main by itself. Three things still move a deployment onto another image: +# - the opt-in fetch below (-e bot_image_pull=true) — the intended update path; +# - the local tag having been moved out of band, by a pull run by hand on the host or by +# an opt-in fetch whose (re)start did not complete: the next ordinary run lands on it; +# - the host no longer having an image for the tag — the first install, or a `compose down` +# that left it unreferenced until the weekly prune reclaimed it. Compose fetches what is +# missing in order to start the stack, which resolves the tag against the registry again. # Pin a tag (e.g. :v1) to make the version explicit in the inventory; to move a # deployment forward, re-run with -e bot_image_pull=true (see below). bot_image: "ghcr.io/duckbugio/flock-telegram:latest" -# Update switch. false (default) = the run stays off the registry and the stack (re)starts -# on the images this host already has; true = fetch what the tags currently point to first, -# so the (re)start below lands on them. Updating is therefore a deliberate operator action: +# Update switch. false (default) = the run does not go looking for a newer image; the stack +# (re)starts on what this host already has (compose still fetches an image that is missing +# locally — the first install, or one the prune reclaimed). true = fetch what the tags +# currently point to first, so the (re)start below lands on them. Updating is therefore a +# deliberate operator action: # ansible-playbook -i inventories//inventory.ini playbook.yml -e bot_image_pull=true # The fetch covers the sidecars as well, not only {{ bot_image }}: their tags float within a -# major too and dind runs privileged, so this switch is their only update path — a -# privileged container has to have one. +# major too and dind runs privileged, so within the role this switch is their only update +# path — a privileged container has to have one. # It is a variable rather than an Ansible tag on purpose — a tag would be a second, # partly overlapping switch, and selecting only the pull task with --tags would skip the # (re)start that actually puts the new image into service. @@ -123,7 +128,8 @@ enable_pr_review: false # Webhook PR-monitoring (enabled when webhook_domain is set in group_vars/all/vars.yml) webhook_domain: "" notification_chat_ids: "" -# Floats within major 2; like dind, the role moves it only on an -e bot_image_pull=true run. +# Floats within major 2; like dind, the role moves it on an -e bot_image_pull=true run — or, +# as with bot_image above, when the host has no copy of the tag left to start from. caddy_image: "caddy:2-alpine" # PR poller cadence (seconds). The bot polls Gitea for new PR comments — the reliable @@ -133,8 +139,9 @@ gitea_poll_interval: 90 # Docker-in-Docker sidecar so the team can run dockerized linters/tests. ON by # default; set to false on hosts that cannot run a privileged container. -# The tag floats within major 27, and this container is privileged: the role moves it -# only when a run with -e bot_image_pull=true fetches the stack. +# The tag floats within major 27, and this container is privileged: the role moves it when a +# run with -e bot_image_pull=true fetches the stack, or when the host has no copy of the tag +# left to start from (see bot_image above). enable_dind: true dind_image: "docker:27-dind" @@ -142,8 +149,10 @@ dind_image: "docker:27-dind" # filters on is the image's CREATION time (when CI built it), not when this host pulled it, # so a just-pulled image is usually already past docker_prune_keep_hours and is reclaimed # once nothing runs it — after an update, rolling back to the previous image normally means -# pulling that tag again. Images in use by a running container are never touched, so the -# version in service always survives. Set enable_docker_prune: false to skip. +# pulling that tag again. The prune skips any image a container still references, so the +# version in service stays; an image nothing references — e.g. after a `compose down` — is +# reclaimed, and the next ordinary run fetches that tag again to bring the stack up. +# Set enable_docker_prune: false to skip. enable_docker_prune: true docker_prune_schedule: "weekly" # systemd OnCalendar (weekly | daily | "Sun *-*-* 04:00:00") docker_prune_keep_hours: 168 # keep anything newer than this many hours (168 = 7 days) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml index 6a0b4bf..d2fe281 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml @@ -156,12 +156,14 @@ poll: 15 changed_when: false -# No --pull here: this task never contacts the registry, so a run made for a token rotation -# or a limit change cannot pick up a new build — it recreates the container on whatever the -# tag points to on THIS host. Moving to a new image is the explicit fetch above (or the -# operator's own pull). --force-recreate stays: with the local tag held still it only -# rebuilds the container (so a changed .env is picked up even on compose versions older -# than v2.14, which don't detect that themselves), never the image. +# No --pull here: this task does not go looking for a newer build, so a run made for a token +# rotation or a limit change recreates the container on whatever the tag points to on THIS +# host. Compose's default policy does fetch an image the host has none of (first install, or +# one the weekly prune reclaimed after a `compose down`), which resolves the tag again; short +# of that, moving to a new image is the explicit fetch above or the operator's own pull. +# --force-recreate stays: with the local image unchanged it recreates the container only (so +# a changed .env is picked up even on compose versions older than v2.14, which don't detect +# that themselves), not the image. - name: (Re)start the bot on its current image ansible.builtin.command: cmd: docker compose up -d --force-recreate diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 index 1f33584..897d2c4 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2 @@ -2,13 +2,14 @@ services: bot: image: {{ bot_image }} - # No pull policy override on purpose: compose reuses the image this host already has - # for the tag, so a `docker compose up` (playbook run, host reboot recovery) does not - # go to the registry on its own — even though bot_image tracks a moving tag. It does - # not freeze the version: if the local tag has since been moved (by the role's opt-in - # bot_image_pull task, or a `docker compose pull` on the host), the next `up -d - # --force-recreate` recreates the container on that newer image. Updating is thus a - # deliberate pull followed by `up -d`, never a side effect of the `up` alone. + # No pull policy override on purpose: compose's default is "missing", so an `up` + # (playbook run, host reboot recovery) reuses the image this host already has for the + # tag rather than re-resolving it — even though bot_image tracks a moving tag. Two + # things still change the version an `up` starts: the local tag having been moved out + # of band (the role's opt-in bot_image_pull task, or a `docker compose pull` on the + # host), and the image being absent altogether — first install, or a prune reclaimed + # it — where "missing" fetches the tag from the registry to have something to run. + # Updating is therefore a deliberate pull followed by `up -d`, not a by-product of it. container_name: {{ container_prefix }}-bot restart: unless-stopped # Graceful drain on deploy: give the bot up to 75s after SIGTERM to drain diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 index 09127fc..f01afe5 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/docker-prune.service.j2 @@ -3,7 +3,9 @@ # pulled it, so a freshly pulled image can already be older than {{ docker_prune_keep_hours }}h # and is reclaimed as soon as nothing runs it: after an update the image you rolled off # is normally gone by the next run, and rolling back means pulling that tag again. -# Images in use by a container are never touched, so the running version always survives. +# The prune skips any image a container still references, so the version in service stays; +# once nothing references it — e.g. after a `docker compose down` — it is reclaimed, and the +# next ordinary playbook run fetches that tag again to bring the stack up. [Unit] Description=Prune unused Docker images and build cache From ddfa91789bd3486cc6f252094823a727a7d26e09 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:54:17 +0000 Subject: [PATCH 17/26] fix(deploy): report the opt-in fetch from its own changed flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report claimed the fetch's output was "the only honest answer to did a new image arrive". It is not: `docker compose pull` prints " Pulling"/"Pulled" for every service whether or not anything was downloaded, and a line per layer per status transition on top — so the stderr_lines+stdout_lines join collapsed hundreds of progress lines into one multi-kilobyte debug string that still needed interpreting. Put the signal in the task instead: "Pull complete" is printed only for a layer that was actually downloaded, so changed_when on it makes Ansible's own flag mean "a new image arrived", and the report shrinks to one line derived from that flag. Verified against captured non-TTY output of a real `docker compose pull` in both states, and under async/poll. --- .../deploy/roles/claude_tg_bot/tasks/main.yml | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml index d2fe281..1aadaa5 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml @@ -142,10 +142,13 @@ # would leave a privileged container with no path to a security fix at all. Nothing here is # automatic; the operator still picks the moment. The (re)start below then recreates the # containers on whatever was fetched. -# changed_when: false — a fetch on its own changes nothing that is in service. What it did -# is reported verbatim by "Report status" instead: the (re)start always reports changed -# (--force-recreate recreates the container on every run), so its flag cannot tell the -# operator whether a new image actually arrived. +# changed_when is where the answer to "did a new image arrive?" belongs: compose prints +# "Pull complete" once per layer it actually downloaded, and prints none of those lines when +# every tag already resolved to the local image. (A "Pulling"/"Pulled" pair is printed per +# service either way, so it answers nothing.) The narrow case this misses is a fetch that +# only re-points a tag at layers the host already has: nothing is downloaded, so it reports +# ok. The (re)start below recreates the container on every run (--force-recreate), so it +# reports changed whether or not the image moved — its flag says nothing about that. - name: Fetch the current images (opt-in update) ansible.builtin.command: cmd: docker compose pull @@ -154,7 +157,8 @@ register: image_fetch async: 1800 poll: 15 - changed_when: false + changed_when: >- + 'Pull complete' in (image_fetch.stderr + image_fetch.stdout) # No --pull here: this task does not go looking for a newer build, so a run made for a token # rotation or a limit change recreates the container on whatever the tag points to on THIS @@ -187,13 +191,16 @@ - name: Report status vars: - # Verbatim and uninterpreted: the fetch's own output is the only honest answer to - # "did a new image arrive?", since the (re)start above always reports changed. + # Restates the fetch task's own changed flag (see its comment above) rather than echoing + # its output: compose prints a line per layer and per status transition, so quoting it + # here would put hundreds of progress lines into one debug string and still leave the + # reader to interpret them. image_fetch_report: >- - {{ 'not requested (bot_image_pull=false) — the registry was not contacted' + {{ 'not requested (bot_image_pull=false) — no update fetch ran' if ((image_fetch is not defined) or (image_fetch.skipped | default(false))) - else (((image_fetch.stderr_lines | default([])) + (image_fetch.stdout_lines | default([]))) - | map('trim') | select | join(' | ') | default('no output', true)) }} + else ('a new image was downloaded; the (re)start above recreated the stack on it' + if (image_fetch.changed | default(false)) + else 'nothing downloaded — the tags already resolved to the local images') }} ansible.builtin.debug: msg: - "{{ container_prefix }}-bot is {{ bot_state.stdout }} on the local copy of {{ bot_image }}" From f9361d589aebc4e17e02be548f2ca93733185176 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 21:57:11 +0000 Subject: [PATCH 18/26] docs: soften the deploy update note and say what the switch refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "An ordinary re-run never contacts the registry" is not true of a host that has lost the image: after a `compose down` the weekly prune reclaims it, and the next ordinary run has compose fetch the tag again. Describe what the role does — it doesn't go looking for a newer build — instead of promising something the deployment's own housekeeping can break. Also name what `-e bot_image_pull=true` actually moves: the variable reads as if it were about the bot alone, while the fetch and the recreate cover the sidecars too, including the privileged dind. All eight translations carry the same two corrections. --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.de.md b/README.de.md index cd79c5b..d3c985b 100644 --- a/README.de.md +++ b/README.de.md @@ -107,7 +107,7 @@ Der **Poller** ist die empfohlene Methode, um auf Review-Kommentare zu reagieren - **Sprachnachrichten:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (oder `OPENAI_API_KEY`). Werden transkribiert und als Befehle ausgeführt. - **dind-Sidecar:** `docker compose --profile dind up -d` stellt dem Team dockerisierte Linter/Tests bereit (setze `DOCKER_HOST=tcp://dind:2375`). - **Isolation pro Chat:** Jeder Chat erhält `/workspace/chat_` (1:1 → privat; Gruppe → ein gemeinsamer Workspace); Chats sind vollständig isoliert und laufen parallel, begrenzt durch `MAX_CONCURRENT_CHAT_RUNS`. Setze in Gruppen `REQUIRE_GROUP_MENTION=true`, damit der Bot nur antwortet, wenn er per @mention erwähnt wird oder auf ihn geantwortet wird. -- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf kontaktiert die Registry nicht und kann deshalb keinen neuen Build übernehmen — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen; setze `bot_image`, um einen Tag festzuhalten. +- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf sucht nicht nach einem neueren Build — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen — das aktualisiert den gesamten Stack (Bot, dind, Caddy) und erstellt die Container neu; setze `bot_image`, um einen Tag festzuhalten. ## Sicherheit diff --git a/README.es.md b/README.es.md index 8ca9f9f..14bd3ff 100644 --- a/README.es.md +++ b/README.es.md @@ -107,7 +107,7 @@ El **poller** es la forma recomendada de reaccionar a los comentarios de revisi - **Mensajes de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, más `MISTRAL_API_KEY` (o `OPENAI_API_KEY`). Se transcriben y se ejecutan como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` le da al equipo linters/pruebas dockerizados (establece `DOCKER_HOST=tcp://dind:2375`). - **Aislamiento por chat:** cada chat obtiene `/workspace/chat_` (1:1 → privado; grupo → un espacio de trabajo compartido); los chats están totalmente aislados y se ejecutan en paralelo, limitado por `MAX_CONCURRENT_CHAT_RUNS`. En los grupos, establece `REQUIRE_GROUP_MENTION=true` para responder solo cuando se te @menciona o se te responde. -- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal nunca contacta con el registro, así que no puede recoger una compilación nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`; establece `bot_image` para fijar una etiqueta. +- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no va a buscar una compilación más nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`, lo que actualiza toda la pila (bot, dind, Caddy) y recrea los contenedores; establece `bot_image` para fijar una etiqueta. ## Seguridad diff --git a/README.fr.md b/README.fr.md index 63d5f96..06fa01c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -107,7 +107,7 @@ Le **poller** est le moyen recommandé pour réagir aux commentaires de revue - **Messages vocaux :** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcrits et exécutés comme des commandes. - **Sidecar dind :** `docker compose --profile dind up -d` fournit à l'équipe des linters/tests dockerisés (définissez `DOCKER_HOST=tcp://dind:2375`). - **Isolation par conversation :** chaque conversation reçoit `/workspace/chat_` (1:1 → privé ; groupe → un espace de travail partagé) ; les conversations sont totalement isolées et s'exécutent en parallèle, plafonnées par `MAX_CONCURRENT_CHAT_RUNS`. Dans les groupes, définissez `REQUIRE_GROUP_MENTION=true` pour ne répondre que lorsque le bot est @mentionné ou cité en réponse. -- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne contacte jamais le registre et ne peut donc pas récupérer une nouvelle build — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte. Pour mettre à jour, relancez avec `-e bot_image_pull=true` ; définissez `bot_image` pour épingler un tag. +- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne va pas chercher une build plus récente — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte. Pour mettre à jour, relancez avec `-e bot_image_pull=true`, ce qui met à jour toute la stack (bot, dind, Caddy) et recrée les conteneurs ; définissez `bot_image` pour épingler un tag. ## Sécurité diff --git a/README.ja.md b/README.ja.md index 98918b1..2bfc8a6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **音声メッセージ:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`、加えて `MISTRAL_API_KEY`(または `OPENAI_API_KEY`)。文字起こしされてコマンドとして実行されます。 - **dind サイドカー:** `docker compose --profile dind up -d` で、Docker 化されたリンター/テストをチームに提供します(`DOCKER_HOST=tcp://dind:2375` を設定)。 - **チャットごとの隔離:** 各チャットには `/workspace/chat_` が割り当てられます(1:1 → プライベート、グループ → 1 つの共有ワークスペース)。チャットは完全に隔離され、`MAX_CONCURRENT_CHAT_RUNS` を上限として並列実行されます。グループでは `REQUIRE_GROUP_MENTION=true` を設定すると、@メンションまたは返信されたときのみ応答します。 -- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行はレジストリに接続しないため、新しいビルドを取り込むことはありません。そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。更新するには `-e bot_image_pull=true` を付けて再実行してください。タグを固定するには `bot_image` を設定します。 +- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行は新しいビルドを探しにいかず、そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。更新するには `-e bot_image_pull=true` を付けて再実行してください(スタック全体 — ボット・dind・Caddy — が更新され、コンテナが再作成されます)。タグを固定するには `bot_image` を設定します。 ## セキュリティ diff --git a/README.md b/README.md index 6ac58f2..86b59aa 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The **poller** is the recommended way to react to review comments — it reaches - **Voice messages:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (or `OPENAI_API_KEY`). Transcribed and run as commands. - **dind sidecar:** `docker compose --profile dind up -d` gives the team dockerized linters/tests (set `DOCKER_HOST=tcp://dind:2375`). Ansible deploys enable dind by default and inject `DOCKER_HOST` automatically. - **Per-chat isolation:** each chat gets `/workspace/chat_` (1:1 → private; group → one shared workspace); chats are fully isolated and run in parallel, capped by `MAX_CONCURRENT_CHAT_RUNS`. In groups, set `REQUIRE_GROUP_MENTION=true` to respond only when @mentioned or replied to. -- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run never contacts the registry, so it can't pick up a new build — it restarts the container on the image that tag already points to on that host. To update, re-run with `-e bot_image_pull=true`; set `bot_image` to pin a tag. +- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run doesn't go looking for a newer build — it restarts the container on the image that tag already points to on that host. To update, re-run with `-e bot_image_pull=true`, which refreshes the whole stack (bot, dind, Caddy) and recreates the containers; set `bot_image` to pin a tag. ## Security diff --git a/README.pt-BR.md b/README.pt-BR.md index 5c76c35..d5f52da 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -107,7 +107,7 @@ O **poller** é a forma recomendada de reagir a comentários de revisão — ele - **Mensagens de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, mais `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcritas e executadas como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` dá ao time linters/testes dockerizados (defina `DOCKER_HOST=tcp://dind:2375`). - **Isolamento por chat:** cada chat recebe `/workspace/chat_` (1:1 → privado; grupo → um workspace compartilhado); os chats são totalmente isolados e rodam em paralelo, limitados por `MAX_CONCURRENT_CHAT_RUNS`. Em grupos, defina `REQUIRE_GROUP_MENTION=true` para responder apenas quando for @mencionado ou respondido. -- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum nunca acessa o registro, então não pega uma build nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host. Para atualizar, execute novamente com `-e bot_image_pull=true`; defina `bot_image` para fixar uma tag. +- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não sai atrás de uma build mais nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host. Para atualizar, execute novamente com `-e bot_image_pull=true`, o que atualiza a pilha inteira (bot, dind, Caddy) e recria os contêineres; defina `bot_image` para fixar uma tag. ## Segurança diff --git a/README.ru.md b/README.ru.md index f79e4e2..6d4b4fa 100644 --- a/README.ru.md +++ b/README.ru.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **Голосовые сообщения:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, плюс `MISTRAL_API_KEY` (или `OPENAI_API_KEY`). Транскрибируются и выполняются как команды. - **Сайдкар dind:** `docker compose --profile dind up -d` даёт команде линтеры/тесты в Docker (задайте `DOCKER_HOST=tcp://dind:2375`). - **Изоляция по чатам:** каждый чат получает `/workspace/chat_` (1:1 → приватное; группа → одно общее рабочее пространство); чаты полностью изолированы и выполняются параллельно с ограничением `MAX_CONCURRENT_CHAT_RUNS`. В группах задайте `REQUIRE_GROUP_MENTION=true`, чтобы отвечать только при @упоминании или в ответ на сообщение. -- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не обращается к реестру и потому не подхватит новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте. Чтобы обновиться, запустите с `-e bot_image_pull=true`; задайте `bot_image`, чтобы зафиксировать тег. +- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не ищет более новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте. Чтобы обновиться, запустите с `-e bot_image_pull=true` — это обновит весь стек (бот, dind, Caddy) и пересоздаст контейнеры; задайте `bot_image`, чтобы зафиксировать тег. ## Безопасность diff --git a/README.zh.md b/README.zh.md index c888694..ac511e4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **语音消息:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`,外加 `MISTRAL_API_KEY`(或 `OPENAI_API_KEY`)。语音会被转写并作为命令运行。 - **dind 边车容器:** `docker compose --profile dind up -d` 为团队提供容器化的 linters/测试(设置 `DOCKER_HOST=tcp://dind:2375`)。 - **按聊天隔离:** 每个聊天都会获得一个 `/workspace/chat_`(一对一 → 私有;群组 → 一个共享工作区);不同聊天彼此完全隔离并行运行,受 `MAX_CONCURRENT_CHAT_RUNS` 限制。在群组中,设置 `REQUIRE_GROUP_MENTION=true` 可让机器人仅在被 @提及或被回复时才响应。 -- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色只在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会访问镜像仓库,因此取不到新构建——它只是用该标签在这台主机本地已指向的镜像重启容器。要更新,请附带 `-e bot_image_pull=true` 重新运行;设置 `bot_image` 以固定某个标签。 +- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会去寻找更新的构建——它只是用该标签在这台主机本地已指向的镜像重启容器。要更新,请附带 `-e bot_image_pull=true` 重新运行,这会更新整个技术栈(bot、dind、Caddy)并重建容器;设置 `bot_image` 以固定某个标签。 ## 安全 From fa273b4cac2c20314849bd9746d83f6ef2ea61f8 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:01:07 +0000 Subject: [PATCH 19/26] test(deploy): check the opt-in switch in every shipped variable source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard read bot_image_pull only from the role defaults, which is the weakest source Ansible has. The example inventory's group_vars/all/vars.yml — the directory the README tells operators to copy — outranks it, so setting the switch to true there would restore the implicit update in every derived deployment while the default still read false and the suite stayed green. Walk the shipped deploy tree instead of listing files: group_vars, host_vars, a second inventory and a play's own vars: are all sources that outrank a default, and a fixed list is one forgotten file away from letting the switch back on. Both YAML and INI spellings count as an assignment; comments are stripped first so the update command quoted in the role's own documentation is not mistaken for one. Inventories other than the example are gitignored and stay the operator's business. --- adapters/telegram/deploy_pull_test.go | 122 ++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go index 3fdfc51..a23a5b5 100644 --- a/adapters/telegram/deploy_pull_test.go +++ b/adapters/telegram/deploy_pull_test.go @@ -8,7 +8,9 @@ package telegram_test // host it, and `task tests` is the only gate CI runs — a Go test is what gets executed. import ( + "io/fs" "os" + "path/filepath" "regexp" "strings" "testing" @@ -23,7 +25,11 @@ type deployManifest struct { ansible bool } -// Every file that decides whether a deploy may replace the running image. +// Every shipped file that can NAME a pull directive. It is not the whole surface that +// decides whether a deploy may replace the running image: the files that can flip the +// opt-in knob those directives are gated on are a moving set (group_vars, host_vars, a +// second inventory, a play's own `vars:`), so TestBotImagePullIsOptIn walks for them +// instead of listing them here. var deployManifests = []deployManifest{ {path: "docker-compose.yml"}, {path: "deploy/roles/claude_tg_bot/templates/docker-compose.yml.j2"}, @@ -445,25 +451,109 @@ func TestStripCommentKeepsDirectivesVisible(t *testing.T) { } } -// TestBotImagePullIsOptIn asserts the knob the pull task is gated on defaults to false. -// A default of true would make that `when:` always fire, which is an unconditional pull -// wearing a gate — exactly the behaviour the guard above exists to keep out. The guard +const ( + // deployRoot is the shipped deploy tree: the role plus the example inventory. + deployRoot = "deploy" + // exampleInventory is the only inventory this repo ships; .gitignore keeps every other + // inventories// out, and an operator's local copy is not this test's business. + exampleInventory = "example" + // botImagePullDefault is where the knob has to be defined, so the gate has a value to + // read when no inventory mentions it. + botImagePullDefault = "deploy/roles/claude_tg_bot/defaults/main.yml" +) + +// botImagePullSetting matches a shipped ASSIGNMENT of the opt-in knob, in either syntax +// Ansible reads one from: a YAML mapping key (role defaults, group_vars, host_vars, a +// play's `vars:`) or an INI entry in an inventory's [group:vars] section. Anchoring it at +// the start of the line is what keeps `-e bot_image_pull=true` inside a documented command +// line, and `when: bot_image_pull | bool` (whose key is `when`), from reading as one. +var botImagePullSetting = regexp.MustCompile(`^\s*bot_image_pull\s*[:=]\s*(\S+)`) + +// varSetting is one assignment of a variable in a shipped file. +type varSetting struct { + line int // 0-based index into the document's lines + value string +} + +// TestBotImagePullIsOptIn asserts that nothing this repo ships turns the update switch on. +// A true value would make the pull task's `when:` always fire, which is an unconditional +// pull wearing a gate — exactly the behaviour the guard above exists to keep out. The guard // checks that the gate NAMES this knob; this test is what makes naming it mean "off". +// +// It checks every shipped file, not just the role default, because a role default is the +// weakest source Ansible has: the example inventory's group_vars — the directory the README +// tells operators to copy — outranks it, so `bot_image_pull: true` there would put the +// implicit update back into every derived deployment while the default still reads false. func TestBotImagePullIsOptIn(t *testing.T) { - const path = "deploy/roles/claude_tg_bot/defaults/main.yml" - raw, err := os.ReadFile(path) + sources, err := shippedVarSources(deployRoot) if err != nil { - t.Fatalf("read %s: %v", path, err) + t.Fatalf("walk %s: %v", deployRoot, err) } - m := regexp.MustCompile(`(?m)^bot_image_pull:\s*(\S+)`).FindStringSubmatch(string(raw)) - if m == nil { - t.Fatalf("%s: no bot_image_pull default — the pull task's `when:` gate must have one", path) + defaulted := false + for _, path := range sources { + raw, err := os.ReadFile(path) + if err != nil { + // Keep going: one unreadable file must not hide a setting in the others. + t.Errorf("read %s: %v", path, err) + continue + } + for _, set := range botImagePullSettings(string(raw)) { + if filepath.ToSlash(path) == botImagePullDefault { + defaulted = true + } + if isFalseValue(set.value) { + continue + } + t.Errorf("%s:%d sets bot_image_pull to %q, want a false value — updating must stay "+ + "opt-in, otherwise every playbook run fetches images again", path, set.line+1, set.value) + } } - // Every spelling Ansible reads as false: the YAML 1.1 booleans in any case, plus 0, - // which `| bool` (how the task consumes it) also treats as false. - falsey := map[string]bool{"false": true, "no": true, "n": true, "off": true, "0": true} - if got := strings.Trim(m[1], `"'`); !falsey[strings.ToLower(got)] { - t.Errorf("%s: bot_image_pull defaults to %q, want a false value — updating must stay "+ - "opt-in, otherwise every playbook run pulls again", path, got) + if !defaulted { + t.Errorf("%s: no bot_image_pull default — the pull task's `when:` gate must have one", + botImagePullDefault) } } + +// shippedVarSources returns every tracked file under root that Ansible could read a +// variable from. It walks instead of listing: group_vars, host_vars, a second inventory and +// a play's own `vars:` all outrank a role default, so a fixed list would be one forgotten +// file away from letting the switch back on. Inventories other than the example are skipped +// — .gitignore keeps them out of the repo, so they are the operator's, not ours. +func shippedVarSources(root string) ([]string, error) { + inventories := filepath.Join(root, "inventories") + var files []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + files = append(files, path) + return nil + } + if filepath.Dir(path) == inventories && d.Name() != exampleInventory { + return fs.SkipDir + } + return nil + }) + return files, err +} + +// botImagePullSettings returns every assignment of the opt-in knob in a shipped file. +// Comments come off first, so the update command quoted in the role's own documentation is +// not read as an assignment. +func botImagePullSettings(content string) []varSetting { + var found []varSetting + for i, raw := range strings.Split(content, "\n") { + if m := botImagePullSetting.FindStringSubmatch(stripComment(raw)); m != nil { + found = append(found, varSetting{line: i, value: m[1]}) + } + } + return found +} + +// isFalseValue reports whether Ansible reads the value as false: the YAML 1.1 booleans in +// any case, plus 0, which `| bool` (how the task consumes the knob) also treats as false. +func isFalseValue(value string) bool { + falsey := map[string]bool{"false": true, "no": true, "n": true, "off": true, "0": true} + return falsey[strings.ToLower(strings.Trim(value, `"'`))] +} From 13825863b655cac6c554d4b0a29514823e9c1b8d Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:01:32 +0000 Subject: [PATCH 20/26] test(deploy): stop the pull guard firing on operator-facing text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two false positives, both reproducible against the role as it stands. A quote was tracked from anywhere in a line, so an apostrophe in an unquoted scalar opened a scalar that never closed and the trailing comment was never cut: a sentence saying the role does NOT run a pull failed the build. And a debug msg naming the manual command — the natural place to tell an operator how to update by hand — was read as a directive. A guard that reddens CI over prose is a guard people delete rather than fix, so both are worth closing. A quote now only opens a scalar where one can begin (line start, after whitespace or a flow punctuator), which leaves an apostrophe inside a word as the literal character YAML reads it as. Falling back to "cut at the first # once a quote is left open" would have been simpler but can cut inside a quoted string and hide a real directive, which is the more expensive mistake. Lines belonging to a debug task are skipped: debug executes nothing, so its msg is the role talking about a command, not running one. Attributing a line to its task needed the walk-back to step over list items that hold scalars — a msg list entry is a value, not a task. --- adapters/telegram/deploy_pull_test.go | 168 +++++++++++++++++++------- 1 file changed, 125 insertions(+), 43 deletions(-) diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go index a23a5b5..39a5ff5 100644 --- a/adapters/telegram/deploy_pull_test.go +++ b/adapters/telegram/deploy_pull_test.go @@ -63,6 +63,8 @@ var ( yamlKey = regexp.MustCompile(`^\s*([\w.-]+):(?:\s|$)`) // The opt-in knob a pull's own condition has to name (see defaults/main.yml). gateVariable = regexp.MustCompile(`\bbot_image_pull\b`) + // The module whose arguments are text for the operator rather than a command to run. + debugModule = regexp.MustCompile(`^(?:ansible\.builtin\.)?debug$`) ) // pullFinding is one line that fetches an image without the opt-in gate. @@ -73,13 +75,16 @@ type pullFinding struct { } // TestDeployNeverPullsImplicitly asserts that no shipped compose file and no Ansible task -// fetches an image on its own. Updating is the operator's decision — a run with -// `-e bot_image_pull=true`, or an explicit pull on the host — never a side effect of a -// playbook run made for something else, like a token rotation or a limit change. +// asks for a newer image on its own. Moving a deployment onto another build is the +// operator's decision — a run with `-e bot_image_pull=true`, or a pull on the host — rather +// than a by-product of a run made for something else, like a token rotation or a limit +// change. What it cannot promise is that a run never reaches the registry: compose's default +// policy still fetches a tag this host has no image for (see defaults/main.yml). // // What it checks, exactly: a pull is accepted only when it sits in an Ansible task whose // OWN `when:` (a key at the task's own indentation, not one inherited from an enclosing -// `block:`) names bot_image_pull, whose default TestBotImagePullIsOptIn pins to false. +// `block:`) names bot_image_pull, whose default TestBotImagePullIsOptIn pins to false — or +// in a debug task, which executes nothing and only prints a command for the operator. // What it does NOT check: that the condition is semantically equivalent to that knob — // `when: bot_image_pull | bool or true` names it and would pass. It catches an ungated // pull, and a pull gated on the wrong variable or on a constant; it is not an evaluator. @@ -116,8 +121,8 @@ func findUngatedPulls(content string, ansible bool) []pullFinding { if !re.MatchString(line) { continue } - if ansible && pullIsGated(lines, i) { - continue // the opt-in update path + if ansible && pullIsAllowed(lines, i) { + continue } found = append(found, pullFinding{line: i, re: re, text: strings.TrimSpace(line)}) } @@ -125,35 +130,52 @@ func findUngatedPulls(content string, ansible bool) []pullFinding { return found } -// pullIsGated reports whether the Ansible task holding line i is gated on the opt-in knob. -func pullIsGated(lines []string, i int) bool { - cond, ok := taskOwnCondition(lines, i) - return ok && gateVariable.MatchString(cond) +// pullIsAllowed reports whether a directive on line i is one the invariant permits. Exactly +// two shapes are: +// - the opt-in update path: the task DIRECTLY holding the line has its own `when:` naming +// bot_image_pull; +// - operator-facing text: the line belongs to a debug task, which executes nothing. Its +// msg is the role TALKING about a command — "to update by hand, run …" — and a guard +// that reddens CI over the sentence documenting the manual path is a guard people +// delete. The whole task is skipped rather than only its msg, which is the readable +// rule; the price is that a `vars:` on a debug task is unscanned too, so a command +// smuggled through a `lookup('pipe', …)` there would pass. This catches accidents, not +// evasion — as the guard's own docblock says, it is not an evaluator. +func pullIsAllowed(lines []string, i int) bool { + keys := taskOwnKeys(lines, i) + for key := range keys { + if debugModule.MatchString(key) { + return true + } + } + return gateVariable.MatchString(keys["when"]) } -// taskOwnCondition returns the `when:` expression of the task that DIRECTLY contains line -// i. "Directly" is what makes the answer independent of key order: the task is the nearest -// list item at or above line i, and only keys at that item's own indentation are its own — -// so a `when:` on an enclosing `block:` is not picked up whether it is written before or -// after the block, and the module arguments (or a nested block's tasks) below the key line -// cannot smuggle one in. A condition inherited from a block deliberately does not count: -// someone reading the task cannot see that it is gated, and the gate is the invariant. -func taskOwnCondition(lines []string, i int) (string, bool) { +// taskOwnKeys returns the keys of the task that DIRECTLY contains line i, each mapped to +// its value with any continuation lines appended. "Directly" is what makes the answer +// independent of key order: the task is the nearest list item at or above line i, and only +// keys at that item's own indentation are its own — so a `when:` on an enclosing `block:` is +// not picked up whether it is written before or after the block, and the module arguments +// (or a nested block's tasks) below the key line cannot smuggle one in. A condition +// inherited from a block deliberately does not count: someone reading the task cannot see +// that it is gated, and the gate is the invariant. Returns nil when line i is not inside a +// list item at all, so a caller reading a key off the result gets the empty string. +func taskOwnKeys(lines []string, i int) map[string]string { start := i - for start >= 0 && !yamlListItem.MatchString(lines[start]) { + for start >= 0 && !startsMapping(lines[start]) { start-- } if start < 0 { - return "", false + return nil } dash := len(yamlListItem.FindStringSubmatch(lines[start])[1]) keyIndent, ok := taskKeyIndent(lines, start, dash) if !ok { - return "", false + return nil } - var cond []string - inWhen := false + keys := map[string]string{} + current := "" for j := start; j < len(lines); j++ { line := stripComment(lines[j]) if j == start { @@ -171,30 +193,43 @@ func taskOwnCondition(lines []string, i int) (string, bool) { break // back out to the enclosing mapping: this task ended } if indent > keyIndent { - if inWhen { - cond = append(cond, strings.TrimSpace(line)) // a block scalar or a list value - } - continue // module arguments, or a nested block's tasks + // A block scalar, a list value, module arguments, or a nested block's tasks. + appendKeyValue(keys, current, line) + continue } key := yamlKey.FindStringSubmatch(line) if key == nil { - if inWhen { - cond = append(cond, strings.TrimSpace(line)) // a list value at key indentation - } - continue - } - if key[1] == "when" { - inWhen = true - _, value, _ := strings.Cut(line, ":") - cond = append(cond, strings.TrimSpace(value)) + appendKeyValue(keys, current, line) // a list value at key indentation continue } - inWhen = false + current = key[1] + _, value, _ := strings.Cut(line, ":") + appendKeyValue(keys, current, value) + } + return keys +} + +// startsMapping reports whether a line opens a list item that is a MAPPING — "- key:", or a +// bare "-" whose keys follow below. A task is always one; a list item holding a scalar +// ("- to update by hand: run …", an entry of a debug msg list) is a VALUE, and the task it +// belongs to is further up. Walking past those is what lets a directive named inside a msg +// list be attributed to the debug task printing it. +func startsMapping(line string) bool { + m := yamlListItem.FindStringSubmatch(line) + if m == nil { + return false } - if len(cond) == 0 { - return "", false + rest := strings.TrimLeft(stripComment(line)[len(m[1])+1:], " \t") + return rest == "" || yamlKey.MatchString(rest) +} + +// appendKeyValue adds a line to the value collected for key, ignoring text that belongs to +// no key yet. +func appendKeyValue(keys map[string]string, key, value string) { + if key == "" { + return } - return strings.Join(cond, " "), true + keys[key] = strings.TrimSpace(keys[key] + " " + strings.TrimSpace(value)) } // taskKeyIndent returns the column at which the keys of the list item on line start begin. @@ -214,9 +249,20 @@ func taskKeyIndent(lines []string, start, dash int) (int, bool) { return 0, false } +// quoteOpener is where a quoted scalar can START: the beginning of the line, or after +// whitespace or one of the flow punctuators. Anywhere else — inside a word — a quote is the +// literal character YAML reads it as, which is what makes "the operator's call" prose and +// not an unterminated scalar. +const quoteOpener = " \t[{,:" + // stripComment removes a YAML end-of-line comment: a "#" that starts the line or follows -// whitespace, and is not inside a quoted scalar. Quote tracking keeps a "#" that is part -// of a value (e.g. a colour or a fragment URL in quotes) from truncating a real directive. +// whitespace, and is not inside a quoted scalar. Quote tracking keeps a "#" that is part of +// a value (e.g. a colour or a fragment URL in quotes) from truncating a real directive, and +// the opener rule keeps an apostrophe in a word from opening a scalar that never closes — +// which would leave the comment on the line and fail the build over a sentence saying the +// directive is NOT used. Both mistakes are worth avoiding, but not symmetrically: cutting +// too early hides a real directive, so the rule stays "a quote only quotes where a scalar +// can begin" rather than "stop tracking quotes when one is left open". func stripComment(line string) string { var quote byte for i := 0; i < len(line); i++ { @@ -226,7 +272,7 @@ func stripComment(line string) string { if c == quote { quote = 0 } - case c == '\'' || c == '"': + case (c == '\'' || c == '"') && (i == 0 || strings.IndexByte(quoteOpener, line[i-1]) >= 0): quote = c case c == '#' && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t'): return line[:i] @@ -393,6 +439,34 @@ func TestPullGateAcceptsOnlyTheOptInTask(t *testing.T) { - name: Fetch the images ansible.builtin.command: cmd: docker compose pull +`, + accepted: false, + }, + { + name: "a debug task telling the operator how to update by hand", + doc: `--- +- name: Report status + ansible.builtin.debug: + msg: + - "to update by hand: docker compose pull && docker compose up -d" +`, + accepted: true, // debug runs nothing; this is the role TALKING about the command + }, + { + name: "the short module name is a debug task too", + doc: `--- +- name: Report status + debug: + msg: "update by hand with: docker compose pull" +`, + accepted: true, + }, + { + name: "a command task is not excused by quoting the same instruction", + doc: `--- +- name: Report status + ansible.builtin.command: + cmd: docker compose pull && docker compose up -d `, accepted: false, }, @@ -432,6 +506,10 @@ func TestStripCommentKeepsDirectivesVisible(t *testing.T) { hidden := []string{ `# No --pull always here: updating is opt-in`, ` # cmd: docker compose pull`, + // An apostrophe inside a word is the literal character YAML reads it as, not the + // start of a quoted scalar — treating it as one leaves the quote open to the end of + // the line, so the comment is never cut off and the guard fires on prose. + ` msg: Updating is the operator's call # not a docker compose pull`, } for _, line := range hidden { if matchesPullDirective(stripComment(line)) { @@ -443,6 +521,10 @@ func TestStripCommentKeepsDirectivesVisible(t *testing.T) { ` cmd: docker compose pull # opt-in update`, ` pull_policy: always # keep us current`, ` cmd: echo "nothing # here" && docker compose pull`, + // Cutting too early is the worse failure: it hides a real directive. A quoted "#" + // still has to be skipped on a line that also contains an apostrophe, so the fix + // for the case above cannot be "give up and cut at the first # after a space". + ` cmd: echo "it's # here" && docker compose pull`, } for _, line := range visible { if !matchesPullDirective(stripComment(line)) { From b1a746e7c5de167e2eaa648b51397833463df10a Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:01:51 +0000 Subject: [PATCH 21/26] docs(deploy): stop advertising a restart entry point the role lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler's comment offered `--tags restart` as a way to invoke it, but no task in the role carries a tag and nothing notifies the handler, so that command selects zero tasks and the handler cannot run by any route. Tagging the handler would not change that — a handler fires on a notify, not on a tag selection. Say what is actually true: the handler is unreachable today and kept for a task that comes to need it, while converging already restarts the stack idempotently. Same class of claim this branch removes elsewhere — a documented invocation that does not exist costs an operator a debugging session. --- .../deploy/roles/claude_tg_bot/handlers/main.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml index e3e2237..37ed375 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml @@ -1,7 +1,10 @@ --- -# Convenience handler (e.g. for `--tags restart`). The converge task -# "(Re)start the bot on its current image" already runs -# `docker compose up -d --force-recreate`, which is idempotent. +# Not reachable as the role stands, and no invocation restores it: no task notifies this +# handler and none carries a `restart` tag, so `--tags restart` would select no task at all, +# and tagging the handler would not help either — a handler runs only when notified. It is +# kept as the restart entry point for a task that comes to need one. Nothing is lost in the +# meantime: converging already restarts the stack, because the "(Re)start the bot on its +# current image" task runs `docker compose up -d --force-recreate`, which is idempotent. - name: Restart claude-tg-bot ansible.builtin.command: cmd: docker compose restart From 552d9af1784ab071c5e1cba1c36935ca3e8af08d Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:02:22 +0000 Subject: [PATCH 22/26] docs(deploy): separate the pinned-tag and floating-tag update paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two clauses pointed at different mechanisms but read as one instruction: pin a tag, then re-run with -e bot_image_pull=true to move forward. On a pinned tag that fetch is a no-op — the tag does not move, so nothing is downloaded and the running version stays put, while the run's own report says "nothing downloaded" and the operator concludes the switch is broken. Spell the routes out separately: a pinned tag moves by editing bot_image and running normally, because the new tag has no image on the host and the (re)start fetches it; the switch is for a floating tag, which has to be re-resolved against the registry. Also say what the switch does on a pinned bot_image, since it still refreshes the sidecars, whose tags float. --- .../deploy/roles/claude_tg_bot/defaults/main.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index 862c8c5..63905bc 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -25,8 +25,14 @@ legacy_volume_project: "" # - the host no longer having an image for the tag — the first install, or a `compose down` # that left it unreferenced until the weekly prune reclaimed it. Compose fetches what is # missing in order to start the stack, which resolves the tag against the registry again. -# Pin a tag (e.g. :v1) to make the version explicit in the inventory; to move a -# deployment forward, re-run with -e bot_image_pull=true (see below). +# Moving a deployment onto another version therefore has two routes, and which one applies +# depends on this tag — they are not interchangeable: +# - PINNED to an immutable tag (e.g. :v1.2.3, a digest): edit bot_image and run normally. +# The new tag has no image on the host, so the (re)start fetches it to have something to +# run. The opt-in switch is not the tool here: a pinned tag does not move, so a fetch +# would report that nothing was downloaded and the running version would not change. +# - FLOATING (:latest, :v1): the tag has to be re-resolved against the registry, which is +# what -e bot_image_pull=true does (see below). bot_image: "ghcr.io/duckbugio/flock-telegram:latest" # Update switch. false (default) = the run does not go looking for a newer image; the stack @@ -37,7 +43,9 @@ bot_image: "ghcr.io/duckbugio/flock-telegram:latest" # ansible-playbook -i inventories//inventory.ini playbook.yml -e bot_image_pull=true # The fetch covers the sidecars as well, not only {{ bot_image }}: their tags float within a # major too and dind runs privileged, so within the role this switch is their only update -# path — a privileged container has to have one. +# path — a privileged container has to have one. That is also what it still does when +# bot_image is pinned: the pinned tag resolves to the image already here and nothing is +# downloaded for the bot, while the floating sidecar tags are re-resolved. # It is a variable rather than an Ansible tag on purpose — a tag would be a second, # partly overlapping switch, and selecting only the pull task with --tags would skip the # (re)start that actually puts the new image into service. From 81b26b3ff67dc4c9a6d14cbc52b594749cf6d090 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:03:24 +0000 Subject: [PATCH 23/26] docs: name the case where an ordinary deploy run still fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8 READMEs were the only surface left stating "an ordinary re-run doesn't go looking for a newer build" without its exception, which the role defaults, the compose template and the prune unit all name: a host can be left with no copy of the tag — a first install, or a `compose down` whose image the weekly prune then reclaimed — and compose fetches it again to have something to start. Being the most operator-facing surface, that is the worst place for the unqualified version. The same sentence also listed the refreshed stack as "bot, dind, Caddy"; Caddy only exists when webhook_domain is set and dind when enable_dind is, so it now reads as the sidecars in use. All 8 languages carry both corrections. --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.de.md b/README.de.md index d3c985b..52d4154 100644 --- a/README.de.md +++ b/README.de.md @@ -107,7 +107,7 @@ Der **Poller** ist die empfohlene Methode, um auf Review-Kommentare zu reagieren - **Sprachnachrichten:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (oder `OPENAI_API_KEY`). Werden transkribiert und als Befehle ausgeführt. - **dind-Sidecar:** `docker compose --profile dind up -d` stellt dem Team dockerisierte Linter/Tests bereit (setze `DOCKER_HOST=tcp://dind:2375`). - **Isolation pro Chat:** Jeder Chat erhält `/workspace/chat_` (1:1 → privat; Gruppe → ein gemeinsamer Workspace); Chats sind vollständig isoliert und laufen parallel, begrenzt durch `MAX_CONCURRENT_CHAT_RUNS`. Setze in Gruppen `REQUIRE_GROUP_MENTION=true`, damit der Bot nur antwortet, wenn er per @mention erwähnt wird oder auf ihn geantwortet wird. -- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf sucht nicht nach einem neueren Build — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen — das aktualisiert den gesamten Stack (Bot, dind, Caddy) und erstellt die Container neu; setze `bot_image`, um einen Tag festzuhalten. +- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf sucht nicht nach einem neueren Build — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt, es sei denn, auf dem Host ist keine Kopie dieses Tags mehr vorhanden (Erstinstallation oder ein `compose down` plus der wöchentliche Prune) — dann holt Compose es erneut, um den Stack zu starten. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen — das aktualisiert den gesamten Stack (den Bot plus die aktivierten Sidecars — dind, Caddy) und erstellt die Container neu; setze `bot_image`, um einen Tag festzuhalten. ## Sicherheit diff --git a/README.es.md b/README.es.md index 14bd3ff..5ecf1d7 100644 --- a/README.es.md +++ b/README.es.md @@ -107,7 +107,7 @@ El **poller** es la forma recomendada de reaccionar a los comentarios de revisi - **Mensajes de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, más `MISTRAL_API_KEY` (o `OPENAI_API_KEY`). Se transcriben y se ejecutan como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` le da al equipo linters/pruebas dockerizados (establece `DOCKER_HOST=tcp://dind:2375`). - **Aislamiento por chat:** cada chat obtiene `/workspace/chat_` (1:1 → privado; grupo → un espacio de trabajo compartido); los chats están totalmente aislados y se ejecutan en paralelo, limitado por `MAX_CONCURRENT_CHAT_RUNS`. En los grupos, establece `REQUIRE_GROUP_MENTION=true` para responder solo cuando se te @menciona o se te responde. -- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no va a buscar una compilación más nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`, lo que actualiza toda la pila (bot, dind, Caddy) y recrea los contenedores; establece `bot_image` para fijar una etiqueta. +- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no va a buscar una compilación más nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host, salvo que en el host ya no quede una copia de esa etiqueta (primera instalación, o un `compose down` más la limpieza semanal): entonces Compose la descarga de nuevo para poder levantar la pila. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`, lo que actualiza toda la pila (el bot más los sidecars en uso — dind, Caddy) y recrea los contenedores; establece `bot_image` para fijar una etiqueta. ## Seguridad diff --git a/README.fr.md b/README.fr.md index 06fa01c..6c00924 100644 --- a/README.fr.md +++ b/README.fr.md @@ -107,7 +107,7 @@ Le **poller** est le moyen recommandé pour réagir aux commentaires de revue - **Messages vocaux :** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcrits et exécutés comme des commandes. - **Sidecar dind :** `docker compose --profile dind up -d` fournit à l'équipe des linters/tests dockerisés (définissez `DOCKER_HOST=tcp://dind:2375`). - **Isolation par conversation :** chaque conversation reçoit `/workspace/chat_` (1:1 → privé ; groupe → un espace de travail partagé) ; les conversations sont totalement isolées et s'exécutent en parallèle, plafonnées par `MAX_CONCURRENT_CHAT_RUNS`. Dans les groupes, définissez `REQUIRE_GROUP_MENTION=true` pour ne répondre que lorsque le bot est @mentionné ou cité en réponse. -- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne va pas chercher une build plus récente — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte. Pour mettre à jour, relancez avec `-e bot_image_pull=true`, ce qui met à jour toute la stack (bot, dind, Caddy) et recrée les conteneurs ; définissez `bot_image` pour épingler un tag. +- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne va pas chercher une build plus récente — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte, sauf s'il ne reste plus de copie de ce tag sur l'hôte (première installation, ou un `compose down` suivi du nettoyage hebdomadaire) : Compose la récupère alors de nouveau pour pouvoir démarrer la stack. Pour mettre à jour, relancez avec `-e bot_image_pull=true`, ce qui met à jour toute la stack (le bot plus les sidecars utilisés — dind, Caddy) et recrée les conteneurs ; définissez `bot_image` pour épingler un tag. ## Sécurité diff --git a/README.ja.md b/README.ja.md index 2bfc8a6..d8ff19c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **音声メッセージ:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`、加えて `MISTRAL_API_KEY`(または `OPENAI_API_KEY`)。文字起こしされてコマンドとして実行されます。 - **dind サイドカー:** `docker compose --profile dind up -d` で、Docker 化されたリンター/テストをチームに提供します(`DOCKER_HOST=tcp://dind:2375` を設定)。 - **チャットごとの隔離:** 各チャットには `/workspace/chat_` が割り当てられます(1:1 → プライベート、グループ → 1 つの共有ワークスペース)。チャットは完全に隔離され、`MAX_CONCURRENT_CHAT_RUNS` を上限として並列実行されます。グループでは `REQUIRE_GROUP_MENTION=true` を設定すると、@メンションまたは返信されたときのみ応答します。 -- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行は新しいビルドを探しにいかず、そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。更新するには `-e bot_image_pull=true` を付けて再実行してください(スタック全体 — ボット・dind・Caddy — が更新され、コンテナが再作成されます)。タグを固定するには `bot_image` を設定します。 +- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行は新しいビルドを探しにいかず、そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。ただし、そのタグのコピーがホストに残っていない場合(初回インストール、または `compose down` と週次プルーンの後)は、スタックを起動するために Compose が改めて取得します。更新するには `-e bot_image_pull=true` を付けて再実行してください(スタック全体 — ボットと有効なサイドカー(dind・Caddy)— が更新され、コンテナが再作成されます)。タグを固定するには `bot_image` を設定します。 ## セキュリティ diff --git a/README.md b/README.md index 86b59aa..5765af4 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The **poller** is the recommended way to react to review comments — it reaches - **Voice messages:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (or `OPENAI_API_KEY`). Transcribed and run as commands. - **dind sidecar:** `docker compose --profile dind up -d` gives the team dockerized linters/tests (set `DOCKER_HOST=tcp://dind:2375`). Ansible deploys enable dind by default and inject `DOCKER_HOST` automatically. - **Per-chat isolation:** each chat gets `/workspace/chat_` (1:1 → private; group → one shared workspace); chats are fully isolated and run in parallel, capped by `MAX_CONCURRENT_CHAT_RUNS`. In groups, set `REQUIRE_GROUP_MENTION=true` to respond only when @mentioned or replied to. -- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run doesn't go looking for a newer build — it restarts the container on the image that tag already points to on that host. To update, re-run with `-e bot_image_pull=true`, which refreshes the whole stack (bot, dind, Caddy) and recreates the containers; set `bot_image` to pin a tag. +- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run doesn't go looking for a newer build — it restarts the container on the image that tag already points to on that host, unless this host no longer has a copy of that tag (first install, or a `compose down` plus the weekly prune), where Compose fetches it again in order to start the stack. To update, re-run with `-e bot_image_pull=true`, which refreshes the whole stack (the bot plus the sidecars in use — dind, Caddy) and recreates the containers; set `bot_image` to pin a tag. ## Security diff --git a/README.pt-BR.md b/README.pt-BR.md index d5f52da..de9ce8a 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -107,7 +107,7 @@ O **poller** é a forma recomendada de reagir a comentários de revisão — ele - **Mensagens de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, mais `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcritas e executadas como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` dá ao time linters/testes dockerizados (defina `DOCKER_HOST=tcp://dind:2375`). - **Isolamento por chat:** cada chat recebe `/workspace/chat_` (1:1 → privado; grupo → um workspace compartilhado); os chats são totalmente isolados e rodam em paralelo, limitados por `MAX_CONCURRENT_CHAT_RUNS`. Em grupos, defina `REQUIRE_GROUP_MENTION=true` para responder apenas quando for @mencionado ou respondido. -- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não sai atrás de uma build mais nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host. Para atualizar, execute novamente com `-e bot_image_pull=true`, o que atualiza a pilha inteira (bot, dind, Caddy) e recria os contêineres; defina `bot_image` para fixar uma tag. +- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não sai atrás de uma build mais nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host, a menos que o host não tenha mais uma cópia dessa tag (primeira instalação, ou um `compose down` mais a limpeza semanal): aí o Compose a baixa de novo para conseguir subir a pilha. Para atualizar, execute novamente com `-e bot_image_pull=true`, o que atualiza a pilha inteira (o bot mais os sidecars em uso — dind, Caddy) e recria os contêineres; defina `bot_image` para fixar uma tag. ## Segurança diff --git a/README.ru.md b/README.ru.md index 6d4b4fa..23ad9a5 100644 --- a/README.ru.md +++ b/README.ru.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **Голосовые сообщения:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, плюс `MISTRAL_API_KEY` (или `OPENAI_API_KEY`). Транскрибируются и выполняются как команды. - **Сайдкар dind:** `docker compose --profile dind up -d` даёт команде линтеры/тесты в Docker (задайте `DOCKER_HOST=tcp://dind:2375`). - **Изоляция по чатам:** каждый чат получает `/workspace/chat_` (1:1 → приватное; группа → одно общее рабочее пространство); чаты полностью изолированы и выполняются параллельно с ограничением `MAX_CONCURRENT_CHAT_RUNS`. В группах задайте `REQUIRE_GROUP_MENTION=true`, чтобы отвечать только при @упоминании или в ответ на сообщение. -- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не ищет более новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте. Чтобы обновиться, запустите с `-e bot_image_pull=true` — это обновит весь стек (бот, dind, Caddy) и пересоздаст контейнеры; задайте `bot_image`, чтобы зафиксировать тег. +- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не ищет более новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте, если только на хосте не осталось копии этого тега (первая установка либо `compose down` плюс еженедельная очистка) — тогда Compose скачивает его снова, чтобы поднять стек. Чтобы обновиться, запустите с `-e bot_image_pull=true` — это обновит весь стек (бот и включённые сайдкары — dind, Caddy) и пересоздаст контейнеры; задайте `bot_image`, чтобы зафиксировать тег. ## Безопасность diff --git a/README.zh.md b/README.zh.md index ac511e4..2c1aca1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **语音消息:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`,外加 `MISTRAL_API_KEY`(或 `OPENAI_API_KEY`)。语音会被转写并作为命令运行。 - **dind 边车容器:** `docker compose --profile dind up -d` 为团队提供容器化的 linters/测试(设置 `DOCKER_HOST=tcp://dind:2375`)。 - **按聊天隔离:** 每个聊天都会获得一个 `/workspace/chat_`(一对一 → 私有;群组 → 一个共享工作区);不同聊天彼此完全隔离并行运行,受 `MAX_CONCURRENT_CHAT_RUNS` 限制。在群组中,设置 `REQUIRE_GROUP_MENTION=true` 可让机器人仅在被 @提及或被回复时才响应。 -- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会去寻找更新的构建——它只是用该标签在这台主机本地已指向的镜像重启容器。要更新,请附带 `-e bot_image_pull=true` 重新运行,这会更新整个技术栈(bot、dind、Caddy)并重建容器;设置 `bot_image` 以固定某个标签。 +- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会去寻找更新的构建——它只是用该标签在这台主机本地已指向的镜像重启容器;除非这台主机上已没有该标签的副本(首次安装,或 `compose down` 加上每周清理),此时 Compose 会重新拉取它以便启动整个技术栈。要更新,请附带 `-e bot_image_pull=true` 重新运行,这会更新整个技术栈(bot 以及已启用的 sidecar——dind、Caddy)并重建容器;设置 `bot_image` 以固定某个标签。 ## 安全 From b632b96ae14242cff0218374fd19ce7019d5ba9c Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:03:59 +0000 Subject: [PATCH 24/26] fix(deploy): keep an async fetch timeout reporting its own error A `poll:`-ed async job that runs past its 1800s budget comes back without stdout or stderr, so the changed_when added with this task raised "'dict object' has no attribute 'stderr'" and buried Ansible's own "did not complete within 1800s". An operator whose registry is slow got a Jinja error about a dict instead of the timeout that caused it. Defaulting both to an empty string leaves the normal path untouched and lets the real failure through. --- adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml index 1aadaa5..9245d57 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/tasks/main.yml @@ -157,8 +157,12 @@ register: image_fetch async: 1800 poll: 15 + # The defaults are for the timeout case: an async job that did not finish in time comes + # back without stdout/stderr, and reading them straight raises "'dict object' has no + # attribute 'stderr'", which replaces Ansible's own "did not complete within 1800s" with + # a puzzle. Present output is unaffected. changed_when: >- - 'Pull complete' in (image_fetch.stderr + image_fetch.stdout) + 'Pull complete' in ((image_fetch.stderr | default('')) + (image_fetch.stdout | default(''))) # No --pull here: this task does not go looking for a newer build, so a run made for a token # rotation or a limit change recreates the container on whatever the tag points to on THIS From 86fc150af0a16a4fc0cfee5c2540b20cd64e7f49 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:05:14 +0000 Subject: [PATCH 25/26] docs: say how a pinned deployment moves to a new version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The READMEs offered the opt-in switch as THE update path and mentioned pinning in the same breath, which leaves an operator on a pinned tag re-running with the switch and watching nothing happen — the tag has nothing newer to resolve to. The role defaults now separate the two routes; the 8 READMEs carry the same distinction so the operator-facing surface does not contradict them. --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.de.md b/README.de.md index 52d4154..e508aec 100644 --- a/README.de.md +++ b/README.de.md @@ -107,7 +107,7 @@ Der **Poller** ist die empfohlene Methode, um auf Review-Kommentare zu reagieren - **Sprachnachrichten:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (oder `OPENAI_API_KEY`). Werden transkribiert und als Befehle ausgeführt. - **dind-Sidecar:** `docker compose --profile dind up -d` stellt dem Team dockerisierte Linter/Tests bereit (setze `DOCKER_HOST=tcp://dind:2375`). - **Isolation pro Chat:** Jeder Chat erhält `/workspace/chat_` (1:1 → privat; Gruppe → ein gemeinsamer Workspace); Chats sind vollständig isoliert und laufen parallel, begrenzt durch `MAX_CONCURRENT_CHAT_RUNS`. Setze in Gruppen `REQUIRE_GROUP_MENTION=true`, damit der Bot nur antwortet, wenn er per @mention erwähnt wird oder auf ihn geantwortet wird. -- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf sucht nicht nach einem neueren Build — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt, es sei denn, auf dem Host ist keine Kopie dieses Tags mehr vorhanden (Erstinstallation oder ein `compose down` plus der wöchentliche Prune) — dann holt Compose es erneut, um den Stack zu starten. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen — das aktualisiert den gesamten Stack (den Bot plus die aktivierten Sidecars — dind, Caddy) und erstellt die Container neu; setze `bot_image`, um einen Tag festzuhalten. +- **Ansible-Deployment** (Telegram): Ein-Befehl-VPS-Provisionierung aus `adapters/telegram/deploy` — kopiere `inventories/example` in dein eigenes `inventories//` (gitignored), fülle Inventory/Vars/Vault aus, dann `ansible-playbook -i inventories//inventory.ini playbook.yml`. Die Rolle lädt das vorgefertigte Image beim ersten Lauf und behält es danach: Ein gewöhnlicher erneuter Lauf sucht nicht nach einem neueren Build — er startet den Container mit dem Image neu, auf das das Tag auf diesem Host bereits lokal zeigt, es sei denn, auf dem Host ist keine Kopie dieses Tags mehr vorhanden (Erstinstallation oder ein `compose down` plus der wöchentliche Prune) — dann holt Compose es erneut, um den Stack zu starten. Zum Aktualisieren mit `-e bot_image_pull=true` erneut ausführen — das aktualisiert den gesamten Stack (den Bot plus die aktivierten Sidecars — dind, Caddy) und erstellt die Container neu; setze `bot_image`, um einen Tag festzuhalten — ein festgehaltenes Deployment wechselt die Version dann durch Ändern dieses Werts und einen gewöhnlichen Lauf, nicht über den Schalter: Für einen festen Tag gibt es nichts neu aufzulösen. ## Sicherheit diff --git a/README.es.md b/README.es.md index 5ecf1d7..c419328 100644 --- a/README.es.md +++ b/README.es.md @@ -107,7 +107,7 @@ El **poller** es la forma recomendada de reaccionar a los comentarios de revisi - **Mensajes de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, más `MISTRAL_API_KEY` (o `OPENAI_API_KEY`). Se transcriben y se ejecutan como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` le da al equipo linters/pruebas dockerizados (establece `DOCKER_HOST=tcp://dind:2375`). - **Aislamiento por chat:** cada chat obtiene `/workspace/chat_` (1:1 → privado; grupo → un espacio de trabajo compartido); los chats están totalmente aislados y se ejecutan en paralelo, limitado por `MAX_CONCURRENT_CHAT_RUNS`. En los grupos, establece `REQUIRE_GROUP_MENTION=true` para responder solo cuando se te @menciona o se te responde. -- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no va a buscar una compilación más nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host, salvo que en el host ya no quede una copia de esa etiqueta (primera instalación, o un `compose down` más la limpieza semanal): entonces Compose la descarga de nuevo para poder levantar la pila. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`, lo que actualiza toda la pila (el bot más los sidecars en uso — dind, Caddy) y recrea los contenedores; establece `bot_image` para fijar una etiqueta. +- **Despliegue con Ansible** (Telegram): aprovisionamiento de un VPS con un solo comando desde `adapters/telegram/deploy` — copia `inventories/example` a tu propio `inventories//` (en gitignore), rellena el inventario/vars/vault, luego `ansible-playbook -i inventories//inventory.ini playbook.yml`. El rol descarga la imagen precompilada en la primera ejecución y luego la conserva: una reejecución normal no va a buscar una compilación más nueva — reinicia el contenedor con la imagen a la que esa etiqueta ya apunta localmente en ese host, salvo que en el host ya no quede una copia de esa etiqueta (primera instalación, o un `compose down` más la limpieza semanal): entonces Compose la descarga de nuevo para poder levantar la pila. Para actualizar, vuelve a ejecutarlo con `-e bot_image_pull=true`, lo que actualiza toda la pila (el bot más los sidecars en uso — dind, Caddy) y recrea los contenedores; establece `bot_image` para fijar una etiqueta — un despliegue fijado cambia de versión editando ese valor y reejecutando, no con el interruptor: una etiqueta fija no tiene nada más nuevo que resolver. ## Seguridad diff --git a/README.fr.md b/README.fr.md index 6c00924..3a5c836 100644 --- a/README.fr.md +++ b/README.fr.md @@ -107,7 +107,7 @@ Le **poller** est le moyen recommandé pour réagir aux commentaires de revue - **Messages vocaux :** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcrits et exécutés comme des commandes. - **Sidecar dind :** `docker compose --profile dind up -d` fournit à l'équipe des linters/tests dockerisés (définissez `DOCKER_HOST=tcp://dind:2375`). - **Isolation par conversation :** chaque conversation reçoit `/workspace/chat_` (1:1 → privé ; groupe → un espace de travail partagé) ; les conversations sont totalement isolées et s'exécutent en parallèle, plafonnées par `MAX_CONCURRENT_CHAT_RUNS`. Dans les groupes, définissez `REQUIRE_GROUP_MENTION=true` pour ne répondre que lorsque le bot est @mentionné ou cité en réponse. -- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne va pas chercher une build plus récente — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte, sauf s'il ne reste plus de copie de ce tag sur l'hôte (première installation, ou un `compose down` suivi du nettoyage hebdomadaire) : Compose la récupère alors de nouveau pour pouvoir démarrer la stack. Pour mettre à jour, relancez avec `-e bot_image_pull=true`, ce qui met à jour toute la stack (le bot plus les sidecars utilisés — dind, Caddy) et recrée les conteneurs ; définissez `bot_image` pour épingler un tag. +- **Déploiement Ansible** (Telegram) : provisionnement d'un VPS en une commande depuis `adapters/telegram/deploy` — copiez `inventories/example` vers votre propre `inventories//` (ignoré par git), remplissez inventory/vars/vault, puis `ansible-playbook -i inventories//inventory.ini playbook.yml`. Le rôle récupère l'image pré-construite lors de la première exécution puis la conserve : une réexécution ordinaire ne va pas chercher une build plus récente — elle redémarre le conteneur sur l'image vers laquelle ce tag pointe déjà localement sur cet hôte, sauf s'il ne reste plus de copie de ce tag sur l'hôte (première installation, ou un `compose down` suivi du nettoyage hebdomadaire) : Compose la récupère alors de nouveau pour pouvoir démarrer la stack. Pour mettre à jour, relancez avec `-e bot_image_pull=true`, ce qui met à jour toute la stack (le bot plus les sidecars utilisés — dind, Caddy) et recrée les conteneurs ; définissez `bot_image` pour épingler un tag — un déploiement épinglé change alors de version en modifiant cette valeur puis en relançant normalement, pas via l'option : un tag épinglé n'a rien de plus récent à résoudre. ## Sécurité diff --git a/README.ja.md b/README.ja.md index d8ff19c..8534b5b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **音声メッセージ:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`、加えて `MISTRAL_API_KEY`(または `OPENAI_API_KEY`)。文字起こしされてコマンドとして実行されます。 - **dind サイドカー:** `docker compose --profile dind up -d` で、Docker 化されたリンター/テストをチームに提供します(`DOCKER_HOST=tcp://dind:2375` を設定)。 - **チャットごとの隔離:** 各チャットには `/workspace/chat_` が割り当てられます(1:1 → プライベート、グループ → 1 つの共有ワークスペース)。チャットは完全に隔離され、`MAX_CONCURRENT_CHAT_RUNS` を上限として並列実行されます。グループでは `REQUIRE_GROUP_MENTION=true` を設定すると、@メンションまたは返信されたときのみ応答します。 -- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行は新しいビルドを探しにいかず、そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。ただし、そのタグのコピーがホストに残っていない場合(初回インストール、または `compose down` と週次プルーンの後)は、スタックを起動するために Compose が改めて取得します。更新するには `-e bot_image_pull=true` を付けて再実行してください(スタック全体 — ボットと有効なサイドカー(dind・Caddy)— が更新され、コンテナが再作成されます)。タグを固定するには `bot_image` を設定します。 +- **Ansible デプロイ**(Telegram): `adapters/telegram/deploy` からワンコマンドで VPS をプロビジョニングします。`inventories/example` を自分用の `inventories//`(gitignore 対象)にコピーし、inventory/vars/vault を埋めてから `ansible-playbook -i inventories//inventory.ini playbook.yml` を実行します。ロールは初回実行でビルド済みイメージを取得し、その後はそのイメージを保持します。通常の再実行は新しいビルドを探しにいかず、そのタグがこのホスト上でローカルに指しているイメージでコンテナを再起動するだけです。ただし、そのタグのコピーがホストに残っていない場合(初回インストール、または `compose down` と週次プルーンの後)は、スタックを起動するために Compose が改めて取得します。更新するには `-e bot_image_pull=true` を付けて再実行してください(スタック全体 — ボットと有効なサイドカー(dind・Caddy)— が更新され、コンテナが再作成されます)。タグを固定するには `bot_image` を設定します。固定した場合、バージョンの移行はこのスイッチではなく `bot_image` の値を書き換えて通常どおり実行することで行います(固定タグには解決し直す新しい先がないためです)。 ## セキュリティ diff --git a/README.md b/README.md index 5765af4..a0907fa 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The **poller** is the recommended way to react to review comments — it reaches - **Voice messages:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, plus `MISTRAL_API_KEY` (or `OPENAI_API_KEY`). Transcribed and run as commands. - **dind sidecar:** `docker compose --profile dind up -d` gives the team dockerized linters/tests (set `DOCKER_HOST=tcp://dind:2375`). Ansible deploys enable dind by default and inject `DOCKER_HOST` automatically. - **Per-chat isolation:** each chat gets `/workspace/chat_` (1:1 → private; group → one shared workspace); chats are fully isolated and run in parallel, capped by `MAX_CONCURRENT_CHAT_RUNS`. In groups, set `REQUIRE_GROUP_MENTION=true` to respond only when @mentioned or replied to. -- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run doesn't go looking for a newer build — it restarts the container on the image that tag already points to on that host, unless this host no longer has a copy of that tag (first install, or a `compose down` plus the weekly prune), where Compose fetches it again in order to start the stack. To update, re-run with `-e bot_image_pull=true`, which refreshes the whole stack (the bot plus the sidecars in use — dind, Caddy) and recreates the containers; set `bot_image` to pin a tag. +- **Ansible deploy** (Telegram): one-command VPS provision from `adapters/telegram/deploy` — copy `inventories/example` to your own `inventories//` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories//inventory.ini playbook.yml`. The role installs the prebuilt image on the first run and then keeps it: an ordinary re-run doesn't go looking for a newer build — it restarts the container on the image that tag already points to on that host, unless this host no longer has a copy of that tag (first install, or a `compose down` plus the weekly prune), where Compose fetches it again in order to start the stack. To update, re-run with `-e bot_image_pull=true`, which refreshes the whole stack (the bot plus the sidecars in use — dind, Caddy) and recreates the containers; set `bot_image` to pin a tag — a pinned deployment then moves by editing that value and re-running, not by the switch, since a pinned tag has nothing newer to resolve to. ## Security diff --git a/README.pt-BR.md b/README.pt-BR.md index de9ce8a..60d13f0 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -107,7 +107,7 @@ O **poller** é a forma recomendada de reagir a comentários de revisão — ele - **Mensagens de voz:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, mais `MISTRAL_API_KEY` (ou `OPENAI_API_KEY`). Transcritas e executadas como comandos. - **Sidecar dind:** `docker compose --profile dind up -d` dá ao time linters/testes dockerizados (defina `DOCKER_HOST=tcp://dind:2375`). - **Isolamento por chat:** cada chat recebe `/workspace/chat_` (1:1 → privado; grupo → um workspace compartilhado); os chats são totalmente isolados e rodam em paralelo, limitados por `MAX_CONCURRENT_CHAT_RUNS`. Em grupos, defina `REQUIRE_GROUP_MENTION=true` para responder apenas quando for @mencionado ou respondido. -- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não sai atrás de uma build mais nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host, a menos que o host não tenha mais uma cópia dessa tag (primeira instalação, ou um `compose down` mais a limpeza semanal): aí o Compose a baixa de novo para conseguir subir a pilha. Para atualizar, execute novamente com `-e bot_image_pull=true`, o que atualiza a pilha inteira (o bot mais os sidecars em uso — dind, Caddy) e recria os contêineres; defina `bot_image` para fixar uma tag. +- **Deploy com Ansible** (Telegram): provisionamento de um VPS em um comando a partir de `adapters/telegram/deploy` — copie `inventories/example` para o seu próprio `inventories//` (no gitignore), preencha inventory/vars/vault e então `ansible-playbook -i inventories//inventory.ini playbook.yml`. O role baixa a imagem pré-construída na primeira execução e depois a mantém: uma reexecução comum não sai atrás de uma build mais nova — ela reinicia o contêiner na imagem para a qual essa tag já aponta localmente naquele host, a menos que o host não tenha mais uma cópia dessa tag (primeira instalação, ou um `compose down` mais a limpeza semanal): aí o Compose a baixa de novo para conseguir subir a pilha. Para atualizar, execute novamente com `-e bot_image_pull=true`, o que atualiza a pilha inteira (o bot mais os sidecars em uso — dind, Caddy) e recria os contêineres; defina `bot_image` para fixar uma tag — um deploy fixado passa a mudar de versão editando esse valor e reexecutando, não pelo interruptor: uma tag fixa não tem nada mais novo a resolver. ## Segurança diff --git a/README.ru.md b/README.ru.md index 23ad9a5..5c44b46 100644 --- a/README.ru.md +++ b/README.ru.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **Голосовые сообщения:** `ENABLE_VOICE_MESSAGES=true`, `VOICE_PROVIDER=mistral|openai|local`, плюс `MISTRAL_API_KEY` (или `OPENAI_API_KEY`). Транскрибируются и выполняются как команды. - **Сайдкар dind:** `docker compose --profile dind up -d` даёт команде линтеры/тесты в Docker (задайте `DOCKER_HOST=tcp://dind:2375`). - **Изоляция по чатам:** каждый чат получает `/workspace/chat_` (1:1 → приватное; группа → одно общее рабочее пространство); чаты полностью изолированы и выполняются параллельно с ограничением `MAX_CONCURRENT_CHAT_RUNS`. В группах задайте `REQUIRE_GROUP_MENTION=true`, чтобы отвечать только при @упоминании или в ответ на сообщение. -- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не ищет более новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте, если только на хосте не осталось копии этого тега (первая установка либо `compose down` плюс еженедельная очистка) — тогда Compose скачивает его снова, чтобы поднять стек. Чтобы обновиться, запустите с `-e bot_image_pull=true` — это обновит весь стек (бот и включённые сайдкары — dind, Caddy) и пересоздаст контейнеры; задайте `bot_image`, чтобы зафиксировать тег. +- **Развёртывание через Ansible** (Telegram): провижининг VPS одной командой из `adapters/telegram/deploy` — скопируйте `inventories/example` в собственный `inventories//` (в gitignore), заполните inventory/vars/vault, затем `ansible-playbook -i inventories//inventory.ini playbook.yml`. Роль скачивает готовый образ при первом запуске и дальше сохраняет его: обычный повторный прогон не ищет более новую сборку — он перезапускает контейнер на том образе, на который тег уже указывает локально на этом хосте, если только на хосте не осталось копии этого тега (первая установка либо `compose down` плюс еженедельная очистка) — тогда Compose скачивает его снова, чтобы поднять стек. Чтобы обновиться, запустите с `-e bot_image_pull=true` — это обновит весь стек (бот и включённые сайдкары — dind, Caddy) и пересоздаст контейнеры; задайте `bot_image`, чтобы зафиксировать тег, — тогда развёртывание переезжает на новую версию правкой этого значения и обычным прогоном, а не переключателем: у зафиксированного тега нечего перерезолвить. ## Безопасность diff --git a/README.zh.md b/README.zh.md index 2c1aca1..849feaa 100644 --- a/README.zh.md +++ b/README.zh.md @@ -107,7 +107,7 @@ GITEA_POLL_INTERVAL=90 - **语音消息:** `ENABLE_VOICE_MESSAGES=true`、`VOICE_PROVIDER=mistral|openai|local`,外加 `MISTRAL_API_KEY`(或 `OPENAI_API_KEY`)。语音会被转写并作为命令运行。 - **dind 边车容器:** `docker compose --profile dind up -d` 为团队提供容器化的 linters/测试(设置 `DOCKER_HOST=tcp://dind:2375`)。 - **按聊天隔离:** 每个聊天都会获得一个 `/workspace/chat_`(一对一 → 私有;群组 → 一个共享工作区);不同聊天彼此完全隔离并行运行,受 `MAX_CONCURRENT_CHAT_RUNS` 限制。在群组中,设置 `REQUIRE_GROUP_MENTION=true` 可让机器人仅在被 @提及或被回复时才响应。 -- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会去寻找更新的构建——它只是用该标签在这台主机本地已指向的镜像重启容器;除非这台主机上已没有该标签的副本(首次安装,或 `compose down` 加上每周清理),此时 Compose 会重新拉取它以便启动整个技术栈。要更新,请附带 `-e bot_image_pull=true` 重新运行,这会更新整个技术栈(bot 以及已启用的 sidecar——dind、Caddy)并重建容器;设置 `bot_image` 以固定某个标签。 +- **Ansible 部署**(Telegram):从 `adapters/telegram/deploy` 一条命令配置一台 VPS——将 `inventories/example` 复制为你自己的 `inventories//`(已 gitignore),填好 inventory/vars/vault,然后执行 `ansible-playbook -i inventories//inventory.ini playbook.yml`。该角色在首次运行时拉取预构建镜像,之后一直沿用它:普通的重复运行不会去寻找更新的构建——它只是用该标签在这台主机本地已指向的镜像重启容器;除非这台主机上已没有该标签的副本(首次安装,或 `compose down` 加上每周清理),此时 Compose 会重新拉取它以便启动整个技术栈。要更新,请附带 `-e bot_image_pull=true` 重新运行,这会更新整个技术栈(bot 以及已启用的 sidecar——dind、Caddy)并重建容器;设置 `bot_image` 可固定某个标签——固定之后,换版本要靠修改这个值并正常运行,而不是靠这个开关:固定标签没有更新的目标可供重新解析。 ## 安全 From d60d6ce2c542c22ddb5524e6c50b8d6cbcbd2f76 Mon Sep 17 00:00:00 2001 From: Konstantin Zaytcev Date: Mon, 27 Jul 2026 23:08:41 +0000 Subject: [PATCH 26/26] test(deploy): walk the deploy tree through an fs.FS Reading walked paths with os.ReadFile trips gosec G304 (file inclusion via a variable) and fails the lint gate. Rooting the walk at an fs.FS reads through fs.ReadFile instead, which is not a filesystem-wide open, and confines the scan to the deploy tree by construction rather than by convention. Paths in the failure messages are unchanged. --- adapters/telegram/deploy_pull_test.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go index 39a5ff5..8779920 100644 --- a/adapters/telegram/deploy_pull_test.go +++ b/adapters/telegram/deploy_pull_test.go @@ -10,7 +10,7 @@ package telegram_test import ( "io/fs" "os" - "path/filepath" + "path" "regexp" "strings" "testing" @@ -567,27 +567,29 @@ type varSetting struct { // tells operators to copy — outranks it, so `bot_image_pull: true` there would put the // implicit update back into every derived deployment while the default still reads false. func TestBotImagePullIsOptIn(t *testing.T) { - sources, err := shippedVarSources(deployRoot) + deploy := os.DirFS(deployRoot) + sources, err := shippedVarSources(deploy) if err != nil { t.Fatalf("walk %s: %v", deployRoot, err) } defaulted := false - for _, path := range sources { - raw, err := os.ReadFile(path) + for _, rel := range sources { + name := path.Join(deployRoot, rel) + raw, err := fs.ReadFile(deploy, rel) if err != nil { // Keep going: one unreadable file must not hide a setting in the others. - t.Errorf("read %s: %v", path, err) + t.Errorf("read %s: %v", name, err) continue } for _, set := range botImagePullSettings(string(raw)) { - if filepath.ToSlash(path) == botImagePullDefault { + if name == botImagePullDefault { defaulted = true } if isFalseValue(set.value) { continue } t.Errorf("%s:%d sets bot_image_pull to %q, want a false value — updating must stay "+ - "opt-in, otherwise every playbook run fetches images again", path, set.line+1, set.value) + "opt-in, otherwise every playbook run fetches images again", name, set.line+1, set.value) } } if !defaulted { @@ -596,23 +598,22 @@ func TestBotImagePullIsOptIn(t *testing.T) { } } -// shippedVarSources returns every tracked file under root that Ansible could read a +// shippedVarSources returns every tracked file in the deploy tree that Ansible could read a // variable from. It walks instead of listing: group_vars, host_vars, a second inventory and // a play's own `vars:` all outrank a role default, so a fixed list would be one forgotten // file away from letting the switch back on. Inventories other than the example are skipped // — .gitignore keeps them out of the repo, so they are the operator's, not ours. -func shippedVarSources(root string) ([]string, error) { - inventories := filepath.Join(root, "inventories") +func shippedVarSources(deploy fs.FS) ([]string, error) { var files []string - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + err := fs.WalkDir(deploy, ".", func(name string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { - files = append(files, path) + files = append(files, name) return nil } - if filepath.Dir(path) == inventories && d.Name() != exampleInventory { + if path.Dir(name) == "inventories" && d.Name() != exampleInventory { return fs.SkipDir } return nil