Skip to content

Voer DEPLOY-3.1.md uit als een script, met bewijs per stap - #352

Merged
Apolloccrypt merged 3 commits into
mainfrom
feat/deploy-3.1-script
Sep 2, 2026
Merged

Voer DEPLOY-3.1.md uit als een script, met bewijs per stap#352
Apolloccrypt merged 3 commits into
mainfrom
feat/deploy-3.1-script

Conversation

@Apolloccrypt

@Apolloccrypt Apolloccrypt commented Sep 2, 2026

Copy link
Copy Markdown
Owner

deploy/deploy-3.1.sh voert deploy/DEPLOY-3.1.md uit. Een deploy is een
commando vanaf de NUC:

bash deploy/deploy-3.1.sh [--preflight-only] [--rollback <TS>] [--dry-run]

Het runbook blijft de leesbare bron. Het script voegt niets aan het plan toe,
het weigert alleen door te gaan als een meting het tegenspreekt.

Reviewronde: negen punten, met bewijs

1. De ssh-argumenten. Dit was een echte bug en de reviewer had gelijk.
ssh bewaart argv niet: het plakt alles na de host aan elkaar tot een string, en
de remote shell splitst opnieuw op witruimte. De zes servicenamen als een
argument kwamen aan als zes losse parameters, dus elke remote lus over $2
draaide precies een keer. Reproductie, met bash -c als sshd:

--- current: unquoted ---     --- fixed: printf %q per argument ---
container relay-main          container relay-main
argc=7                        container relay-health
                              container relay-finance
                              container relay-legal
                              container relay-iot
                              container admin
                              argc=2

q_args() quote nu elk argument met printf %q. De test bootst de
samenvoeging na en heeft een tegenproef die faalt als de simulatie ooit stopt
met ssh na te doen. Er draait geen sshd waar ik een sleutel voor heb, dus de
simulatie is "$*" plus bash -c, wat exact is wat de client en de server
samen doen.

2. Strict mode. Alle twintig heredocs draaien set -euo pipefail; de test
telt heredocs tegen strict-regels, dus een nieuwe heredoc zonder -e faalt.
cp ... && chmod ... is uit elkaar getrokken (een AND-lijst verbergt een
falende cp voor -e), en de test verbiedt dat patroon. remote() vangt de
exitcode, print de serveruitvoer met |-prefix en stopt daarna met de
STOP-regel, dus de diagnose staat er nog.

3. Backupasserties meten nu iets. after .env backup bytes,
after docroot tar bytes, after docroot tar entries, en
after tags for this TS = 6 telt de echte docker images-tags in plaats van
de zes regels tekst in het manifest. expect_count en expect_min lezen een
veld en vergelijken een getal; ze matchen niet meer op een geechode padstring.

4. Fase 5c. Alleen de twee benoemde confs (PARAMANT_NGINX_CONFS),
readlink -f eerst want cp -a van een symlink is geen backup, en per
sed-edit de eis dat before > 0. Is er niets te doen, dan stopt hij met
FATAL nothing to change for 'sign': de server conf heeft niet de vorm die het runbook noemt in plaats van OK te melden. after edited files = 2 is een
harde assertie.

5. --preflight-only schrijft niets. 1c en 1d krijgen report in plaats
van write mee en melden alleen. De slotregel is Nothing was written, de
faseheader zegt read-only, en het runbook zegt het ook. De test controleert
dat preflight bash -s -- ... report aanroept en nergens write.

6. Rollback. .env gaat terug voor de recreate, zodat de containers de
oude file lezen. nginx -t voor de reload, en bij een fout geen reload.
tar x legt over maar verwijdert niet wat de deploy toevoegde, dus 8c
vergelijkt de tar-inhoud met de docroot en ruimt het verschil op (met de
IGNORE-lijst ontzien). 8a stopt als het manifest, .env, de tar of een
nginx-backup ontbreekt: missing backups = 0 is een assertie, geen mededeling.

7. De 26 verwijderde frontendbestanden. rsync zonder --delete liet ze
staan en try_files bleef ze serveren, dus /compliance/nis2 en /paraid
gaven nog 200. Fase 5b leidt de lijst af uit
git diff --diff-filter=D --name-only <vorige HEAD>..HEAD -- frontend/ (26
bestanden, frontend/paraid.html erbij) en verwijdert die uit de docroot, met
de IGNORE-lijst van check-prod-drift.sh ontzien en een pad-guard. Fase 6e
controleert dat /compliance/nis2, /compliance/iec62443,
/compliance/nen7510, /paraid, /paraid-app en /dicom 404 geven.

