feat: multi-server deployment topology and CI fan-out #5

Open
shcizo wants to merge 6 commits from feat/multi-server-fanout into main
Owner

Bakgrund

Den ursprungliga frågan var om updater kunde hantera Docker Swarm och Docker Compose samtidigt istället för exklusivt via MODE.

Svaret blev nej — och det visade sig vara rätt svar. En instans kan bara nå den Docker-daemon den är konfigurerad mot. Compose-stackarna ligger på andra servrar, så en instans som klarade båda lägena skulle fortfarande bara nå en daemon. Begränsningen låg aldrig i applikationen utan i deployment-topologin: en instans för en hel flotta.

Lösningen är därför en instans per server, och fan-out från CI. Det gör MODE-exklusiviteten korrekt snarare än en brist.

Konsekvens: noll Go-ändringar i den här PR:en.

Innehåll

Dokumentation

  • Designspec med de tre förkastade alternativen och de fyra antaganden beslutet vilar på — så att "gör så den klarar båda" inte tas upp igen utan att antagandena ändrats.
  • README.md: ny sektion om deployment-topologi, omskriven MODE-rad och known-gaps-punkt, samt rättad ingress som tidigare motsade den nya texten.
  • CLAUDE.md: designintent-punkten uppdelad — rationalen bakom exklusiviteten och swarm-lägets säkerhetsgrind var två regler som delade en bullet.

gitea-action/ — fan-out

endpoint tar nu en newline-separerad lista istället för en enda URL. En ensam URL fungerar oförändrat som en lista med ett element.

endpoint: |
  https://updater-swarm.example.com/update
  https://updater-web01.example.com/update
  https://updater-web02.example.com/update

Newline och inte komma: URL:er får innehålla komma men aldrig radbrytning, så avgränsaren kan inte kollidera med datat.

Det som faktiskt var svårt

Loopen måste fortsätta förbi fel. Actionen hade set -euo pipefail, vilket gör att första nedsläckta servern avbryter körningen — resten av flottan uppdateras aldrig, och loggen döljer vilka som hann med. set -e är borttaget med en kommentar som förklarar varför, så det inte "städas tillbaka".

head -n -1 var GNU-specifikt. Den befintliga koden använde det, vilket gjorde fan-out-logiken omöjlig att testa på en utvecklarmaskin (illegal line count på BSD/macOS). Ersatt med bash parameter expansion, som dessutom tar bort två subprocesser per endpoint.

tag var dokumenterad som kosmetisk men är load-bearing i swarm-läge. Kedjan handlers.go:59-62swarm.go Job.Imageswarm_executor.go ContainerSpec.Image gör att en utelämnad tag deployar :latest istället för den byggda committen. Compose-läget är genuint tag-agnostiskt, swarm är det inte. Eftersom det nya exemplet sätter en swarm-endpoint först låg detta nu på den dokumenterade lyckliga vägen. Dokumentationen är rättad i både action.yml och gitea-action/README.md.

Timeouts. En host som accepterar TCP men aldrig svarar blockerade loopen i all evighet — samma delvis-deployade utfall som ändringen finns för, nådd en annan väg. --connect-timeout 10 --max-time 900; generöst eftersom /update är synkron och väntar ut jobbet.

Verifiering

go build ./... och go test ./... gröna med tömd testcache.

Fan-out-logiken kördes mot en lokal HTTP-server i tre scenarier:

Scenario Utfall
Tre endpoints, mittersta död Alla tre försöktes, exit 1
En endpoint Exit 0
Indentering och blankrader Två rena URL:er, exit 0

Att den tredje endpointen försöktes är beviset att loopen fortsatte förbi felet.

Känd begränsning

Actionen är shell inuti composite-YAML och det finns ingen testharness för den i repot. Verifieringen ovan kördes för hand och fångas inte av CI. Regression i fan-out-logiken upptäcks alltså inte automatiskt.

En orelaterad, sedan tidigare befintlig inkonsekvens rättades på vägen: README.md beskrev compose-lägets skydd som två faktorer när koden gör tre (token, opt-in-label och STACKS_ROOT-prefix).

