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 f9080c5..c2114e1 100644 --- a/.github/workflows/publish-telegram.yml +++ b/.github/workflows/publish-telegram.yml @@ -8,8 +8,11 @@ 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. 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/**' @@ -18,6 +21,7 @@ on: - 'go.sum' - 'adapters/telegram/**' - '!adapters/telegram/deploy/**' + - '!**/*_test.go' - '.dockerignore' - '.github/workflows/publish-telegram.yml' workflow_dispatch: @@ -48,15 +52,24 @@ 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; 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 + # 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}},priority=250 type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=0.1.${{ github.run_number }},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..0c537e2 100644 --- a/.github/workflows/publish-vk.yml +++ b/.github/workflows/publish-vk.yml @@ -8,8 +8,11 @@ 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. 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/**' @@ -18,6 +21,7 @@ on: - 'go.sum' - 'adapters/vk/**' - '!adapters/vk/deploy/**' + - '!**/*_test.go' - '.dockerignore' - '.github/workflows/publish-vk.yml' workflow_dispatch: @@ -48,15 +52,24 @@ 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; 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 + # 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}},priority=250 type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=0.1.${{ github.run_number }},enable={{is_default_branch}} type=ref,event=tag type=sha,format=short diff --git a/README.de.md b/README.de.md index 1db73a6..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 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 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 9dd354e..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; 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 d5da514..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 ; 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 bcfdeed..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` を実行します。ロールはビルド済みイメージを取得します。タグを固定するには `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 63c1243..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 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 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 371ab19..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; 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 57065ea..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`. Роль подтягивает готовый образ; задайте `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 ab504c7..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`。该角色会拉取预构建镜像;设置 `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` 可固定某个标签——固定之后,换版本要靠修改这个值并正常运行,而不是靠这个开关:固定标签没有更新的目标可供重新解析。 ## 安全 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..63905bc 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,42 @@ 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 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. +# 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 +# (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 within the role this switch is their only update +# 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. +bot_image_pull: false + # Node major version for the bundled `claude` CLI node_major: 22 @@ -103,6 +136,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 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 @@ -112,12 +147,20 @@ 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 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" -# 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. 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/handlers/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/handlers/main.yml index 78e82e9..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,6 +1,10 @@ --- -# Convenience handler (e.g. for `--tags restart`). The main converge task -# already runs `docker compose up -d --build`, 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 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..9245d57 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,45 @@ mode: "0600" no_log: true -- name: Pull image and (re)start the bot +# The update path, off unless the operator asks for it with -e bot_image_pull=true. +# 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 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 up -d --pull always --force-recreate + cmd: docker compose pull + chdir: "{{ app_dir }}" + when: bot_image_pull | bool + 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 | 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 +# 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 chdir: "{{ app_dir }}" async: 1800 poll: 15 @@ -158,5 +194,19 @@ until: bot_state.stdout == "running" - name: Report status + vars: + # 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) — no update fetch ran' + if ((image_fetch is not defined) or (image_fetch.skipped | default(false))) + 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 }} — 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" 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..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,7 +2,14 @@ services: bot: image: {{ bot_image }} - pull_policy: always + # 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 adcfbd1..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 @@ -1,6 +1,11 @@ # 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. +# 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 diff --git a/adapters/telegram/deploy_pull_test.go b/adapters/telegram/deploy_pull_test.go new file mode 100644 index 0000000..8779920 --- /dev/null +++ b/adapters/telegram/deploy_pull_test.go @@ -0,0 +1,642 @@ +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 ( + "io/fs" + "os" + "path" + "regexp" + "strings" + "testing" +) + +// 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 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 +} + +// 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"}, + {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`), + // 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|$)`) + 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. +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 +// 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 — 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. +func TestDeployNeverPullsImplicitly(t *testing.T) { + for _, m := range deployManifests { + raw, err := os.ReadFile(m.path) + if err != nil { + // Keep going: one unreadable manifest must not hide a pull in the others. + t.Errorf("read %s: %v", m.path, err) + continue + } + 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 + } + if ansible && pullIsAllowed(lines, i) { + continue + } + found = append(found, pullFinding{line: i, re: re, text: strings.TrimSpace(line)}) + } + } + return found +} + +// 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"]) +} + +// 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 && !startsMapping(lines[start]) { + start-- + } + if start < 0 { + return nil + } + dash := len(yamlListItem.FindStringSubmatch(lines[start])[1]) + keyIndent, ok := taskKeyIndent(lines, start, dash) + if !ok { + return nil + } + + keys := map[string]string{} + current := "" + 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 { + // 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 { + appendKeyValue(keys, current, line) // a list value at key indentation + continue + } + 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 + } + 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 + } + 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. +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 strings.TrimSpace(lines[j]) == "" { + continue + } + indent := len(lines[j]) - len(strings.TrimLeft(lines[j], " \t")) + return indent, indent > dash + } + 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, 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++ { + c := line[i] + switch { + case quote != 0: + if c == quote { + quote = 0 + } + 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] + } + } + return line +} + +// 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"`, + ` pull: always`, + ` pull: "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:`, + ` community.docker.docker_compose_v2:`, + ` docker_compose:`, + } + 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`, + ` when: bot_image_pull | bool`, + ` pull_policy: missing`, + ` pull: never`, + } + 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 +} + +// 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: "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, + }, + { + 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`, + // 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)) { + 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`, + // 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)) { + t.Errorf("a trailing comment hides a directive: %s", line) + } + } +} + +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) { + deploy := os.DirFS(deployRoot) + sources, err := shippedVarSources(deploy) + if err != nil { + t.Fatalf("walk %s: %v", deployRoot, err) + } + defaulted := false + 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", name, err) + continue + } + for _, set := range botImagePullSettings(string(raw)) { + 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", name, set.line+1, set.value) + } + } + 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 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(deploy fs.FS) ([]string, error) { + var files []string + err := fs.WalkDir(deploy, ".", func(name string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + files = append(files, name) + return nil + } + if path.Dir(name) == "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, `"'`))] +}