Skip to content

fix(deploy): stop updating the bot as a side effect of any playbook run - #65

Open
zaytcevcom wants to merge 26 commits into
mainfrom
duck/-5362116338/no-implicit-image-updates
Open

fix(deploy): stop updating the bot as a side effect of any playbook run#65
zaytcevcom wants to merge 26 commits into
mainfrom
duck/-5362116338/no-implicit-image-updates

Conversation

@zaytcevcom

Copy link
Copy Markdown
Contributor

Summary

An Ansible deploy updated the bot as a side effect of any playbook run. Re-running the
playbook to rotate a token, change webhook_domain, toggle enable_dind or adjust a rate limit
silently swallowed everything merged into main since the last converge — and the operator got no
signal that the running version had changed.

Two independent mechanisms caused it, and both had to go:

  • docker compose up -d --pull always --force-recreate in the role's converge task;
  • pull_policy: always in the rendered docker-compose.yml — which would have kept the behaviour
    alive on any up, including a manual one on the host and the --tags restart handler.

After this change a deploy behaves like an ordinary container deployment: it starts the stack on the
image the host already has and does not go looking for a newer build. Moving to a new version is an
explicit act.

A host reboot was already safe (docker restart does not re-resolve a tag) — that was verified, not
assumed; the defect was specific to compose up.

Related issue

None.

Type of change

  • Bug fix
  • New feature
  • Refactor / internal change
  • Documentation
  • Build / CI / dependencies

What changed, per acceptance criterion

  • AC1 — no implicit pull remains in the deploy surface. --pull always and
    pull_policy: always are gone. A guard test fails if either returns, in any spelling
    (--pull=always, argv form, pull: always, docker pull, docker compose pull,
    community.docker.docker_compose_v2), across tasks/main.yml, handlers/main.yml,
    playbook.yml, the shipped compose file and the template.
  • AC2 — an explicit update path exists. New role knob bot_image_pull (default false)
    gates a docker compose pull task placed before the (Re)start, so a fetched image is
    actually put into service:
    ansible-playbook … -e bot_image_pull=true. The guard accepts a pull only when the pull
    task's own when: names that knob — a condition inherited from an enclosing block: does not
    count, and the knob's default is pinned to a false value.
  • AC3 — a fresh install still works. No pull_policy override and no build: section, so
    Compose's default missing policy fetches an absent image on the first up. Verified on live
    Docker, not inferred.
  • AC4 — the installed version is machine-readable. org.opencontainers.image.version was
    the literal string latest, because docker/metadata-action takes the version from the
    highest-priority tag and type=raw,value=latest won by default. The immutable
    0.1.<run_number> now carries priority=250 — above latest (200), clear of the tie with
    sha (100), and still below a vX.Y.Z tag ref (600). The set of published tags is unchanged;
    :latest is still published.
  • AC5 — the docs describe what the code actually does. All 8 READMEs, the role defaults,
    both templates and both workflows were corrected and cross-checked against each other.

How it was verified

task lint            # 0 issues
task tests           # go test -race ./... — all 32 packages ok
task build           # ok
task security-scan   # govulncheck: No vulnerabilities found

Beyond the gate, the load-bearing claims were checked against reality rather than from memory:

  • The bug and the fix, on live Docker. A container was started on a tag, the tag was moved, and
    up -d --force-recreate re-landed it on the new image — reproducing the defect; docker restart
    did not, which is why reboots were never the problem.
  • docker image prune --filter until= filters by image creation time, not pull time
    (docs.docker.com). The role's comment claimed the opposite, which mattered once the local store
    became the source of truth. Corrected.
  • docker/metadata-action@v6 priorities read from the action's own README and sources.
  • changed_when for the fetch report was chosen after capturing real non-TTY
    docker compose pull output: an up-to-date run prints Pulling/Pulled per service but zero
    Pull complete, so presence of output proves nothing. The flag keys on Pull complete, and was
    rendered through real Ansible (core 2.21) in four states — never ran / skipped / ran-current /
    ran-downloaded — including with async+poll.
  • The guard test was mutation-tested in both directions (16 cases), including a direct
    old-vs-new comparison proving it no longer flips verdict on YAML key order.
  • Ansible/YAML was parsed with yaml.safe_load and the report expression rendered through
    Jinja, since CI has no ansible-lint or yamllint.

