feat(runner): täglicher Aufräumer für das, was abgebrochene Jobs liegen lassen #13

Merged
l.kirchner merged 8 commits from feat/runner-cleanup into main 2026-10-13 11:10:56 +02:00
Member

Täglicher Aufräumer für den instanzweiten Runner (ct/runner.sh). Entschieden in l.kirchner/patchmgr als D64 (b), #patchmgr-decisions: ENTSCHEID uspi38nj7i858djx6wqhtqwibo, Vorschlag ytptxzxth38o9qf74tpd7w757o. Nicht auf den Runnern installiert; das entscheidet Lutz nach dem Merge.

Warum

Ein abgebrochener Job (cancel-in-progress, Timeout) erreicht seinen eigenen Abbau-Schritt nie. Seine Test-Datenbank und seine Arbeitskopien bleiben liegen. Gemessen auf runner-01 am 07.10.2026, nur aus patchmgr:

  • 11 Container,
  • 209 verwaiste Volumes mit 19 GB,
  • 4 Baumkopien von counter-check zu je 1,2 GB.

Die Volumes behebt patchmgr selbst (l.kirchner/patchmgr#106, docker rm -f -v). Was ein Abbruch liegen lässt, kann aber kein Schritt im Job räumen.

Was der PR tut

install/runner-cleanup.sh (installiert als /usr/local/sbin/ci-runner-cleanup):

  • Wem etwas gehört, sagt das Objekt selbst, nicht nur sein Name.
    • Container werden nur über das Label org.luki-net.project=<projekt> gesucht.
    • Danach muss der Name das Muster des Projekts treffen. Ein Container mit Label und fremdem Namen bleibt stehen.
  • Ein Präfix, das kein anderes Projekt teilt.
    • Projektnamen bestehen nur aus a-z und 0-9, ohne Bindestrich. Deshalb kann kein anderes Projekt Namen mit <projekt>- beginnen.
    • Ein Container-Muster ist ^<projekt>- und danach nur Buchstaben, Ziffern und _.-, also ein wörtliches Präfix. Kein Quantor kann den Bindestrich wahlweise machen, keine Alternation den Anker umgehen, und es gibt keinen ungültigen Ausdruck, der still übersprungen würde.
    • Ein tmp-Muster ist <projekt>- und danach nur Buchstaben, Ziffern und _.-*?. Damit nennt es kein Verzeichnis und enthält keine Klammern.
    • Abgewiesen werden damit unter anderem:
      • ^.*, ^patchmgr-x|.*, ^patchmgr-{0}.*, ^patchmgr-[;
      • ?*, *, patchmgr-[a-z]*, 'patchmgr-$(id)';
      • fremde Präfixe und Projektnamen mit Bindestrich.
  • Ein Projekt, eine Datei. Nennen zwei Dateien dasselbe PROJECT, werden beide abgewiesen, und der Lauf endet mit Exit 1.
  • Wem die Projektnamen gehören:
    • Wer den Runner verwaltet, vergibt sie, einmal je Instanz. Die Dateien unter /etc/ci-runner-cleanup.d/ gehören root.
    • Das Label setzt der Job selbst. Glauben schenkt der Aufräumer ihm nur, weil die Verwaltung das Projekt angemeldet hat.
    • Bekannte Grenze, entschieden von Lutz am 11.10.2026: Label und Präfix sagen, zu welchem Projekt etwas gehört, nicht zu welchem Repo. Ein Fork von patchmgr mit unverändertem testdb.sh setzt auf diesem Runner dasselbe Label und dasselbe Präfix. Seine Reste, die älter als MAX_AGE_HOURS sind, würden mit entfernt.
      • Hingenommen, weil patchmgr privat ist, 0 Forks hat und nur Lutz und Bots beitragen. Gemessen am 11.10.2026 über die Gitea-API: private: true, forks_count: 0, /forks leer. Als Mitarbeiter sind nur bot-claude-code, claude-bot, kimi-bot und codex-bot eingetragen. Die Autoren auf main sind nur Lutz und l.kirchner.
      • Auslöser: Öffnet sich das Repo für Forks, setzt deploy/testdb.sh zusätzlich ein Repo-Label, etwa org.luki-net.repository=$GITHUB_REPOSITORY, und der Aufräumer filtert auch danach.
      • tmp-Einträge tragen kein Label. Für sie muss dann auch das Präfix, das counter-check seinem Verzeichnis gibt, das Repo nennen. Bis dahin träfe patchmgr-counter-check-* auch die Kopien eines Forks, die älter als MAX_AGE_HOURS sind.
      • Die Grenze steht im Kopfkommentar von install/runner-cleanup.sh.
  • Die Musterdatei wird gelesen, nicht ausgeführt.
    • Erlaubt sind drei Schlüssel, Werte bloß oder in einfachen Anführungszeichen, dazu Kommentare und Leerzeilen.
    • Ein unbekannter Schlüssel lässt die Datei überspringen, etwa MAX_AGE_HOURS=…. Ein bloßer Wert mit anderen Zeichen als Buchstaben, Ziffern und _^*.- ebenso, etwa $(…).
    • In einfachen Anführungszeichen ist ein Wert wörtlich. Was dort an Zeichen außerhalb der Muster steht, weist die Musterprüfung oben ab.
  • Mindestalter 4 h, aus der gemessenen Job-Zeitgrenze.
    • act_runner v0.2.13 läuft auf runner-01 bis -03 ohne Konfigurationsdatei (act_runner daemon, kein --config).
      • LoadDefault setzt dann runner.timeout auf 3 h (config.go:114f.), angewandt je Job in runner.go:99.
      • capacity ist ebenfalls Vorgabe, also 1.
      • Gemessen am 08.10.2026, nur lesend. Beleg im D64-Faden: izdmnk3xjjrnp8fzsd6oj1udio. Die Grenze auf Gitea-Seite ist nicht gemessen.
    • Ein Container ist so alt wie das spätere von Created und State.StartedAt. Einen Container, den ein Job wieder gestartet hat, zählt der Aufräumer ab diesem Start.
    • Ein tmp-Eintrag ist so alt wie seine ctime, nicht seine mtime.
      • cp -a, rsync -a und tar -x geben einer frischen Kopie eine alte mtime.
      • Eine ctime kann ein Prozess nicht zurücksetzen.
    • Älter als 4 h heißt also: von keinem Job angelegt oder gestartet, der noch in seiner Zeitgrenze läuft.
    • Bedingung an das Projekt:
      • Es meldet nur Container an, die seine Jobs selbst mit docker run anlegen und nie wieder starten.
      • Startete ein Job einen vorhandenen Container, könnte das zwischen der Altersprüfung und dem rm geschehen; dieses Rennen kann das Skript nicht schließen.
      • Ebenso meldet es nur tmp-Einträge an, die seine Jobs je Lauf frisch anlegen (mktemp) und nie wieder benutzen. Arbeitete ein Job in einem vorhandenen Verzeichnis, könnte das zwischen Altersprüfung und rm geschehen, oder ganz ohne die ctime des Verzeichnisses zu berühren.
      • patchmgr legt je Lauf einen neuen Container an: testdb.sh start entfernt eine Waise desselben Namens und startet dann mit docker run.
      • counter-check legt sein Arbeitsverzeichnis je Lauf mit os.MkdirTemp als patchmgr-counter-check-* an und entfernt es am Ende selbst.
    • MAX_AGE_HOURS ist per Vorgabe 4 und muss eine ganze Zahl von 4 bis 999 sein, ohne führende Null. Ohne Obergrenze liefe eine genügend große Zahl beim Umrechnen in Sekunden über, auch auf 0 (etwa 4611686018427387904). 999 h liegen weit darunter und sind länger als jedes sinnvolle Alter. Eine führende Null läse bash als Oktalzahl.
  • DRY_RUN: leer oder 0 entfernt wirklich, jeder andere Wert listet nur. Der Test belegt 1, true, yes (nur Liste) und 0, leer (entfernt).
  • Container gehen mit docker rm -f -v, also mit ihren anonymen Volumes. Benannte Volumes bleiben.
  • Kein docker prune: Der Runner arbeitet für jedes Repo der Instanz.
  • Fehler:
    • Jedes Projekt läuft in einer eigenen Subshell, darin mit errexit.
    • docker, date, find, stat und rm werden zusätzlich ausdrücklich geprüft.
    • Scheitert etwas, laufen die anderen Projekte weiter, und der Lauf endet mit Exit 1.
  • LC_ALL=C, damit [a-z] nicht von der Locale abhängt.

Die Gegenseite in patchmgr: l.kirchner/patchmgr#110, gemergt in bef0e83. In Runde 2 haben codex und grok APPROVE gegeben.

  • deploy/testdb.sh start setzt am docker run --label org.luki-net.project=patchmgr. Der Name bleibt patchmgr-testdb-<lauf>-<slot>.
    • TestATestDatabaseSaysWhoseItIs vergleicht das Label als ganzes Argument. …=patchmgr-other gilt also nicht; die Gegenprobe dazu ist rot.
  • counter-check legt sein Arbeitsverzeichnis als patchmgr-counter-check-* unter TMPDIR an, bei act-runner also in dessen PrivateTmp.
    • TestRunRemovesItsCopiesAtTheEnd prüft das Präfix.
    • CI 3905 von #110 lief mit dem neuen Präfix.
  • make test-apt startet einen Container mit docker run --rm, ohne Label und ohne passenden Namen. Der Aufräumer nimmt ihn nicht, und --rm räumt ihn selbst ab.

Muster je Projekt als eigene Datei unter /etc/ci-runner-cleanup.d/. Dieser PR legt patchmgr.conf an:

PROJECT=patchmgr
CONTAINER_NAME_PATTERN='^patchmgr-testdb-'
TMP_PATTERN='patchmgr-counter-check-*'

Ein weiteres Projekt meldet sich mit einer eigenen Datei an, ohne das Skript zu ändern.

install/runner-install.sh lädt das Skript wie nexus-runtime.sh über ${LIB_URL%/lib}/install/….

  • Es schreibt ci-runner-cleanup.service (oneshot) und ci-runner-cleanup.timer (täglich, RandomizedDelaySec=1h, Persistent=true).
  • Aktiviert wird nur der Timer; der erste Lauf kommt mit ihm, nicht mit der Installation.
  • /root/runner.credentials nennt den Aufräumer.

tests/test_runner_cleanup.sh, eingebunden in ci.yml. Er arbeitet ohne Docker und ohne root, mit einem eigenen tmp-Wurzelpfad.

  • docker und stat sind nachgebaut und antworten aus Tabellen. stat ist nachgebaut, weil sich eine ctime nicht zurückdatieren lässt.
  • find und rm sind echt, aber nur unter dem tmp-Wurzelpfad und für die eigene Listen-Datei des Aufräumers. Die legt mktemp im Test unter einem Verzeichnis des Tests an (TMPDIR), nicht in /tmp. Ein Test prüft, dass dort am Ende nichts liegen bleibt.
  • Die Einrichtung bricht ab, wenn ein Ersatz nicht ausführbar ist. So kann nie das echte docker antworten.

Seit Runde 1

codex, Blocker:

  • Laufende Jobs waren nur übers Alter geschützt, und 24 h waren geschätzt. Jetzt gilt das gemessene Mindestalter: 3 h plus Rand.
  • Namensmuster beweisen keinen Eigentümer. Jetzt gelten Label und Name, und die Muster tragen das Projektpräfix.

grok, Hinweise:

  • Muster wie ^.* und ?* werden jetzt abgewiesen.
  • DRY_RUN ist jetzt klar definiert und im Test belegt.

Seit Runde 2

Runde 2 auf 4c48d96: codex und grok REQUEST_CHANGES. Alle Befunde sind in 9f0f8eb behoben.

  • Verzeichnis-mtime (beide): Eine frische Kopie kann eine alte mtime tragen und wäre während eines laufenden Jobs entfernt worden. Jetzt zählt die ctime.
  • Container-Created (codex): Ein wieder gestarteter Container behielt sein altes Alter. Jetzt zählt das spätere von Created und StartedAt. Die Bedingung an das Projekt steht oben.
  • Präfix-Überlappung (codex, grok): Das Projekt patchmgr hätte mit patchmgr-* Einträge eines Projekts patchmgr-other getroffen, und ^patchmgr-testdb-|.* bestand die Präfixprüfung. Jetzt sind Projektnamen ohne Bindestrich, und | ist abgewiesen.
  • Exit-Code (codex): In ( … ) || … schaltet bash errexit ab; ein gescheitertes docker rm ergab Exit 0. Jetzt werden Fehler ausdrücklich gesammelt, und jedes Projekt läuft mit errexit.
  • Hinweise (grok, codex), mit umgesetzt:
    • Eine Musterdatei konnte MAX_AGE_HOURS überschreiben; jetzt wird sie gelesen statt ausgeführt.
    • LC_ALL=C ist gesetzt.
    • Die Test-Einrichtung bricht bei einem Fehler ab.
    • Unit-Beschreibung und Meldung nennen Label, Präfix und Alter.
    • Im README fehlte ein Komma.
  • Aussage im PR-Text berichtigt: „Was älter als 3 h ist, gehört keinem laufenden Job mehr" galt so nicht. Jetzt steht oben genau, was das Alter sagt und was nicht.

Seit Runde 3

Runde 3 auf 9f0f8eb: grok APPROVE, codex REQUEST_CHANGES. Behoben in bb3b870:

  • Überlauf: MAX_AGE_HOURS=4611686018427387904 ergab limit=0. Jetzt sind höchstens drei Ziffern erlaubt, ohne führende Null; das deckt auch grok's Hinweis zur Oktalzahl.
  • Präfix:
    • ^patchmgr-{0}.* machte den Bindestrich wahlweise.
    • ^patchmgr-[ wurde als ungültiger Ausdruck still übersprungen.
    • 'patchmgr-$(id)' wurde angenommen, und der PR-Text behauptete das Gegenteil.
    • Jetzt sind beide Muster ein wörtliches Präfix plus eine kleine Zeichenmenge.
  • Doppelte Anmeldung: Zwei Dateien konnten dasselbe PROJECT beanspruchen. Jetzt werden beide abgewiesen. Wer die Namen vergibt und was ein Fork mit gleichem Label bedeutet, steht oben.
  • Neustart zwischen Altersprüfung und rm: Das lässt sich im Skript nicht schließen. Die Bedingung an das Projekt sagt es jetzt ausdrücklich: nur Container, die die Jobs selbst anlegen und nie wieder starten. patchmgr erfüllt das.
  • Hinweis (grok): Ein Probelauf meldete removed for …: 0. Jetzt meldet er none (DRY_RUN, …).

Seit Runde 4

Runde 4 auf bb3b870: grok APPROVE, codex REQUEST_CHANGES mit zwei Befunden.

  • tmp-Wiederverwendung: Die Bedingung an das Projekt nannte nur Container. Ein Job, der ein vorhandenes Verzeichnis weiterbenutzt, ist über die ctime nicht geschützt. Jetzt gilt die Bedingung auch für tmp-Einträge, im Skript-Kommentar und oben (ea2c85b).
  • Fork mit gleichem Label: Das ist jetzt eine benannte und entschiedene Grenze, kein offener Befund (0a6517d). Begründung, Messung und Auslöser stehen oben unter „Wem die Projektnamen gehören".
  • Beide Commits ändern nur Kommentare. Das Verhalten des Skripts ist dasselbe wie auf bb3b870.

Seit Runde 5

Runde 5 auf 0a6517d: grok APPROVE, codex REQUEST_CHANGES. Behoben in d856593, nur Kommentare, README und PR-Text:

  • Blocker (codex): Die Begründung der Obergrenze von MAX_AGE_HOURS stimmte nicht. Mit „eine größere Zahl liefe über" war jede Zahl über 999 gemeint, aber 1000 * 3600 läuft nicht über. Die Obergrenze verhindert den Überlauf durch sehr große Eingaben. So steht es jetzt im Skript-Kommentar und oben.
  • Hinweis (grok): Der Auslöser der Fork-Grenze nannte nur das Repo-Label in testdb.sh. tmp-Einträge tragen kein Label, also muss dann auch ihr Präfix das Repo nennen. Das ist ergänzt, im Kommentar und oben.
  • Hinweis (grok): Die README-Zeile steht neben „in production" und sagte nicht, dass ältere Runner den Aufräumer erst haben, wenn er dort eingerichtet wird. Das ist ergänzt.
  • Hinweis (grok), nicht geändert: Nach dem Pflichtpräfix wirkt . im Container-Muster als beliebiges Zeichen. Treffer bleiben unter Label und ^<projekt>-; das angemeldete Muster ^patchmgr-testdb- enthält keinen Punkt.
  • Hinweis (grok), nicht geändert: Ob Persistent=true beim ersten Aktivieren des Timers sofort einen Lauf auslöst, ist nicht gemessen. Ein solcher Lauf wäre echt. Er ist durch Label, Präfix und Alter begrenzt wie jeder andere.

Seit Runde 5b

Runde 5b auf d856593: codex REQUEST_CHANGES. grok brach ohne Schlusszeile ab; das zählt nicht als Urteil, und der Lauf wird wiederholt.

  • Blocker (codex): Der PR-Text sagte, find und rm wirkten im Test nur unter dem tmp-Wurzelpfad. Die Listen-Datei, die der Aufräumer mit mktemp anlegt und mit rm -f entfernt, lag aber im geerbten TMPDIR, auf dem Runner also in dessen /tmp. Fremde Daten waren nicht betroffen, nur diese eigene Datei. Jetzt setzt der Test TMPDIR auf ein eigenes Verzeichnis und prüft am Ende, dass dort nichts liegen bleibt (e292a5e). Das Skript selbst ist unverändert.

Prüfungen

  • bash -n und shellcheck -S warning -e SC1090,SC1091 über die drei geänderten Skripte lokal auf e292a5e: sauber.
  • bash tests/test_runner_cleanup.sh lokal auf e292a5e: 46 von 46 ok.
  • Gegenproben auf e292a5e in einem eigenen Worktree, je eine Mutation in install/runner-cleanup.sh: 21 von 21 rot, jede im zugehörigen Test. Die Mutationen:
    • Alter nach mtime;
    • Container nach Created statt letztem Start;
    • Projektname mit Bindestrich;
    • Regex nach dem Präfix erlaubt;
    • Container-Präfix nicht geprüft;
    • beliebige Zeichen im tmp-Muster;
    • tmp-Präfix nicht geprüft;
    • Datei ausgeführt statt gelesen;
    • gescheitertes docker rm ohne Folgen;
    • ein Fehler hält die anderen Projekte auf;
    • doppeltes Projekt angenommen;
    • Suche nach Name statt Label;
    • Name nicht geprüft;
    • ohne -v;
    • DRY_RUN=0 als Probelauf;
    • MAX_AGE_HOURS=3 erlaubt;
    • MAX_AGE_HOURS ohne Obergrenze;
    • ohne Altersgrenze, für tmp und für Container;
    • ein Probelauf meldet eine Entfernung;
    • die eigene Listen-Datei bleibt liegen.
  • CI 3995 auf e292a5e: grün, Job 8121 auf runner-01. tests/test_runner_cleanup.sh meldet dort pass: 46, fail: 0. shellcheck ist auf dem Runner nicht installiert und wurde dort übersprungen, wie dokumentiert (K-114).

Nicht Teil dieses PRs

  • Installation auf runner-01/02/03.
  • Container ohne Label und Kopien unter dem alten Namen counter-check-*, also von patchmgr-Läufen vor l.kirchner/patchmgr#110, erkennt der Aufräumer nicht. Auf runner-01 wurde am 07.10. einmal von Hand aufgeräumt (D64 d).
  • Verwaiste Volumes anderer Projekte: Auf runner-01 liegen 351 aus pharos, siehe luki-net/pharos-platform#212. Ohne Label und angemeldete Datei rührt das Skript sie nicht an.
Täglicher Aufräumer für den instanzweiten Runner (`ct/runner.sh`). Entschieden in l.kirchner/patchmgr als **D64 (b)**, `#patchmgr-decisions`: ENTSCHEID `uspi38nj7i858djx6wqhtqwibo`, Vorschlag `ytptxzxth38o9qf74tpd7w757o`. **Nicht auf den Runnern installiert**; das entscheidet Lutz nach dem Merge. ## Warum Ein abgebrochener Job (`cancel-in-progress`, Timeout) erreicht seinen eigenen Abbau-Schritt nie. Seine Test-Datenbank und seine Arbeitskopien bleiben liegen. Gemessen auf runner-01 am 07.10.2026, nur aus patchmgr: - 11 Container, - 209 verwaiste Volumes mit 19 GB, - 4 Baumkopien von counter-check zu je 1,2 GB. Die Volumes behebt patchmgr selbst (l.kirchner/patchmgr#106, `docker rm -f -v`). Was ein Abbruch liegen lässt, kann aber kein Schritt im Job räumen. ## Was der PR tut **`install/runner-cleanup.sh`** (installiert als `/usr/local/sbin/ci-runner-cleanup`): - **Wem etwas gehört, sagt das Objekt selbst, nicht nur sein Name.** - Container werden nur über das Label `org.luki-net.project=<projekt>` gesucht. - Danach muss der Name das Muster des Projekts treffen. Ein Container mit Label und fremdem Namen bleibt stehen. - **Ein Präfix, das kein anderes Projekt teilt.** - Projektnamen bestehen nur aus `a-z` und `0-9`, ohne Bindestrich. Deshalb kann kein anderes Projekt Namen mit `<projekt>-` beginnen. - Ein Container-Muster ist `^<projekt>-` und danach nur Buchstaben, Ziffern und `_.-`, also ein wörtliches Präfix. Kein Quantor kann den Bindestrich wahlweise machen, keine Alternation den Anker umgehen, und es gibt keinen ungültigen Ausdruck, der still übersprungen würde. - Ein tmp-Muster ist `<projekt>-` und danach nur Buchstaben, Ziffern und `_.-*?`. Damit nennt es kein Verzeichnis und enthält keine Klammern. - Abgewiesen werden damit unter anderem: - `^.*`, `^patchmgr-x|.*`, `^patchmgr-{0}.*`, `^patchmgr-[`; - `?*`, `*`, `patchmgr-[a-z]*`, `'patchmgr-$(id)'`; - fremde Präfixe und Projektnamen mit Bindestrich. - **Ein Projekt, eine Datei.** Nennen zwei Dateien dasselbe `PROJECT`, werden beide abgewiesen, und der Lauf endet mit Exit 1. - **Wem die Projektnamen gehören:** - Wer den Runner verwaltet, vergibt sie, einmal je Instanz. Die Dateien unter `/etc/ci-runner-cleanup.d/` gehören root. - Das Label setzt der Job selbst. Glauben schenkt der Aufräumer ihm nur, weil die Verwaltung das Projekt angemeldet hat. - **Bekannte Grenze, entschieden von Lutz am 11.10.2026:** Label und Präfix sagen, zu welchem Projekt etwas gehört, nicht zu welchem Repo. Ein Fork von patchmgr mit unverändertem `testdb.sh` setzt auf diesem Runner dasselbe Label und dasselbe Präfix. Seine Reste, die älter als `MAX_AGE_HOURS` sind, würden mit entfernt. - Hingenommen, weil patchmgr privat ist, 0 Forks hat und nur Lutz und Bots beitragen. Gemessen am 11.10.2026 über die Gitea-API: `private: true`, `forks_count: 0`, `/forks` leer. Als Mitarbeiter sind nur `bot-claude-code`, `claude-bot`, `kimi-bot` und `codex-bot` eingetragen. Die Autoren auf `main` sind nur Lutz und `l.kirchner`. - **Auslöser:** Öffnet sich das Repo für Forks, setzt `deploy/testdb.sh` zusätzlich ein Repo-Label, etwa `org.luki-net.repository=$GITHUB_REPOSITORY`, und der Aufräumer filtert auch danach. - tmp-Einträge tragen kein Label. Für sie muss dann auch das Präfix, das counter-check seinem Verzeichnis gibt, das Repo nennen. Bis dahin träfe `patchmgr-counter-check-*` auch die Kopien eines Forks, die älter als `MAX_AGE_HOURS` sind. - Die Grenze steht im Kopfkommentar von `install/runner-cleanup.sh`. - **Die Musterdatei wird gelesen, nicht ausgeführt.** - Erlaubt sind drei Schlüssel, Werte bloß oder in einfachen Anführungszeichen, dazu Kommentare und Leerzeilen. - Ein unbekannter Schlüssel lässt die Datei überspringen, etwa `MAX_AGE_HOURS=…`. Ein bloßer Wert mit anderen Zeichen als Buchstaben, Ziffern und `_^*.-` ebenso, etwa `$(…)`. - In einfachen Anführungszeichen ist ein Wert wörtlich. Was dort an Zeichen außerhalb der Muster steht, weist die Musterprüfung oben ab. - **Mindestalter 4 h, aus der gemessenen Job-Zeitgrenze.** - act_runner v0.2.13 läuft auf runner-01 bis -03 ohne Konfigurationsdatei (`act_runner daemon`, kein `--config`). - `LoadDefault` setzt dann `runner.timeout` auf 3 h (`config.go:114f.`), angewandt je Job in `runner.go:99`. - `capacity` ist ebenfalls Vorgabe, also 1. - Gemessen am 08.10.2026, nur lesend. Beleg im D64-Faden: `izdmnk3xjjrnp8fzsd6oj1udio`. Die Grenze auf Gitea-Seite ist nicht gemessen. - Ein **Container** ist so alt wie das spätere von `Created` und `State.StartedAt`. Einen Container, den ein Job wieder gestartet hat, zählt der Aufräumer ab diesem Start. - Ein **tmp-Eintrag** ist so alt wie seine **ctime**, nicht seine mtime. - `cp -a`, `rsync -a` und `tar -x` geben einer frischen Kopie eine alte mtime. - Eine ctime kann ein Prozess nicht zurücksetzen. - Älter als 4 h heißt also: von keinem Job angelegt oder gestartet, der noch in seiner Zeitgrenze läuft. - **Bedingung an das Projekt:** - Es meldet nur Container an, die seine Jobs selbst mit `docker run` anlegen und nie wieder starten. - Startete ein Job einen vorhandenen Container, könnte das zwischen der Altersprüfung und dem `rm` geschehen; dieses Rennen kann das Skript nicht schließen. - Ebenso meldet es nur tmp-Einträge an, die seine Jobs je Lauf frisch anlegen (`mktemp`) und nie wieder benutzen. Arbeitete ein Job in einem vorhandenen Verzeichnis, könnte das zwischen Altersprüfung und `rm` geschehen, oder ganz ohne die ctime des Verzeichnisses zu berühren. - patchmgr legt je Lauf einen neuen Container an: `testdb.sh start` entfernt eine Waise desselben Namens und startet dann mit `docker run`. - counter-check legt sein Arbeitsverzeichnis je Lauf mit `os.MkdirTemp` als `patchmgr-counter-check-*` an und entfernt es am Ende selbst. - `MAX_AGE_HOURS` ist per Vorgabe 4 und muss eine ganze Zahl von 4 bis 999 sein, ohne führende Null. Ohne Obergrenze liefe eine genügend große Zahl beim Umrechnen in Sekunden über, auch auf 0 (etwa `4611686018427387904`). 999 h liegen weit darunter und sind länger als jedes sinnvolle Alter. Eine führende Null läse bash als Oktalzahl. - **`DRY_RUN`:** leer oder `0` entfernt wirklich, **jeder andere Wert** listet nur. Der Test belegt `1`, `true`, `yes` (nur Liste) und `0`, leer (entfernt). - Container gehen mit `docker rm -f -v`, also mit ihren anonymen Volumes. Benannte Volumes bleiben. - **Kein `docker prune`**: Der Runner arbeitet für jedes Repo der Instanz. - **Fehler:** - Jedes Projekt läuft in einer eigenen Subshell, darin mit `errexit`. - `docker`, `date`, `find`, `stat` und `rm` werden zusätzlich ausdrücklich geprüft. - Scheitert etwas, laufen die anderen Projekte weiter, und der Lauf endet mit Exit 1. - `LC_ALL=C`, damit `[a-z]` nicht von der Locale abhängt. **Die Gegenseite in patchmgr**: l.kirchner/patchmgr#110, gemergt in `bef0e83`. In Runde 2 haben codex und grok APPROVE gegeben. - `deploy/testdb.sh start` setzt am `docker run` `--label org.luki-net.project=patchmgr`. Der Name bleibt `patchmgr-testdb-<lauf>-<slot>`. - `TestATestDatabaseSaysWhoseItIs` vergleicht das Label als ganzes Argument. `…=patchmgr-other` gilt also nicht; die Gegenprobe dazu ist rot. - counter-check legt sein Arbeitsverzeichnis als `patchmgr-counter-check-*` unter `TMPDIR` an, bei act-runner also in dessen PrivateTmp. - `TestRunRemovesItsCopiesAtTheEnd` prüft das Präfix. - CI 3905 von #110 lief mit dem neuen Präfix. - `make test-apt` startet einen Container mit `docker run --rm`, ohne Label und ohne passenden Namen. Der Aufräumer nimmt ihn nicht, und `--rm` räumt ihn selbst ab. **Muster je Projekt** als eigene Datei unter `/etc/ci-runner-cleanup.d/`. Dieser PR legt `patchmgr.conf` an: ``` PROJECT=patchmgr CONTAINER_NAME_PATTERN='^patchmgr-testdb-' TMP_PATTERN='patchmgr-counter-check-*' ``` Ein weiteres Projekt meldet sich mit einer eigenen Datei an, ohne das Skript zu ändern. **`install/runner-install.sh`** lädt das Skript wie `nexus-runtime.sh` über `${LIB_URL%/lib}/install/…`. - Es schreibt `ci-runner-cleanup.service` (oneshot) und `ci-runner-cleanup.timer` (täglich, `RandomizedDelaySec=1h`, `Persistent=true`). - Aktiviert wird **nur der Timer**; der erste Lauf kommt mit ihm, nicht mit der Installation. - `/root/runner.credentials` nennt den Aufräumer. **`tests/test_runner_cleanup.sh`**, eingebunden in `ci.yml`. Er arbeitet ohne Docker und ohne root, mit einem eigenen tmp-Wurzelpfad. - `docker` und `stat` sind nachgebaut und antworten aus Tabellen. `stat` ist nachgebaut, weil sich eine ctime nicht zurückdatieren lässt. - `find` und `rm` sind echt, aber nur unter dem tmp-Wurzelpfad und für die eigene Listen-Datei des Aufräumers. Die legt `mktemp` im Test unter einem Verzeichnis des Tests an (`TMPDIR`), nicht in `/tmp`. Ein Test prüft, dass dort am Ende nichts liegen bleibt. - Die Einrichtung bricht ab, wenn ein Ersatz nicht ausführbar ist. So kann nie das echte `docker` antworten. ## Seit Runde 1 **codex, Blocker:** - Laufende Jobs waren nur übers Alter geschützt, und 24 h waren geschätzt. Jetzt gilt das gemessene Mindestalter: 3 h plus Rand. - Namensmuster beweisen keinen Eigentümer. Jetzt gelten Label und Name, und die Muster tragen das Projektpräfix. **grok, Hinweise:** - Muster wie `^.*` und `?*` werden jetzt abgewiesen. - `DRY_RUN` ist jetzt klar definiert und im Test belegt. ## Seit Runde 2 Runde 2 auf `4c48d96`: codex und grok REQUEST_CHANGES. Alle Befunde sind in `9f0f8eb` behoben. - **Verzeichnis-mtime (beide):** Eine frische Kopie kann eine alte mtime tragen und wäre während eines laufenden Jobs entfernt worden. Jetzt zählt die ctime. - **Container-`Created` (codex):** Ein wieder gestarteter Container behielt sein altes Alter. Jetzt zählt das spätere von `Created` und `StartedAt`. Die Bedingung an das Projekt steht oben. - **Präfix-Überlappung (codex, grok):** Das Projekt `patchmgr` hätte mit `patchmgr-*` Einträge eines Projekts `patchmgr-other` getroffen, und `^patchmgr-testdb-|.*` bestand die Präfixprüfung. Jetzt sind Projektnamen ohne Bindestrich, und `|` ist abgewiesen. - **Exit-Code (codex):** In `( … ) || …` schaltet bash `errexit` ab; ein gescheitertes `docker rm` ergab Exit 0. Jetzt werden Fehler ausdrücklich gesammelt, und jedes Projekt läuft mit `errexit`. - **Hinweise (grok, codex), mit umgesetzt:** - Eine Musterdatei konnte `MAX_AGE_HOURS` überschreiben; jetzt wird sie gelesen statt ausgeführt. - `LC_ALL=C` ist gesetzt. - Die Test-Einrichtung bricht bei einem Fehler ab. - Unit-Beschreibung und Meldung nennen Label, Präfix und Alter. - Im README fehlte ein Komma. - **Aussage im PR-Text berichtigt:** „Was älter als 3 h ist, gehört keinem laufenden Job mehr" galt so nicht. Jetzt steht oben genau, was das Alter sagt und was nicht. ## Seit Runde 3 Runde 3 auf `9f0f8eb`: grok APPROVE, codex REQUEST_CHANGES. Behoben in `bb3b870`: - **Überlauf:** `MAX_AGE_HOURS=4611686018427387904` ergab `limit=0`. Jetzt sind höchstens drei Ziffern erlaubt, ohne führende Null; das deckt auch grok's Hinweis zur Oktalzahl. - **Präfix:** - `^patchmgr-{0}.*` machte den Bindestrich wahlweise. - `^patchmgr-[` wurde als ungültiger Ausdruck still übersprungen. - `'patchmgr-$(id)'` wurde angenommen, und der PR-Text behauptete das Gegenteil. - Jetzt sind beide Muster ein wörtliches Präfix plus eine kleine Zeichenmenge. - **Doppelte Anmeldung:** Zwei Dateien konnten dasselbe `PROJECT` beanspruchen. Jetzt werden beide abgewiesen. Wer die Namen vergibt und was ein Fork mit gleichem Label bedeutet, steht oben. - **Neustart zwischen Altersprüfung und `rm`:** Das lässt sich im Skript nicht schließen. Die Bedingung an das Projekt sagt es jetzt ausdrücklich: nur Container, die die Jobs selbst anlegen und nie wieder starten. patchmgr erfüllt das. - **Hinweis (grok):** Ein Probelauf meldete `removed for …: 0`. Jetzt meldet er `none (DRY_RUN, …)`. ## Seit Runde 4 Runde 4 auf `bb3b870`: grok APPROVE, codex REQUEST_CHANGES mit zwei Befunden. - **tmp-Wiederverwendung:** Die Bedingung an das Projekt nannte nur Container. Ein Job, der ein vorhandenes Verzeichnis weiterbenutzt, ist über die ctime nicht geschützt. Jetzt gilt die Bedingung auch für tmp-Einträge, im Skript-Kommentar und oben (`ea2c85b`). - **Fork mit gleichem Label:** Das ist jetzt eine benannte und entschiedene Grenze, kein offener Befund (`0a6517d`). Begründung, Messung und Auslöser stehen oben unter „Wem die Projektnamen gehören". - Beide Commits ändern nur Kommentare. Das Verhalten des Skripts ist dasselbe wie auf `bb3b870`. ## Seit Runde 5 Runde 5 auf `0a6517d`: grok APPROVE, codex REQUEST_CHANGES. Behoben in `d856593`, nur Kommentare, README und PR-Text: - **Blocker (codex):** Die Begründung der Obergrenze von `MAX_AGE_HOURS` stimmte nicht. Mit „eine größere Zahl liefe über" war jede Zahl über 999 gemeint, aber `1000 * 3600` läuft nicht über. Die Obergrenze verhindert den Überlauf durch sehr große Eingaben. So steht es jetzt im Skript-Kommentar und oben. - **Hinweis (grok):** Der Auslöser der Fork-Grenze nannte nur das Repo-Label in `testdb.sh`. tmp-Einträge tragen kein Label, also muss dann auch ihr Präfix das Repo nennen. Das ist ergänzt, im Kommentar und oben. - **Hinweis (grok):** Die README-Zeile steht neben „in production" und sagte nicht, dass ältere Runner den Aufräumer erst haben, wenn er dort eingerichtet wird. Das ist ergänzt. - **Hinweis (grok), nicht geändert:** Nach dem Pflichtpräfix wirkt `.` im Container-Muster als beliebiges Zeichen. Treffer bleiben unter Label und `^<projekt>-`; das angemeldete Muster `^patchmgr-testdb-` enthält keinen Punkt. - **Hinweis (grok), nicht geändert:** Ob `Persistent=true` beim ersten Aktivieren des Timers sofort einen Lauf auslöst, ist nicht gemessen. Ein solcher Lauf wäre echt. Er ist durch Label, Präfix und Alter begrenzt wie jeder andere. ## Seit Runde 5b Runde 5b auf `d856593`: codex REQUEST_CHANGES. grok brach ohne Schlusszeile ab; das zählt nicht als Urteil, und der Lauf wird wiederholt. - **Blocker (codex):** Der PR-Text sagte, `find` und `rm` wirkten im Test nur unter dem tmp-Wurzelpfad. Die Listen-Datei, die der Aufräumer mit `mktemp` anlegt und mit `rm -f` entfernt, lag aber im geerbten `TMPDIR`, auf dem Runner also in dessen `/tmp`. Fremde Daten waren nicht betroffen, nur diese eigene Datei. Jetzt setzt der Test `TMPDIR` auf ein eigenes Verzeichnis und prüft am Ende, dass dort nichts liegen bleibt (`e292a5e`). Das Skript selbst ist unverändert. ## Prüfungen - `bash -n` und `shellcheck -S warning -e SC1090,SC1091` über die drei geänderten Skripte lokal auf `e292a5e`: sauber. - `bash tests/test_runner_cleanup.sh` lokal auf `e292a5e`: **46 von 46 ok**. - **Gegenproben** auf `e292a5e` in einem eigenen Worktree, je eine Mutation in `install/runner-cleanup.sh`: **21 von 21 rot**, jede im zugehörigen Test. Die Mutationen: - Alter nach mtime; - Container nach `Created` statt letztem Start; - Projektname mit Bindestrich; - Regex nach dem Präfix erlaubt; - Container-Präfix nicht geprüft; - beliebige Zeichen im tmp-Muster; - tmp-Präfix nicht geprüft; - Datei ausgeführt statt gelesen; - gescheitertes `docker rm` ohne Folgen; - ein Fehler hält die anderen Projekte auf; - doppeltes Projekt angenommen; - Suche nach Name statt Label; - Name nicht geprüft; - ohne `-v`; - `DRY_RUN=0` als Probelauf; - `MAX_AGE_HOURS=3` erlaubt; - `MAX_AGE_HOURS` ohne Obergrenze; - ohne Altersgrenze, für tmp und für Container; - ein Probelauf meldet eine Entfernung; - die eigene Listen-Datei bleibt liegen. - **CI 3995** auf `e292a5e`: **grün**, Job 8121 auf runner-01. `tests/test_runner_cleanup.sh` meldet dort `pass: 46, fail: 0`. shellcheck ist auf dem Runner nicht installiert und wurde dort übersprungen, wie dokumentiert (K-114). ## Nicht Teil dieses PRs - Installation auf runner-01/02/03. - Container ohne Label und Kopien unter dem alten Namen `counter-check-*`, also von patchmgr-Läufen vor l.kirchner/patchmgr#110, erkennt der Aufräumer nicht. Auf runner-01 wurde am 07.10. einmal von Hand aufgeräumt (D64 d). - Verwaiste Volumes anderer Projekte: Auf runner-01 liegen 351 aus pharos, siehe luki-net/pharos-platform#212. Ohne Label und angemeldete Datei rührt das Skript sie nicht an.
claude-bot added 1 commit 2026-10-07 23:45:46 +02:00
feat(runner): a daily cleaner for what cancelled jobs leave behind
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
6c449d3502
A cancelled job never reaches its own teardown step, so its test database
container and its working copies stay on the runner. Measured on runner-01
on 07.10.2026: 11 containers, 209 volumes (19 GB) and 4 tree copies of
1.2 GB each from l.kirchner/patchmgr alone (its D64).

install/runner-cleanup.sh removes only what a project registers by name
pattern under /etc/ci-runner-cleanup.d/, and only past MAX_AGE_HOURS
(default 24, at least 2): containers with their anonymous volumes
(rm -f -v), and entries in act-runner's private /tmp. No docker prune --
this runner works for every repository of the instance. Unanchored
container patterns and too wide tmp patterns are skipped; DRY_RUN=1 lists.

runner-install.sh installs it as /usr/local/sbin/ci-runner-cleanup with a
daily timer and patchmgr's pattern file. Only the timer is enabled; the
first run comes with it. tests/test_runner_cleanup.sh runs it against a
stand-in docker in CI.
l.kirchner added 1 commit 2026-10-10 11:59:06 +02:00
fix(runner): the cleaner takes only what a project proves is its own
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
4c48d96645
Review of #13: a name pattern proves no owner on a shared runner, and age
alone did not keep running jobs safe without a measured job limit.

- A project file names PROJECT, CONTAINER_NAME_PATTERN and TMP_PATTERN.
  Containers are found by the label org.luki-net.project=$PROJECT, which
  the project sets itself, and then matched by name; both patterns must
  begin with the project's own name, so ^.*, ?* or another project's
  prefix are refused. Each file is read in a subshell of its own.
- Minimum age 4 h: act_runner v0.2.13 ends a job after 3 h (runner.timeout;
  no config file on runner-01..03, LoadDefault sets 3 h), and each runner
  runs one job at a time. MAX_AGE_HOURS cannot go to 3 or below.
- DRY_RUN: empty or 0 removes, any other value only lists.
- A project whose cleanup fails does not stop the others; the run's exit
  code says so.
l.kirchner added 1 commit 2026-10-10 12:16:05 +02:00
fix(runner): the cleaner dates by ctime and last start, reads its files
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
9f0f8ebb6f
Review findings on #13. A tmp entry is as old as its ctime: cp -a,
rsync -a and tar -x give a fresh copy an old mtime. A container counts
from the later of its creation and its last start. Project names have no
hyphen, so that "<project>-" is a prefix no other project shares, and a
container pattern has no alternation. The files are read, not sourced.
Failures are collected and make the run exit 1, each project in a
subshell of its own with errexit on.
l.kirchner added 1 commit 2026-10-10 12:35:58 +02:00
fix(runner): literal prefixes, bounded age, one file per project
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 3s
bb3b870db0
Review findings on #13, round 3. A container pattern is ^<project>- and
then only letters, digits and _.-, a tmp pattern <project>- and then only
letters, digits and _.-*?: no quantifier makes the hyphen optional, no
broken regex is skipped in silence. MAX_AGE_HOURS is 4 to 999 without a
leading zero, so the seconds cannot overflow. A project named in two
files is refused in both. A dry run says it removed nothing.
l.kirchner added 2 commits 2026-10-11 01:04:41 +02:00
l.kirchner added 1 commit 2026-10-11 01:16:42 +02:00
docs(runner): what the age bound prevents; forks and tmp prefixes
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 3s
d8565939ac
l.kirchner added 1 commit 2026-10-11 01:19:36 +02:00
test(runner): the cleaner's list file stays inside the test
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 3s
e292a5e614
l.kirchner merged commit 993a83d674 into main 2026-10-13 11:10:56 +02:00
l.kirchner deleted branch feat/runner-cleanup 2026-10-13 11:10:56 +02:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luki-net/proxmox-scripts#13