8. De SDK-precondition. Het runbook zei 3.2.1; dat is 3.3.0, en die release
komt niet op PyPI omdat er geen trusted publisher staat (gerapporteerd
2026-09-02, niet hier gemeten; zo staat het er ook). De deploy wacht er niet
meer op en neemt de eerste uitweg uit het runbook:
PARAMANT_INLINE_RECEIPT_HEADER=1 in .env als de regel ontbreekt (fase 1d),
de drie proxy_buffer-regels op elke location ~ ^/v2/outbound (fase 5c,
idempotent, met before outbound locations > 0 als eis), en fase 6h. Die leest
de env-var uit de draaiende containers, leest nginx -T in plaats van het
bestand, controleert dat /v2/outbound geen 5xx geeft, en doet met
PARAMANT_SMOKE_API_KEY een echte upload plus download tegen
relay.paramant.app: 200 en geen 502, X-Paramant-Receipt aanwezig, de drie
referentievelden aanwezig, en het headerblok boven 16 KB. Zonder die sleutel
zegt de stap met zoveel woorden wat hij niet heeft bewezen. Het runbook
noemt nu ook de vervolgstap: zodra 3.3.0 op PyPI staat, de regel eruit en
recreaten.

9. De secretscan is een taintscan geworden. Een namenlijst vangt alleen de
namen die erop staan. De test leidt nu uit de bron af welke variabelen een
.env-waarde of een openssl rand dragen (nu: miss T tok) en meldt elke
echo of printf daarvan. Tellende lezen (grep -c, wc -l) leveren een
getal en tellen niet mee. Drie tegenproeven:

injectie gevangen
cat .env ja, 2 checks
echo "deep token IS $T" ja
SEKRIT="$(grep '^ADMIN_TOKEN=' .env | cut -d= -f2-)"; echo "leaked $SEKRIT" ja, via de taintscan (de oude namenlijst liet dit door)

Reviewronde 2: de blocker en vier guards

1. Blocker: de vlag bereikte de containers nooit. docker-compose.yml
heeft geen env_file. .env vult alleen ${VAR} in het compose-bestand
zelf in, dus een variabele zonder eigen regel in x-relay-env komt nooit in
een container, hoe netjes je hem ook in .env zet. De vier receipt-variabelen
uit #342 hadden die regel niet. Fase 1d schreef dus een vlag die nergens
aankwam, en fase 6h zou pas aan het eind falen met 3.1.0 al live.

Toegevoegd aan x-relay-env, alle vier met lege default (de vlag telt alleen
bij exact 1; de caps vallen leeg terug op hun ingebouwde getallen):

PARAMANT_INLINE_RECEIPT_HEADER:    "${PARAMANT_INLINE_RECEIPT_HEADER:-}"
PARAMANT_RECEIPT_PER_ACCOUNT_MAX:  "${PARAMANT_RECEIPT_PER_ACCOUNT_MAX:-}"
PARAMANT_RECEIPT_UNCAPPED_MAX:     "${PARAMANT_RECEIPT_UNCAPPED_MAX:-}"
PARAMANT_RECEIPT_TOTAL_MAX:        "${PARAMANT_RECEIPT_TOTAL_MAX:-}"

Bewijs, lokaal met docker compose config:

$ docker compose --env-file probe.env config | grep -n 'PARAMANT_INLINE_RECEIPT_HEADER\|PARAMANT_RECEIPT_'
152:      PARAMANT_INLINE_RECEIPT_HEADER: "1"
153:      PARAMANT_RECEIPT_PER_ACCOUNT_MAX: ""
154:      PARAMANT_RECEIPT_TOTAL_MAX: ""
155:      PARAMANT_RECEIPT_UNCAPPED_MAX: ""
241, 330, 419, 508:  idem voor de vier andere relays
596:      (het x-relay-env anker zelf)

met de vlag in .env : 6 treffers op PARAMANT_INLINE_RECEIPT_HEADER: "1"
zonder de vlag      : 0 treffers