Pre-PR review

Three adversarial rounds (round 1 with two independent critics: operational risk, and CI/consistency).
No round found a blocker. Rounds 1 and 2 returned CHANGES_NEEDED; round 3 was clean and an
arbiter verified the result independently, including its own guard-test mutations. What the critics
caught and fixed:

  • The first cut removed auto-update but left no way to update at all. A playbook run became a
    silent no-op: the operator saw changed / bot is running and stayed on the old image with zero
    feedback. That is what the bot_image_pull opt-in exists for.
  • The guard test gave false confidence twice. It first missed the most likely regression (a
    plain docker compose pull task), then — after widening — returned opposite verdicts for
    semantically identical YAML depending on whether when: sat before or after block:, and
    accepted when: true. Now it checks the gate structurally, at the task's own key indentation.
  • Absolute claims in the docs that the code did not deliver, three rounds running: first
    ":latest tracks main", then "an ordinary re-run never changes the running version", then "never
    contacts the registry". Each had a reachable counterexample. Every operator-facing statement now
    names its exceptions instead.
  • The opt-in initially fetched only the bot, which would have left the privileged dind
    sidecar on a floating tag with no update path at all — it used to be refreshed by the
    --pull always this PR removes. The switch now refreshes the whole stack.

Residual risks — what needs a human

  1. No playbook was ever executed — not on a host, not even --check. The when: gate,
    register/skipped handling, changed_when and the async+poll pairing are verified by
    reading, by static guard and by rendering — not by running. CI has no ansible-lint or yamllint,
    so nothing catches Ansible-level mistakes. Worth one ansible-playbook --syntax-check.
  2. One implicit-update path remains open, by design-decision-not-yet-made. compose down
    the weekly prune reclaims the now-unreferenced image → the next ordinary run re-resolves the
    floating :latest and lands on a newer build. It is documented in three places but only
    closed by pinning bot_image to an immutable tag (:0.1.<n> or :sha-…). That default is
    your call — this PR deliberately leaves bot_image on :latest.
  3. Rollback got more expensive. Prune filters by creation time, so the image you moved off is
    usually gone by the next run; rolling back means pulling again — and :latest has moved on.
  4. The privileged dind no longer patches itself. That is the intended consequence of "nothing
    updates automatically", and the opt-in deliberately refreshes the whole stack rather than the bot
    alone — but the cadence for security updates is now a human responsibility.
  5. Watch the first publish after merge: that org.opencontainers.image.version is
    0.1.<run_number> rather than latest, and that the new !**/*_test.go path filter did not
    skip a build that should have run. GitHub's path-filter semantics were taken from documentation,
    not observed live.
  6. Pre-existing, not touched: publish-*.yml combines tags: ["v*"] with paths:. A tag
    pushed to an existing commit generally yields no changed files, so that release path may never
    have triggered. Flagged rather than fixed — it needs a live check.
  7. changed_when parses human-readable Compose output (Pull complete), so it is version- and
    locale-sensitive, and misses a tag re-pointed at layers already present. Reporting only; it does
    not affect correctness.
  8. publish-vk.yml is changed symmetrically even though VK has no Ansible deploy — the label fix
    applies to both images, but VK publishing behaviour rides along with this PR.

Checklist

  • task lint, task tests, and task build pass locally
  • Tests added/updated for the change
  • Docs updated — role defaults, both templates, both workflows and all 8 READMEs
  • The change is focused — no unrelated churn
  • Commits follow Conventional Commits

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.
`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.
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.<run_number> 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.
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.
"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.
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.
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.
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.<run_number> 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.
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/** 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.
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.
…it fetched

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.
"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.
"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.
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.
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.
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
"<service> 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.
"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.

@zaytcevcom zaytcevcom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

  • Summary: no blocker and no major. The deploy change is coherent and the guard genuinely catches the two regressions it was built for — I re-added pull_policy: always to the template and --pull always to the (Re)start in throwaway copies and both turn the suite red, while a legitimate second gated pull (when: bot_image_pull | default(false) | bool) stays green. Five smaller items inline, mostly guard coverage and doc claims the code doesn't quite deliver.
  • Must fix before merge: —
  • Risks/questions: medium — deploy semantics change for every already-provisioned host on the first run after merge, and no playbook was ever executed (not even --syntax-check), with no ansible-lint/yamllint in CI to catch an Ansible-level mistake. The task file itself reads correct: the fetch is ordered before the (Re)start, image_fetch is handled in both the skipped and the undefined case, and a failed fetch aborts before anything is recreated, so a bad opt-in run leaves the old containers up rather than a half-migrated host. I could not construct a post-merge scenario that leaves a host worse off than before the PR.
  • Inline comments: 5

Also checked, no finding

  • Ordering/idempotency: compose file and .env are rendered before the fetch, so the first post-merge run writes a template without pull_policy: always before the up that would have honoured it. Registry unreachable with the default bot_image_pull=false is now a successful run where it used to fail — a strict improvement.
  • .dockerignore / path filters: the Dockerfile builds ./cmd/flock-telegram and never runs go test, so dropping **/*_test.go from the context cannot change the image (one cache-busting rebuild after merge, then stable). ci.yml has no paths: filter, so the new guard test runs on every PR — the gate is not self-excluding.
  • ansible.cfg, prune unit, 8 translations: all consistent with the role; the 7 non-English READMEs are faithful renderings of the English bullet.