## Bakgrund Den ursprungliga frågan var om updater kunde hantera Docker Swarm och Docker Compose samtidigt istället för exklusivt via `MODE`. Svaret blev nej — och det visade sig vara rätt svar. En instans kan bara nå den Docker-daemon den är konfigurerad mot. Compose-stackarna ligger på andra servrar, så en instans som klarade båda lägena skulle fortfarande bara nå en daemon. Begränsningen låg aldrig i applikationen utan i deployment-topologin: en instans för en hel flotta. **Lösningen är därför en instans per server, och fan-out från CI.** Det gör `MODE`-exklusiviteten korrekt snarare än en brist. Konsekvens: **noll Go-ändringar** i den här PR:en. ## Innehåll **Dokumentation** - Designspec med de tre förkastade alternativen och de fyra antaganden beslutet vilar på — så att "gör så den klarar båda" inte tas upp igen utan att antagandena ändrats. - `README.md`: ny sektion om deployment-topologi, omskriven `MODE`-rad och known-gaps-punkt, samt rättad ingress som tidigare motsade den nya texten. - `CLAUDE.md`: designintent-punkten uppdelad — rationalen bakom exklusiviteten och swarm-lägets säkerhetsgrind var två regler som delade en bullet. **`gitea-action/` — fan-out** `endpoint` tar nu en newline-separerad lista istället för en enda URL. En ensam URL fungerar oförändrat som en lista med ett element. ```yaml endpoint: | https://updater-swarm.example.com/update https://updater-web01.example.com/update https://updater-web02.example.com/update ``` Newline och inte komma: URL:er får innehålla komma men aldrig radbrytning, så avgränsaren kan inte kollidera med datat. ## Det som faktiskt var svårt **Loopen måste fortsätta förbi fel.** Actionen hade `set -euo pipefail`, vilket gör att första nedsläckta servern avbryter körningen — resten av flottan uppdateras aldrig, och loggen döljer vilka som hann med. `set -e` är borttaget med en kommentar som förklarar varför, så det inte "städas tillbaka". **`head -n -1` var GNU-specifikt.** Den befintliga koden använde det, vilket gjorde fan-out-logiken omöjlig att testa på en utvecklarmaskin (`illegal line count` på BSD/macOS). Ersatt med bash parameter expansion, som dessutom tar bort två subprocesser per endpoint. **`tag` var dokumenterad som kosmetisk men är load-bearing i swarm-läge.** Kedjan `handlers.go:59-62` → `swarm.go` `Job.Image` → `swarm_executor.go` `ContainerSpec.Image` gör att en utelämnad tag deployar `:latest` istället för den byggda committen. Compose-läget är genuint tag-agnostiskt, swarm är det inte. Eftersom det nya exemplet sätter en swarm-endpoint först låg detta nu på den dokumenterade lyckliga vägen. Dokumentationen är rättad i både `action.yml` och `gitea-action/README.md`. **Timeouts.** En host som accepterar TCP men aldrig svarar blockerade loopen i all evighet — samma delvis-deployade utfall som ändringen finns för, nådd en annan väg. `--connect-timeout 10 --max-time 900`; generöst eftersom `/update` är synkron och väntar ut jobbet. ## Verifiering `go build ./...` och `go test ./...` gröna med tömd testcache. Fan-out-logiken kördes mot en lokal HTTP-server i tre scenarier: | Scenario | Utfall | |---|---| | Tre endpoints, mittersta död | Alla tre försöktes, exit 1 | | En endpoint | Exit 0 | | Indentering och blankrader | Två rena URL:er, exit 0 | Att den **tredje** endpointen försöktes är beviset att loopen fortsatte förbi felet. ## Känd begränsning Actionen är shell inuti composite-YAML och det finns ingen testharness för den i repot. Verifieringen ovan kördes för hand och fångas inte av CI. Regression i fan-out-logiken upptäcks alltså inte automatiskt. En orelaterad, sedan tidigare befintlig inkonsekvens rättades på vägen: `README.md` beskrev compose-lägets skydd som två faktorer när koden gör tre (token, opt-in-label och `STACKS_ROOT`-prefix).
shcizo added 6 commits 2026-08-04 17:53:26 +00:00
One instance per server, each owning its local Docker daemon; Gitea CI
posts /update to every instance. Records why MODE exclusivity is correct
rather than a limitation, and the alternatives rejected.

Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
Three tasks: merge swarm branch to main, reframe deployment docs, and
make the Gitea action post to every endpoint in a fleet.

Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
head -n -1 fails on BSD/macOS, making the plan's local verification steps
unrunnable. Bash parameter expansion is portable and drops two subprocesses
per endpoint.

Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
Documentation fixes from the final branch review, plus small curl/jq
hardening in the gitea-action script:

- tag was documented as cosmetic ("for logging") but is load-bearing in
  swarm mode: handlers.go folds it into the requested image, compose
  discovery strips it via NormaliseImage, but SwarmExecutor assigns it
  directly to ContainerSpec.Image. Omitting it deploys :latest, silently
  diverging from what CI just built. Fixed in action.yml, gitea-action's
  README, and added to CLAUDE.md's Gotchas since it's invisible from
  either mode's code alone.
- gitea-action/README.md's opening line and root README.md's intro/trigger
  flow described compose-only behavior even though both docs' bodies now
  cover swarm mode too.
- README.md's defense-in-depth section described a two-factor gate; compose
  mode is actually three factors (token, label, STACKS_ROOT prefix), and
  swarm mode is genuinely two (no local compose file to path-check against).
- action.yml: curl now has --connect-timeout 10 --max-time 900 so a host
  that accepts TCP but never answers can't block the fan-out loop forever;
  the jq payload build now fails loudly instead of silently sending an
  empty payload to every endpoint.
- CLAUDE.md References section now lists this branch's spec and plan.

Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/multi-server-fanout:feat/multi-server-fanout
git checkout feat/multi-server-fanout
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: shcizo/package-updater#5