Vijf relays plus het anker. Nieuwe stap 4pre doet exact deze meting op de
server voordat er iets herstart wordt en eist >= 5, dus een ontbrekende
doorgifte blokkeert op een leesbare assertie in plaats van op een smoketest
achteraf. Runbookregel 52-54 herschreven: de claim dat compose byte-identiek
is aan 41501bb klopte trouwens al niet meer sinds #340 (git diff geeft
2 insertions, 1 deletion), en er staat nu bij dat fase 3 het compose-bestand
meetrekt en fase 4 het oppakt.

2. Bufferguard per blok in plaats van per bestand. Het gat nagespeeld:

    location /v2/inbound {
        proxy_buffer_size 32k;      <-- staat er, maar op het verkeerde blok
    }
    location ~ ^/v2/outbound {
        limit_req ...;              <-- leeg, en elke download geeft 502
    }

file-scoped grep -c 'proxy_buffer_size 32k'  ->  1   (dus insert overgeslagen)

Vervangen door een tweetraps-awk: pass 1 leest per outbound-blok of de buffer
er al staat, pass 2 voegt alleen toe waar hij ontbreekt. Op de conf hierboven
belandt hij nu wel in het outbound-blok, en tweemaal draaien verandert niets
(2 bufferregels blijven 2, cmp gelijk). De eindcheck telt ook per blok:
after outbound blocks with buffer moet gelijk zijn aan
after outbound locations.

3. Fase 5b resolvet het doelpad. readlink -f op het te verwijderen
bestand en op de docroot, en alles wat niet echt onder de docroot uitkomt
wordt geweigerd en geteld. after refused outside docroot = 0 is een harde
assertie, dus een symlinked submap kan rm niet meer buiten de docroot laten
komen.

4. De 16 KB-claim alleen als hij waar is. De ok over het oversized
headerblok staat nu in de > 16000-tak. Onder die grens volgt een WARN met
NOT PROVEN en geen enkele ok, dus de samenvatting kan niet lezen alsof het
pad door nginx bewezen is terwijl het dat niet is.

5. Taint erft over een toewijzing. s3cr="$tok" telt nu ook als besmet.
Tegenproef: s3cr="$T"; echo "inherited $s3cr" wordt gevangen. Een hop, en
die grens staat als commentaar in de test, met de reden erbij.

Lokaal groen, ronde 2

  • bash -n schoon op beide bestanden.
  • tests/static-sanity.sh: PASS, alle tien checks OK, check 10 groen.
  • tests/deploy-3.1-dryrun.test.sh: 108 pass, 0 fail.
  • docker compose config met en zonder de vlag: 6 tegen 0 (hierboven).
  • Alle drie de modi exit 0 onder --dry-run.

Lokaal groen

  • bash -n schoon op beide bestanden; shellcheck staat niet op deze machine
    en wordt overgeslagen als hij ontbreekt.
  • tests/static-sanity.sh: PASS, alle tien checks OK, check 10 groen.
  • tests/deploy-3.1-dryrun.test.sh: 96 pass, 0 fail.
  • Alle drie de modi exit 0 onder --dry-run, geen enkele ssh gelegd.

Dry-run uitvoer, ingekort

  mode          full deploy, DRY RUN (nothing is executed on the server)
  nginx confs   paramant-public.conf paramant-live.conf
PHASE 0: Before you start (read-only)
[step] 0a. CI on main
  $ gh run list --branch main -L 5
[step] 0b. static sanity on the commit that will be deployed (origin/main)
  $ git worktree add --detach <tmp> origin/main && tests/static-sanity.sh
  SKIP  assert (dry-run): static sanity, checks 1 to 9 clear
[step] 0c. frontend drift against origin/main
  $ bash scripts/check-prod-drift.sh origin/main
[step] 0d. the timestamp this run is named by
  OK    run timestamp fixed at 20260902-1915
PHASE 1: Layout and environment (server, read-only plus two env writes)
[step] 1a. checkout, compose project and container health
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay relay-main\ relay-health\ relay-finance\ relay-legal\ relay-iot\ admin"   # layout and health
  SKIP  assert (dry-run): production checkout is on 41501bb, the commit the runbook measured   [would match /checkout_head = 41501bb/]
  SKIP  assert (dry-run): all six services have a container   [services seen = 6]
  SKIP  assert (dry-run, must NOT match): no service is missing a container   [/container [a-z-]+ MISSING/]
  SKIP  assert (dry-run, must NOT match): no container reports unhealthy   [/unhealthy/]
[step] 1b. environment presence (never values)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # env presence
  SKIP  assert (dry-run): BILLING_MODE is empty in the running relay, so the recurring layer stays off   [would match /env BILLING_MODE +empty/]
  SKIP  assert (dry-run): MOLLIE_API_KEY is set with a live_ prefix, unchanged since 08-08   [would match /env MOLLIE_API_KEY +set, prefix live_/]
[step] 1c. INTERNAL_AUTH_TOKEN (write: step 6 cannot read /v2/health/deep without it)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay write"   # internal auth token
  SKIP  assert (dry-run): INTERNAL_AUTH_TOKEN present exactly once in .env   [after INTERNAL_AUTH_TOKEN lines = 1]
[step] 1d. PARAMANT_INLINE_RECEIPT_HEADER (write: keeps old SDK clients receipted)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay write"   # inline receipt opt-in
  SKIP  assert (dry-run): PARAMANT_INLINE_RECEIPT_HEADER=1 present exactly once in .env   [after PARAMANT_INLINE_RECEIPT_HEADER lines = 1]
PHASE 2: Rollback tags and backups (server, WRITE)
[step] 2a. tag the running images and write the manifest
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay 20260902-1915 /home/paramant/backups relay-main\ relay-health\ relay-finance\ relay-legal\ relay-iot\ admin"   # rollback tags
  SKIP  assert (dry-run): the rollback manifest has six lines, one per service   [after manifest lines = 6]
  SKIP  assert (dry-run): six rollback images really exist on the host, not just six lines of text   [after tags for this TS = 6]
[step] 2b. back up .env, compose state, docroot and the nginx confs
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay 20260902-1915 /home/paramant/backups /home/paramant/app /etc/nginx/backups /etc/nginx/sites-enabled paramant-public.conf\ paramant-live.conf"   # backups
  SKIP  assert (dry-run): .env backup is a real file with content   [after .env backup bytes >= 1]
  SKIP  assert (dry-run): docroot tar is a real file with content   [after docroot tar bytes >= 1]
  SKIP  assert (dry-run): docroot tar holds entries   [after docroot tar entries >= 1]
  SKIP  assert (dry-run): both named nginx confs were resolved and backed up   [after nginx conf backups = 2]
  SKIP  assert (dry-run, must NOT match): both named nginx confs exist on the server (set PARAMANT_NGINX_CONFS if they are named differently)   [/nginxconf [a-z.-]+ ABSENT/]
PHASE 3: Pull main, run the sanity gate, build (server, WRITE)
[step] 3a. pull origin/main fast-forward only
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # git pull
  SKIP  assert (dry-run, must NOT match): server working tree was clean before the pull   [/FATAL working tree not clean/]
[step] 3b. static sanity on the server, same reading rule as phase 0
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # static sanity
  SKIP  assert (dry-run): static sanity on the server
[step] 3c. build the relays and admin from source (containers untouched)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # docker compose build
  SKIP  assert (dry-run): docker compose build succeeded   [build exit = 0]
PHASE 4: Recreate, the canary relay first (server, WRITE)
[step] 4a. relay-iot alone, then read its two boot lines
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # recreate relay-iot
  SKIP  assert (dry-run): relay-iot reached healthy   [would match /healthy [0-9a-f]+ after/]
  SKIP  assert (dry-run): relay-iot logged a billing_config line   [would match /bootline .*"billing_config"/]
  SKIP  assert (dry-run): billing_config says recurring:false, the brake is on   [would match /bootline .*"billing_config".*"recurring":false/]
  SKIP  assert (dry-run): billing_config says mode_source:inferred, so BILLING_MODE is not set anywhere   [would match /bootline .*"billing_config".*"mode_source":"inferred"/]
  SKIP  assert (dry-run, must NOT match): no relay reports recurring:true   [/"billing_config".*"recurring":true/]
  SKIP  assert (dry-run): relay_started reports version 3.1.0   [would match /bootline .*"relay_started".*"version":"3.1.0"/]
[step] 4b. relay-main, then the rest
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # recreate the fleet
  SKIP  assert (dry-run): relay-main recreated and healthy   [would match /recreated relay-main/]
  SKIP  assert (dry-run): admin recreated and healthy   [would match /recreated admin/]
  SKIP  assert (dry-run, must NOT match): every recreated container reached healthy   [/NOTHEALTHY|UNHEALTHY|FATAL/]
  SKIP  assert (dry-run, must NOT match): no relay reports recurring:true after the recreate   [/stance .*"recurring":true/]
  SKIP  assert (dry-run, must NOT match): every relay logged relay_started   [/version .*no relay_started line/]
PHASE 5: Frontend and nginx (server, WRITE)
[step] 5a. rsync the docroot, never with --delete
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay /home/paramant/app"   # rsync docroot
  SKIP  assert (dry-run): docroot rsync succeeded   [rsync exit = 0]
  SKIP  assert (dry-run): docroot now matches the checkout frontend   [after would change = 0]
[step] 5b. prune the frontend files main deleted (rsync without --delete leaves them)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay /home/paramant/app 41501bb dist\ paramant-mark.svg\ developer.js\ docs/paramant-investor-brief.html"   # prune deleted frontend files
  SKIP  assert (dry-run): git names at least one frontend file deleted since the deployed commit   [before deleted-in-git count >= 1]
[step] 5c. the three nginx changes, by hand, keeping the ParaID deny
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- 20260902-1915 /etc/nginx/sites-enabled /etc/nginx/backups paramant-public.conf\ paramant-live.conf"   # nginx edits
  SKIP  assert (dry-run, must NOT match): nginx edits applied and the config tests clean   [/FATAL/]
  SKIP  assert (dry-run): both named confs were really rewritten   [after edited files = 2]
  SKIP  assert (dry-run): /sign is no longer behind auth_request   [after sign gated = 0]
  SKIP  assert (dry-run): the /compliance locations are gone   [after compliance = 0]
  SKIP  assert (dry-run): /dicom no longer serves the page   [after dicom try_files = 0]
  SKIP  assert (dry-run): /dicom now returns 404   [after dicom 404 >= 1]
  SKIP  assert (dry-run): the /v2/outbound location the buffers go on exists   [before outbound locations >= 1]
  SKIP  assert (dry-run): nginx reloaded   [would match /reloaded nginx/]
PHASE 6: Smoke tests
[step] 6a. public auth surface (tests/auth-smoke.sh)
  $ bash tests/auth-smoke.sh https://paramant.app
[step] 6b. /health on each of the six hosts, and the version it reports
  $ curl -s -o /dev/null -w %{http_code} https://<host>/health   # six hosts
  SKIP  assert (dry-run): /health is 200 on six hosts, version 3.1.0
[step] 6c. /v2/health/deep, 401 without the token and 200 with it (server-local)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # deep health
  SKIP  assert (dry-run): /v2/health/deep is 401 without X-Internal-Auth   [deep noauth code = 401]
  SKIP  assert (dry-run): /v2/health/deep is 200 with X-Internal-Auth   [deep auth code = 200]
[step] 6d. /v1/paraid/issue-document is 404 on all six hosts (#319)
  $ curl -s -o /dev/null -w %{http_code} -X POST https://<host>/v1/paraid/issue-document   # six hosts
  SKIP  assert (dry-run): issue-document is 404 on six hosts
[step] 6e. the pages main deleted are really gone, not just unlinked (#319, #323)
  $ curl -s -o /dev/null -w %{http_code} https://paramant.app/compliance/nis2   # and /paraid
  SKIP  assert (dry-run): /compliance/nis2 and /paraid are 404
[step] 6f. /sign answers itself, 200 and no redirect to /auth/login (#317)
  $ curl -s -o /dev/null -w %{http_code} https://paramant.app/sign
  SKIP  assert (dry-run): /sign is 200 without a session
[step] 6g. the 3.0.0 verify suite, on the server (informational, exit 2 blocks)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # post-deploy-verify
[step] 6h. the inline receipt opt-in really works end to end (#342)
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # inline receipt config
  SKIP  assert (dry-run): relay-main runs with PARAMANT_INLINE_RECEIPT_HEADER=1   [would match /inline relay-main +1/]
  SKIP  assert (dry-run): relay-iot runs with PARAMANT_INLINE_RECEIPT_HEADER=1   [would match /inline relay-iot +1/]
  SKIP  assert (dry-run): the loaded nginx config carries the raised proxy buffers   [effective outbound buffers >= 1]
  $ curl -sI https://relay.paramant.app/v2/outbound/<64 hex that never existed>
  SKIP  assert (dry-run): /v2/outbound proxies without a 502
  SKIP  assert (dry-run): a real download carries the inline receipt (needs PARAMANT_SMOKE_API_KEY)
[step] 6i. billing stance once more, from every relay log
  $ ssh -i <prod-key> -o BatchMode=yes -o IdentitiesOnly=yes root@116.203.86.81 "bash -s -- /opt/paramant-relay"   # billing stance
  SKIP  assert (dry-run, must NOT match): every relay still reports recurring:false   [/"recurring":true/]
PHASE 7: Summary
  OK    run timestamp fixed at 20260902-1915
DEPLOY FINISHED, warnings: 0

Bewust niet geautomatiseerd

  • Stap 7 na de deploy. Meekijken, de canary-key minten, taggen, CHANGELOG,
    vaultnotitie. Elk vraagt een oordeel of een geheim dat het script niet maakt.
  • BILLING_MODE zetten. Precies de deploy waar de rem voor bestaat. Het
    script controleert dat hij leeg is en zet hem nooit.
  • De ParaID-deny verwijderen. Het runbook houdt hem deze ronde; het script
    telt hem voor en na en breekt af als hij verdwijnt.
  • Drift bij fase 0 blokkeert niet. Handmatige edits op de docroot zijn een
    WARN met de lijst erbij; alleen Mick weet of zo'n bestand mag sneuvelen.
  • De volledige receipt-smoketest zonder sleutel. Een upload vraagt een
    account, en een deploy hoort geen productiesleutel te bevatten. Met
    PARAMANT_SMOKE_API_KEY draait hij, zonder zegt hij dat hij het niet weet.
  • scripts/rollback-3.0.0.sh wordt niet aangeroepen. Die vraagt
    interactief om bevestiging, wat niet gaat door ssh 'bash -s'. Fase 8 doet
    dezelfde stappen plus de nginx- en docroot-restore die dat script niet doet.

deploy/deploy-3.1.sh draait het runbook vanaf de NUC in acht fasen: de
controles vooraf, layout en env, rollback-tags en backups, pull en build,
de recreate met relay-iot als kanarie, frontend en nginx, de smoketests en
de rollback. Elke stap print de kop, het commando en de meting, en stopt
bij het eerste resultaat dat het runbook niet voorspelt: een checkout die
niet op 41501bb staat, een container die niet healthy wordt, een
billing_config-regel zonder recurring:false en mode_source:inferred, een
relay_started zonder version 3.1.0, of een static-sanity met een FAIL
buiten check 10.

Alles op de server gaat via een ssh-heredoc met BatchMode en IdentitiesOnly.
Sleutels worden nooit geprint, alleen presence, lengte en een prefix van vijf
tekens. Elke fase die iets kapot kan maken print een bewijsregel voor en na.

--preflight-only doet fase 0 en 1 en stopt voor de eerste schrijfactie.
--rollback <TS> zet de getagde images, .env, de nginx-confs en de docroot
terug. --dry-run print elk remote commando in plaats van het uit te voeren.

tests/deploy-3.1-dryrun.test.sh draait die dry-run en controleert dat elke
fase wordt bereikt, dat elke modus stopt waar hij moet stoppen, en dat geen
enkele regel een sleutelwaarde kan bevatten. De scan strippt de drie vormen
die het runbook wel toestaat (lengte, prefix van vijf, een header die curl
opeet) en een write die in .env belandt, en meldt wat er dan nog overblijft.

Het runbook blijft de leesbare bron; het script voert het uit.
@Apolloccrypt
Apolloccrypt force-pushed the feat/deploy-3.1-script branch from 9fdd341 to facccb8 Compare September 2, 2026 16:46
De ssh-argumenten waren de echte bug. ssh bewaart argv niet: het plakt alles
na de host aan elkaar tot een string en de remote shell splitst opnieuw op
witruimte. Zes servicenamen als een argument kwamen daardoor aan als zes
parameters, dus elke remote lus over $2 draaide een keer: fase 1 keek naar
1 van de 6 containers en fase 2 schreef een manifest van 1 regel. q_args()
quote elk argument nu met printf %q. De test bootst de samenvoeging na met
bash -c en heeft een tegenproef: zonder quoting zakt hij terug naar 1 van 6.

Verder:
- alle twintig heredocs draaien set -euo pipefail, cp en chmod staan los
- remote() vangt de exitcode, print de serveruitvoer en stopt met diagnose
- backupasserties meten bytes, tarentries en het aantal docker-tags, niet
  een geechode padstring
- fase 5 bewerkt alleen de twee benoemde confs, volgt symlinks met readlink,
  eist per sed-edit dat er iets te doen valt en stopt als de serverconf
  afwijkt van het patroon dat het runbook noemt
- fase 5 verwijdert de 26 frontendbestanden die main weghaalde, afgeleid uit
  git diff --diff-filter=D en niet uit een wildcard; anders serveert try_files
  /compliance/nis2 en /paraid gewoon door. Fase 6 controleert dat ze 404 geven
- --preflight-only schrijft niets meer: 1c en 1d melden alleen
- rollback zet .env terug voor de recreate, test nginx voor de reload, ruimt
  op wat de deploy toevoegde en stopt als een backup ontbreekt
- de secretscan is een taintscan geworden: hij leidt uit de bron af welke
  variabelen een .env-waarde dragen en meldt elke echo daarvan, dus een
  hernoeming glipt er niet meer langs

Runbook: de SDK-precondition is 3.3.0, niet 3.2.1, en die release komt niet
op PyPI omdat er geen trusted publisher staat. De deploy wacht er niet meer
op en neemt de eerste uitweg: PARAMANT_INLINE_RECEIPT_HEADER=1 in .env, de
drie proxy_buffer-regels op /v2/outbound, en een smoketest die een echte
download doet en eist dat het headerblok boven 16 KB uitkomt zonder 502.
Zonder PARAMANT_SMOKE_API_KEY zegt die stap wat hij niet heeft bewezen.
docker-compose.yml heeft geen env_file. .env vult alleen ${VAR} in het
compose-bestand zelf in, dus een variabele zonder eigen regel in x-relay-env
bereikt nooit een container. De vier receipt-variabelen uit #342 hadden die
regel niet, dus fase 1d schreef een vlag die nergens aankwam en fase 6h zou
pas aan het eind sterven, met 3.1.0 al live.

PARAMANT_INLINE_RECEIPT_HEADER en de drie PARAMANT_RECEIPT_-variabelen staan
nu in x-relay-env, allemaal met een lege default: de vlag telt alleen bij
exact 1 en de caps vallen leeg terug op hun ingebouwde getallen. Bewezen met
docker compose config: met de vlag in .env rendert hij op alle vijf de
relays, zonder de vlag op geen enkele.

Fase 4 controleert dat nu op de server voordat er iets herstart wordt, zodat
een ontbrekende doorgifte blokkeert op een leesbare assertie in plaats van op
een smoketest achteraf. Runbook: de regel dat compose byte-identiek is aan
41501bb klopte al niet meer sinds #340 en is vervangen door wat er nu speelt.

Verder uit ronde 2:
- de bufferguard telt binnen elk /v2/outbound-blok in plaats van per bestand.
  Een conf met proxy_buffer_size op /v2/inbound liet het outbound-blok leeg;
  nagespeeld en gedicht met een tweetraps-awk die ook idempotent is
- fase 5b resolvet het doelpad met readlink -f en weigert alles dat buiten
  de docroot uitkomt, met een teller die geassert wordt
- 6h claimt de 16 KB alleen als het headerblok er echt boven zit; anders
  WARN met NOT PROVEN en geen ok-regel, zodat de samenvatting niet suggereert
  dat het bewezen is
- de taintscan erft over een toewijzing, dus s3cr="$tok" telt ook. Een hop,
  bewust, en die grens staat in de test opgeschreven
@Apolloccrypt
Apolloccrypt merged commit c847761 into main Sep 2, 2026
10 checks passed
@Apolloccrypt
Apolloccrypt deleted the feat/deploy-3.1-script branch September 5, 2026 18:56
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