Two nits not worth an inline

  • changed_when on the fetch task reads image_fetch.stderr + image_fetch.stdout. If the async job hits the 1800s cap the result carries neither key, so the run dies on 'dict object' has no attribute 'stderr' instead of the real "async task did not complete" message. | default('') on both would keep the useful error. The pre-existing compose_up changed_when has the same shape, so this is the established pattern rather than something this PR introduced.
  • --check was already broken before this PR (until: bot_state.stdout == "running" on a command task that check mode skips), and stays broken. Worth knowing before anyone tries the --check suggested in the residual-risk list — it will fail on the wait task, not on the new ones.

}

// Every file that decides whether a deploy may replace the running image.
var deployManifests = []deployManifest{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: the manifest set misses the one shipped file that can silently revert this PR

  • Problem: the comment above calls this "every file that decides whether a deploy may replace the running image", but deploy/inventories/example/group_vars/all/vars.yml is tracked, is the file the README tells operators to cp -r, and group_vars/all beats role defaults. I appended bot_image_pull: true to it and ran the suite: still green. Every inventory derived from the example would then fetch on every ordinary run again — exactly the defect this PR removes — with nothing in CI catching it, because TestBotImagePullIsOptIn reads only defaults/main.yml.
  • Solution: have TestBotImagePullIsOptIn also read deploy/inventories/example/group_vars/all/vars.yml and assert it either does not set bot_image_pull at all or sets it to a false value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in fa273b4 (+ d60d6ce).

Reproduced your mutation first: bot_image_pull: true appended to deploy/inventories/example/group_vars/all/vars.yml left the suite green.

TestBotImagePullIsOptIn no longer reads a single file. It walks the shipped deploy tree over an fs.FS and asserts that every shipped assignment of the knob is a false value, plus that defaults/main.yml still defines one. Walking rather than listing was deliberate: group_vars, host_vars, a second inventory and a play's own vars: all outrank a role default, so a hardcoded list is one forgotten file away from the same hole. Both syntaxes count — a YAML mapping key and an INI [group:vars] entry — and comments are stripped first, so the -e bot_image_pull=true quoted in the role's own docs is not read as an assignment. Inventories other than example are skipped: .gitignore keeps them out of the repo, and an operator's local copy is not ours to police.

Mutation results, each applied to the tree and reverted:

mutation verdict
bot_image_pull: true in inventories/example/group_vars/all/vars.yml FAIL, names the file and line
bot_image_pull: false there pass (an explicit opt-out is legitimate)
key absent there pass
bot_image_pull=true in inventories/example/inventory.ini FAIL
vars: {bot_image_pull: yes} in playbook.yml FAIL
bot_image_pull: false deleted from defaults/main.yml FAIL, "no bot_image_pull default"
bot_image_pull: true in an untracked inventories/mybot/ pass (gitignored, not shipped)

host_vars/ does not exist in the repo, so nothing was invented for it — the walk covers it the day it appears. The comment on deployManifests no longer claims to be "every file that decides whether a deploy may replace the running image"; it now says it is the set of files that can NAME a pull directive, and points at this test for the other half.

---
# 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: the rewritten comment still advertises an entry point the role does not have

  • Problem: the second half was corrected, but --tags restart was kept. grep -rnE '^\s*(tags|notify|listen)\s*:' adapters/telegram/deploy/ returns nothing — no task carries a restart tag and nothing notifies this handler, so ansible-playbook --tags restart selects zero tasks and this handler cannot run by any route. Tagging the handler would not help either: a handler still only fires when notified. Under AC5 this is precisely the class of claim the PR set out to remove.
  • Solution: either drop the --tags restart example and note the handler is currently unreachable, or add a small task with tags: ["restart"] running docker compose restart, so the documented invocation actually exists.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in b1a746e.

grep -rnE '^\s*(tags|notify|listen)\s*:' adapters/telegram/deploy/ returns nothing here too — no task carries a tag, nothing notifies the handler, so it is unreachable by any route and tagging it would not change that.

Took the first of your two options: the comment now states the handler is not reachable as the role stands and says why neither --tags restart nor tagging it would help, that it is kept as the restart entry point for a task that comes to need one, and that nothing is lost meanwhile because converging already restarts the stack idempotently. Adding a real tagged restart task would introduce a second, partly overlapping update entry point, which defaults/main.yml explicitly argues against a few lines above — out of scope for this PR. Deleting the handler is also out of scope here; flagging it as a candidate for a follow-up rather than doing it under this branch.

# - 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: on a pinned tag -e bot_image_pull=true is not the way forward — it is a no-op

  • Problem: the two clauses point at different update paths but read as one instruction. If bot_image is pinned to an immutable tag the tag never moves, so the opt-in fetch downloads nothing and the running version does not change; the operator gets the new "nothing downloaded" report and reasonably concludes the switch is broken. The real move on a pinned tag is to edit bot_image and run normally — compose's default missing policy fetches the now-absent image, no flag needed. bot_image_pull only means anything for a floating tag, which is the case the rest of this block describes.
  • Solution: split it — pinned tag: change bot_image and re-run; floating tag: re-run with -e bot_image_pull=true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and fixed in 552d9af (READMEs in 86fc150).

The two routes are now written as two, and the block says which one applies when:

  • PINNED to an immutable tag: edit bot_image and run normally — the new tag has no image on the host, so the (re)start fetches it. The switch is explicitly called out as not the tool here: a pinned tag does not move, so the fetch reports nothing downloaded and the running version does not change.
  • FLOATING (:latest, :v1): the tag has to be re-resolved, which is what -e bot_image_pull=true does.

Also added one clause to the bot_image_pull block itself, since the switch is not a total no-op on a pinned deployment: the pinned bot tag resolves to the image already present and downloads nothing, while the floating sidecar tags (dind, Caddy) are still re-resolved.

The 8 READMEs had the same conflation — "to update, re-run with -e bot_image_pull=true; set bot_image to pin a tag" — so they carry the same distinction now, one clause each in their own language.

// 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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: two false positives that invite deleting the guard rather than fixing it

  • Problem: both reproduced against the current tree. (a) The quote tracking is fooled by an apostrophe in an unquoted YAML scalar — the quote never closes, so the trailing # is not stripped and msg: Updating is the operator's call # not a docker compose pull fails the build for a comment stating the directive is not used. (b) Any informational line naming the manual command trips the guard: appending - "to update by hand: docker compose pull && docker compose up -d" to the Report status msg list — a very natural follow-up to this PR — turns CI red.
  • Solution: (a) if a quote is still open when the scan reaches end of line, fall back to cutting at the first whitespace-preceded #; (b) skip lines that belong to a task whose module is ansible.builtin.debug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both reproduced and fixed in 1382586.

Proof before the fix, applied to the real role file: adding a Say what the role does not do debug task with msg: Updating is the operator's call # not a docker compose pull, plus - "to update by hand: docker compose pull && docker compose up -d" in the Report status msg list, made TestDeployNeverPullsImplicitly report both lines. Same tree, new guard: green. Both cases are pinned as tests.

(a) Deviated slightly from your suggestion, and it is worth flagging. Falling back to "cut at the first whitespace-preceded # when a quote is left open" fixes your line, but on cmd: echo "a # b" && docker compose pull combined with an apostrophe it cuts inside the quoted string and hides a real directive — a false negative, which for a guard is the more expensive mistake. Instead a quote now only opens a scalar where one can begin (line start, or after whitespace or a flow punctuator), which is what YAML actually does: an apostrophe inside a word is a literal character, so nothing is left open and the comment is cut normally. Both directions are pinned: msg: Updating is the operator's call # not a docker compose pull must not match, and cmd: echo "it's # here" && docker compose pull must still match.

(b) Lines belonging to a debug task are skipped — it executes nothing, so its msg is the role talking about a command rather than running one. Attributing such a line to its task needed one more change: the walk-back to the enclosing task now steps over list items that hold scalars, because a msg list entry is a value, not a task. Three cases pin it: the FQCN form, the short debug: form, and a command task quoting the same instruction, which is still reported.

Honest limitation, and it is now in the docblock: skipping the whole task rather than only its msg means a vars: on a debug task is unscanned too, so a command smuggled through a lookup('pipe', …) there would pass. The guard catches accidents, not evasion — it already said it is not an evaluator. The alternative was scanning vars: and having the guard fire on the operator-facing prose that lives there today, which is the failure mode you flagged.

Regression checked in the same round: --pull always back on the (re)start task and pull_policy: always back in the compose template both still fail the guard.

Comment thread README.md Outdated
- **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_<id>` (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/<name>/` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories/<name>/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/<name>/` (gitignored), fill inventory/vars/vault, then `ansible-playbook -i inventories/<name>/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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: the 8 READMEs are the only surface that states this without its exception

  • Problem: defaults/main.yml, docker-compose.yml.j2 and docker-prune.service.j2 each name the case where an ordinary run still fetches — the host having no local copy of that tag left. All 8 READMEs say "an ordinary re-run doesn't go looking for a newer build" unqualified, which is the same class of absolute claim the earlier rounds removed everywhere else, on the most operator-facing surface there is. Secondary: "(bot, dind, Caddy)" — Caddy is only in the stack when webhook_domain is set, dind only when enable_dind. The 7 translations are otherwise faithful to the English; I compared them line by line.
  • Solution: one clause — "…unless this host no longer has a copy of that tag" — mirrored into the other 7 files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 81b26b3, and the pinned-tag point in 86fc150.

All 8 READMEs now carry the exception in their own language: the ordinary re-run restarts on the image the tag already points to locally 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 — matching the wording in defaults/main.yml, the compose template and the prune unit.

Secondary point taken as well: "(bot, dind, Caddy)" now reads as the bot plus the sidecars in use, since Caddy only exists when webhook_domain is set and dind when enable_dind is.

Swept the whole set afterwards (8 READMEs, defaults/main.yml, tasks/main.yml, handlers/main.yml, both templates, both publish workflows, the guard test): no unqualified claim about pulling or updating is left — every remaining "never" is about unrelated things (the whitelist advice, the arbiter's loop cap). The workflows already stated "or when the host has no copy of the tag left to start from", so they agree.

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.
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.
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.
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.
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant