Reference for the operator and for an engineer changing the stack commands. It names the commands and
the contracts they keep; the commands' own --help and src/hal/stack/, src/hal/ hold the rest. The
picture of the platform, and the state table that owns every guard of stop, start and apply, is
architecture.md (section 8). The sections "Dienste an und aus" and "Notschalter"
are German, the rest English.
~/srv/hal/bin/hal runs hal of the checkout it lives in, from any directory: through
bin/hal-sync it syncs that checkout's locked dependencies (uv sync --locked, never rewriting
uv.lock) and then becomes .venv/bin/hal, from the same environment the systemd units call. A
failed sync ends with exit 3 and an error:/next: pair that names the checkout. It is not on
PATH: the operator links it as hal (hal --help lists its commands).
The first hal apply --local writes .env itself: EDGE_DOMAIN is fleet.yaml's cloudflare.zone
and DOCKER_GID the id of this PC's docker group (Keycloak's secrets are files). .env is local and
never committed. A .env that differs, or lacks one of the two keys, is named and left alone: delete it
and hal apply --local writes it again.
- Start.
docker compose up -d --no-recreate socket-proxy traefikstarts a core service that is not running.hal applynever starts one; it only names a stopped one. Keycloak and its database sit behind the compose profilekeycloak, so a plainup -dstarts neither;hal applyswitches them byservices.keycloak.run.enabledinfleet.yaml.fleet.yamlchanges by pull request and reaches the live checkout byhal update; a local edit leaves the tree dirty and the apply refuses it, naming the path. - The legal unit.
legal(nginx, profilelegal, switched byservices.legal.run.enabled) serves the imprint, the privacy notice and the terms fromlegal/built/forHost: <zone>on127.0.0.1:8081; the stylesheet and appkit's tokens and fonts (legal/assets/appkit-<version>/, whichhal applywrites from the installed appkit) answer under/_rechtliches/, every other path with 404. The catalog gives it the apex as its host, which the Cloudflare redirect leaves to it for the three pages,/favicon.ico(answered 404) and/_rechtliches/(cloudflare.redirect.serve). The pages are built byhal applyfromlegal/frame,legal/sectionsand each app's ownlegal/files at its release (the plan names each page that is new or changes) and go live when it has written them; every answer carriesCache-Control: no-cache, so a cache that stored a page asks again before it shows it; before the first build they answer 404. The stylesheet goes live oncehal updatehas brought the live checkout in; a changedlegal/nginx/default.confneeds the container restarted withhal stop legalandhal start legal. - Readiness. The stack is ready when
127.0.0.1:8081and127.0.0.1:8090listen, an unknown host gets 404 on 8081, and Keycloak answers the master realm on 8090; Keycloak's start takes longest. No command checks these four facts together (a named gap on #222). What proves what today:hal applyobserves the containers running;docker compose psshows their health; the publish phase asks each declared host'sreadypath, only for a plan with changes;keycloak/go-liveprobes 8081 and 8090. - Stop.
docker compose --profile keycloak --profile legal --profile probe down. Without--profile keycloakKeycloak and its database keep running, without--profile probea running probehellostays. It removes no volume; never add--volumes, because that deletes the Keycloak database. - After a reboot socket-proxy, traefik, keycloak-db and keycloak come back on their own
(
restart: unless-stopped), also without the profile on the command line;hellonever does. - Switching Keycloak off (
enabled: falsethrough a pull request,hal update,hal apply) stops and removeskeycloak-dbandkeycloakand keeps the volumekeycloak-db,~/.secrets/keycloakand thekeycloaknetwork; switching it on again uses the same database. Removing the entry fromfleet.yamlis not "off": apply then leaves Keycloak as it is. Whetherauthand127.0.0.1:8090answer depends on Traefik, whichhal applyrecreates only for a changedtraefik.ymlor compose definition and never starts. - Switching an app off at once (
hal stop <app>, no terminal) records the marker, then writes and proves the barrier over the app's catalog hosts, then stops the containers of its Compose project (file-free; volumes, checkout and record stay).hal applynever starts a stopped app: it names it and fetches nothing, refuses the whole run while a container of it still runs, and a stop that arrives during theyes, the build, the start or the health wait wins: the app is not started, or what started is removed, and apply ends partial.hal start <app>(terminal,yes) starts the stopped containers, waits for health, removes the barrier and clears the marker;hal onlineproves the barriers of apps as of units.
What the entrypoint answers without a route: an unknown host or invalid name gets 404; an HTTP/1.1
request without a Host header gets 400 (Go's HTTP server rejects it); an HTTP/1.0 one gets 404.
hello (traefik/whoami, labelled edge.name=hello) and hello-mcp (the same image on mcp-backends,
the MCP origin hello.mcp.internal) live under the compose profile probe, so a plain
docker compose up -d never starts them. Start one with docker compose --profile probe up -d hello;
retire it with stop and rm -f under the same profile. Afterwards hello.<EDGE_DOMAIN> answers 404.
docker compose --profile '*' down removes the containers of every profile (the core, Keycloak,
step-ca, LiteLLM and the gateways) and the project's networks; the listeners on
127.0.0.1:8081 and 127.0.0.1:8090 disappear. It keeps the volumes: keycloak-db holds every
Keycloak user, credential and session and survives the rollback (it and step-ca keep their
names edge_keycloak-db and edge_step-ca, set in compose.yaml). Never run down --volumes. The
socket directory volume is tmpfs and holds nothing; remove its definition alone with
docker volume rm hal_socket-proxy-run. Then revert the commit.
hal's exit codes are in src/hal/grammar.py, its error form in src/hal/output/__init__.py, and hal --help shows both. hal apply ends with 0 (applied or nothing to do), 1 when
the person declined the yes, 4 when a guard said no (no terminal, the apply lock is held, the
checkout is not the live one or not a clean main, a unit is disabled or not set up), 2 for a usage
error, and 3 for a cannot-plan end (also a git that cannot be asked), a failed step and a partial run;
the printed line names which (partial, … after a step ran and a later one failed). apply deviates
from the grammar only by E2 and E3 below, and by the publish: the published exit of the Cloudflare
part stays as it is, so a no at the publish step's own yes still ends 3 until S6-4 of
#296 cuts the publish's closing lines. stop, start, offline and online end
with 0, 2 for a usage error, or with 3 for every outcome of the stack that is not "applied or nothing
to do", a declined yes and a guard's refusal included; an uncaught Ctrl-C ends with 130. hal update
keeps the grammar (its section below). The deviations are:
- E2: a Ctrl-C that the stack catches ends by what the stack then observes, not with 130.
apply,stopandstartprint theirinterrupted …line and end with 3.offlinedrops the interrupt and reports the tunnel as observed: 3 with itspartial …line while the tunnel still runs, 0 when it did stop, the marker is recorded and the boot drop-in is present. A Ctrl-C the stack does not catch ends with 130. - E3: (until #296 A5-3, S6 and S8 print rows) the stack's lines keep the form
<label>: …, noterror:/next::hal apply: …,hal start: …,hal offline: …and, for the publish,cloudflare: …; a number in such a line is the stack's own, not hal's process exit.
hal setup deviates in one way:
- E4:
hal setupprints five rows and a verdict, and a stop as anerror:/next:pair (docs/home.md,hal setup); it shows its plan and asks oneyesbefore it acts (--dry-runonly shows); it ends with 0 when nothing failed or nothing was open, 1 when theyeswas declined, 3 for a failed step, 4 without a terminal (after the plan, before any step). Ansible's own stream stays until its output step; the password is the system's ownsudoprompt, never Ansible's.
fleet.yaml ist der Katalog. Ein Eintrag mit run und ohne repo ist eine Einheit dieses
Repositorys; sein Schluessel ist der Name des Compose-Profils (docs/naming.md). run.enabled: false
schaltet das Profil ab; run.setup nennt optional den Befehl, der den Schluessel macht (step-ca/init,
keycloak/go-live). Wer die Aenderung in welchem Zustand darf, steht in der Zustandstabelle von
architecture.md (Abschnitt 8) und wird hier nicht wiederholt.
Zwei Teile. Das blosse hal apply fuehrt den lokalen Teil (hal apply --local) aus und, nur
wenn der mit Exit 0 endete, die Publish-Phase (hal apply's publish, external.md). Jeder
Teil fragt sein eigenes yes und nur, wenn er etwas zu aendern hat; gibt es nirgends etwas zu aendern,
fragt hal apply nichts. Endet der lokale Teil nicht mit 0, steht eine Zeile "nothing published", es
laeuft kein tofu, und der Exit ist der des lokalen Teils. Ein Ctrl-C (SIGINT) vor der Publish-Phase
endet mit Exit 3 und "interrupted, nothing published"; waehrend der Publish-Phase gelten deren
Unterbrechungsregeln, und ihr Exit bleibt, sobald sie ihr Urteil festgehalten hat. Kommt eine
Unterbrechung ohne festgehaltenen Exit aus ihr heraus, endet es mit Exit 3 und der Zeile
"run hal check hosts". Ein SIGTERM (kill) vor der Publish-Phase beendet den Prozess
wie bei --local: nichts wird veroeffentlicht. Nur hal apply --local endet nach dem yes sauber auf
dem Urteil ✓ applied · N changes · <Zeit> (N die Zahl, die das yes nannte); das blosse hal apply
druckt es nicht und endet mit hal apply: done.
Der lokale Teil. Er bringt jede Einheit in den Zustand, den enabled sagt. An:
docker compose --profile <Einheit> up -d fuer genau die Dienste des Profils. Aus: stop, dann rm -f
derselben Dienste; nie down, nie -v, nie ein Volume (R1). Er zeigt den Plan, laeuft nur an einem
Terminal und wendet ihn erst nach der Eingabe yes an (alles andere: Abbruch, Exit 1); danach liest er
die Container noch einmal und meldet nur, was er sieht. Ein zweiter Lauf meldet nothing to change.
"An" heisst: jeder Dienst der Einheit hat seinen Container laufen (oder ein Einmal-Dienst ist fertig),
nicht, dass er gesund ist; die Gesundheit zeigt docker compose ps.
Wo und woraus er laeuft. Vor jeder Pruefung, auch der des Terminals, liest hal apply
~/.config/hal/config.toml; eine ungueltige Datei, in jeder Tabelle, beendet es mit Exit 2, Pfad und
Grund (home.md, „Konfiguration“). Nur aus dem Live-Checkout ~/srv/hal als Haupt-Worktree (sonst Exit 4
mit dem Grund), sonst haengte er das Live-Projekt an einen fremden Baum. Dann, jeweils mit nur der
ersten verweigernden Zeile: weder ein anderes hal apply --local noch ein hal update laeuft
(Exit 4; stop und start warten nie darauf), HEAD steht auf main (Exit 4) und der Baum ist
sauber (Exit 4, jeder geaenderte oder unverfolgte Pfad auf eigener Zeile; ignorierte wie .env
zaehlen nicht, "ignoriert" ist nur, was die .gitignore des Repositorys sagt). Git laeuft dabei ohne
Hooks, ohne fsmonitor und ohne die globale Konfiguration.
Der Abruf. Danach holt er main von origin und haelt sich ab da an genau diesen Hash. Nur dieser
Abruf liest die globale Git-Konfiguration, damit er sich wie git pull ueber den gh-Credential-Helper
anmeldet; edge liest, uebergibt und druckt nie ein Credential und verweigert eine URL mit Passwort. Er
schreibt nur Objekte, FETCH_HEAD und origin/main; HEAD, Index und Baum bleiben. Scheitert er (kein
Netz, kein origin, Anmeldung, 120 s), oder hat der Checkout Commits, die origin/main fehlen, oder
ist er daneben gewachsen, endet es mit Exit 3 und aendert nichts. Wartet origin/main mit Commits,
die dem Checkout fehlen, endet es ebenfalls mit Exit 3 und aendert nichts: die Zeile checkout N behind,
dann N commits wait on origin main; nothing changed und next: hal update; ein Apply plant nie mit
Code, der gleich ersetzt wird. Erst danach kommt .env: es wird geschrieben, wenn es fehlt, und sonst
genannt und in Ruhe gelassen, wenn es abweicht (siehe Configure; Exit 3). Der Checkout steht danach als
eine Zeile checkout current, und der Plan wendet HEAD an.
hal update bringt den Live-Checkout ~/srv/hal auf origin/main und die gesperrten Abhaengigkeiten
auf ihren Stand; hal apply plant danach die Einheiten. Es laeuft nur an einem Terminal.
Ablauf. Die Waechter wie beim Apply, jeweils mit nur der ersten verweigernden Zeile (Terminal,
Live-Checkout, apply.lock, sauberes main); .env prueft nur der Apply. Es haelt apply.lock vom
Abruf bis zum Ende, so laufen hal apply und hal update nie gleichzeitig. Es holt main von origin
und zeigt, was wartet: jeden Commit auf eigener Zeile, dann die geaenderten Dateien nach Wirkung (hals Code
und Abhaengigkeiten: der naechste hal-Befehl fuehrt sie aus; unter einem Verzeichnis-Bind: die Dateien
aendern sich an Ort und Stelle, und ob ein Dienst sie vor seinem naechsten Neustart nutzt, haengt vom
Dienst ab; vom naechsten Apply geplant: Datei-Binds, compose.yaml, fleet.yaml; sonstige). Die Gruppen
kommen aus der Konfiguration des Checkouts, nie aus den Containern, und sagen deshalb nichts darueber, was
ein laufender Container eingehaengt hat. Dann fragt es Type yes to update the checkout to <Hash>:.
Nur das genaue yes gilt (alles andere: aborted, nothing changed, Exit 1). Nach dem yes prueft es
noch einmal, dass der Baum ein sauberes main auf dem geplanten HEAD ist und der gemerkte Hash ein
Fast-Forward davon ist (sonst Exit 4, nichts geaendert; kann git dabei nicht antworten, Exit 3). Dann merged es genau diesen vollen Hash
(merge --ff-only in einer eigenen Sitzung, ohne Hooks, ohne ignorierte Dateien zu ueberschreiben),
prueft HEAD, Zweig und Baum, fuehrt bin/hal-sync des gemergten Baums aus
(uv sync --locked in einer eigenen Prozessgruppe, ohne den Lock zu erben) und prueft HEAD noch einmal.
Es endet mit
checkout updated to <Hash> · dependencies synced und next: hal apply. Es schreibt nie applied.json
und importiert nach dem Merge nichts mehr. Steht der Checkout schon auf origin/main, druckt es die Zeile
checkout current, nothing to update und next: hal apply und endet mit 0, ohne Frage.
Exit. Die Codes der Grammatik: 0 aktualisiert oder nichts zu tun, 1 abgelehnt, 4 verweigert (ein
Waechter oder die Pruefung nach dem yes; ein git, das nicht antworten kann, ist
keine Verweigerung), 3 Fehlschlag (dieses git eingeschlossen, auch bei der Pruefung nach dem yes), 130 Ctrl-C. Ab dem Merge endet jeder behandelte
Fehler mit 3 und einer error:-Zeile mit partial, dem vollen Hash und dem Schritt (merge,
check HEAD after the merge, sync the dependencies, check HEAD after the sync,
write the closing verdict), dazu next: hal apply. Ab kurz vor dem Merge bis zum Ende wird ein Ctrl-C
nur vermerkt, nie ausgeloest: es unterbricht kein Warten und keine Zeile, die gerade geschrieben wird;
waehrend des Syncs beendet es zudem dessen ganze Prozessgruppe, bevor der Lock frei wird, aber nur,
solange hal den Anfuehrer der Gruppe noch nicht abgeholt (reaped) hat: bis dahin bleibt die Gruppen-ID
belegt, danach kann sie einem anderen Prozess gehoeren. Den Vermerk liest hal nach jedem Schritt mit
seiner Pruefung und ein letztes Mal kurz vor der letzten Zeile (der Abschlusszeile oder der
error:-Zeile eines Fehlers); ein bis dahin vermerktes Ctrl-C macht diese Zeile zur Unterbrechung mit
130 und denselben Angaben. Ein Ctrl-C ab der letzten Zeile aendert nichts: sie wird ganz geschrieben,
und der Lauf endet mit ihrem Code. "nothing changed" steht nur dort, wo HEAD, Zweig und Baum es
beweisen; ein Ctrl-C bei einem Merge, den git ohne Aenderung verweigert hat, nennt next: hal update.
Ein Ctrl-C vor dem Merge (an der Frage, bei der Pruefung danach) endet mit 130 ohne Aenderung. Ein Sync, der scheitert, laesst HEAD auf
dem Hash; der naechste hal update findet nichts Wartendes, endet mit 0 und nennt den Apply.
Nicht behandelbar. SIGKILL, SIGTERM, SIGHUP und SIGQUIT nach dem Merge beenden den Prozess ohne
Meldung: der Merge bleibt, der Lock ist frei, applied.json unberuehrt, und der naechste bin/hal-Lauf
synct und plant. Ein Stromausfall nach dem Merge laesst ihn ebenso stehen; einer waehrend des Merges kann
einen halb geschriebenen Baum und .git/index.lock hinterlassen. Den Baum verweigert der naechste Apply
als nicht sauber, und an der liegengebliebenen Sperrdatei scheitert der naechste Merge, bis sie entfernt
ist.
Ueber Revisionen hinweg gilt ein Name: bin/hal-sync <Checkout> [<Programm> [<Argument>...]]. Der alte
Code ruft das Skript des neuen Baums auf; die Form aendert sich nur, indem beide Schreibweisen fuer ein
Update nebeneinander gelten. Mit Programm meldet es einen Sync-Fehler selbst (error:/next:, Exit 3) und
ersetzt sich durch das Programm; ohne Programm endet es mit 1 und druckt nichts, die Meldung gehoert dem
Aufrufer.
Restrisiken. Ein Timer-Prozess, der zwischen Merge und Ende des Syncs startet, laeuft mit dem
gemergten Code (editierbare Installation) und alten oder halb synchronisierten Abhaengigkeiten. Ein
Nachkomme des Syncs, der seine Prozessgruppe verlaesst und die Pipes schliesst, koennte das Beenden
ueberleben (uv tut das nicht). bin/hal synct, bevor Python apply.lock nimmt: ein zweites
bin/hal apply oder bin/hal update waehrend eines Updates fuehrt sein eigenes uv sync derselben
.venv aus, bevor es verweigert wird; allein uvs eigene Sperre schuetzt dort (nicht geprueft). Die Timer
rufen .venv/bin/hal direkt; eine Aenderung an src/hal/workstation/jobs.py laesst die Unit-Dateien bis
zum naechsten hal setup veraltet. Das erste Update des Live-Checkouts kommt einmal von Hand
(git -C ~/srv/hal pull --ff-only), weil der dort laufende Code hal update noch nicht kennt.
Wenn die gemergte Revision nicht startet. Ein Sync, der wegen Netz oder Platte scheitert, wiederholt
der naechste hal update oder hal apply. Eine Revision, auf der hal nicht startet, laesst jeden
bin/hal-Lauf vor dem Abruf enden, so dass kein Fix ueber hal ankommt: ein Lock, der nicht synct, endet
mit dem Paar error: cannot sync the live checkout's dependencies / next: uv sync --locked --project ...
und Exit 3; Code, der beim Import scheitert, endet mit Pythons Traceback und Exit 1 (bin/hal ersetzt sich
durch .venv/bin/hal, und hal.cli importiert alle Befehle vor jedem Handler). Die Reparatur ist der eine
Handgriff, der bleibt: den Fix per Pull-Request mergen, git -C ~/srv/hal pull --ff-only holt ihn herein,
hal apply geht weiter. Der Pull und uv sync --locked bleiben sonst nur fuer die Neuinstallation
(home.md, Installation).
Der Kern und die Follower. Welche Dienste hal apply --local neu erstellt, in welcher
Reihenfolge, und wie der Eintrag in applied.json gefuehrt wird:
architecture.md, Abschnitt 8, "The applied record".
Images. Laeuft ein Dienst einer Einheit, die nicht ausgeschaltet wird, auf einem anderen Image, als
compose.yaml jetzt nennt, nennt der Plan es mit ! und tauscht es nicht; der Lauf bleibt
nothing to change. Das gilt fuer step-ca, legal und litellm-front. Eine Ausnahme:
- Keycloak und LiteLLM, jeweils der Dienst und seine Datenbank: der Plan nennt
<dienst> recreate image X → Y · snapshot first. Nach dem Ja nimmthal applyvor dem ersten Neuerstellen der Einheithal backup <einheit> --pre-applyund beweist den Snapshot mithal restore --test; erst dann erzeugt es die Datenbank und dann den Dienst neu, und die Ergebniszeile nennt den Snapshot. Scheitert Backup oder Test, laeuft der alte Container weiter, der Lauf endet partial (Exit 3) und veroeffentlicht nichts. Wird die Einheit vor dem Neuerstellen ausgeschaltet, nennt die Fehlerzeile nur den Snapshot. Laeuft ein neuer Container danach nicht oder bricht Ctrl-C das Neuerstellen ab, nennt die Zeile den Snapshot undhal restore <einheit> --live. Eine ausgeschaltete Einheit wird nicht angefasst, undhal startbehaelt den alten Container. Ein nicht angepinntes, neu gebautes Tag von Keycloak oder LiteLLM wird nur genannt, nicht getauscht; heute ist jedes ihrer Images per Digest angepinnt. - Keycloak und LiteLLM beim Anschalten: fehlt dem Dienst oder seiner Datenbank der Container und
besteht das Volume der Datenbank, ist unbekannt, welches Image die Daten zuletzt las. Nach dem Ja
startet
hal applydann zuerst die Datenbank allein, nimmt und beweist den Snapshot wie oben und startet erst danach den Rest. Scheitert Backup oder Test, wird der Dienst nicht erzeugt, der Lauf endet partial (Exit 3), und die Zeile nennt keinenhal restore: die allein gestartete Datenbank ist die, aus der der Snapshot kommt. Wird die Einheit danach ausgeschaltet oder bricht Ctrl-C vor dem Start des Dienstes ab, nennt die Zeile nur den Snapshot; ab dem Start, der den Dienst erzeugt, auchhal restore <einheit> --live. Besteht der Dienst und fehlt nur die Datenbank, erzeugt der Start nichts, was ein Restore zuruecknaehme: die Zeile nennt dann nur den Snapshot. Eine Teilzeile nennt Snapshot und Rueckweg jeder Einheit des Laufs, die einen hat; ein bewiesener Snapshot steht als Detailzeile unter der Zeile der angeschalteten Einheit (pre-apply snapshot <id> · proven). Ohne Volume, und wenn jeder Container besteht, gibt es keinen Snapshot.
Setup. Eine Einheit mit setup wird erst angeschaltet, wenn <setup> --check Exit 0 gibt. Der
Vertrag von <setup> --check: fragt nichts, druckt kein Geheimnis, legt nichts an, braucht keinen
laufenden Dienst; Exit 0 eingerichtet, 1 nicht eingerichtet (die letzte Zeile ist die blosse Tatsache,
was fehlt, ohne Skriptnamen und ohne FAILED at), 2 es kann es nicht sagen (ebenso die blosse Tatsache).
Eine laufende, im Katalog eingeschaltete Einheit mit setup wird bei jedem Apply geprueft: ist sie nicht
eingerichtet, bekommt sie die Setup-Frage und sperrt das Publizieren. Die Frage kommt vor dem Plan ("Type
yes to run <setup> now"; nur das genaue yes zaehlt, no bricht den ganzen Lauf ab, Exit 1, nichts
geaendert). Nach yes laeuft das Setup in deinem Terminal, ohne Argumente, ohne Zeitlimit, Ausgabe
ungefiltert; die Schluessel macht allein dieser Befehl, hal apply sieht sie nie. Dann prueft --check
noch einmal und der Plan folgt mit seinem eigenen yes. Ein Setup, das scheitert oder per Ctrl-C
unterbrochen wird, schaltet nichts (Exit 3); was es gemacht hat, bleibt auch nach einem no zum Plan
(Loeschen bleibt Handarbeit). Ohne Terminal laeuft nie ein Setup. Eine per hal stop ausgeschaltete,
noch laufende Einheit, die nicht eingerichtet ist, wird abgewiesen (hal stop noch einmal).
Nach den Setups und vor dem Plan prueft hal apply jeden Dienst, den dieser Lauf neu anlegt: nennt er ein
Compose-Secret, dessen Datei fehlt, endet der Lauf vor dem yes mit Exit 4, nichts geaendert; die Zeile
nennt den Pfad, next: hal setup.
Exit. Die Nummern stehen in Exception E. Der Grund steht in der Zeile (ein anderes hal apply oder ein
hal update haelt apply.lock; falscher Checkout oder Zweig, Baum nicht sauber, .env abweichend oder
unlesbar, keine Gruppe docker, cloudflare.zone ungueltig, git nicht befragbar, Abruf gescheitert,
Checkout vor oder neben origin/main, Checkout hinter origin/main (dann mit next: hal update),
Katalog oder Compose ungueltig, traefik.yml oder traefik-mcp.yml fehlt, ein Setup-Check, der es
nicht sagen kann, u. a.).
Das blosse hal apply bricht nach einem lokalen Teil, der nicht mit 0
endete, ab ("nothing published"); die Zahl in "the publish did not end clean (exit N)" ist die der
Publish-Phase, deren 2 dort zu 1 wird. Dasselbe 3 kommt vom uv sync des Starters bin/hal; seine
error:-Zeile nennt das Sync.
Die Befehle sind hal stop|start <Einheit> und hal offline|online; hinter ihnen laufen
hal stop|start <Einheit> und hal offline|online. Welche Zustaende es gibt und wer wann darf: Zustandstabelle in
architecture.md, Abschnitt 8. Hier steht, was die Befehle halten.
hal stop und hal start laufen ueber den installierten Live-Starter (~/.local/bin/hal, der auf
~/srv/hal/bin/hal zeigt).
Pro Einheit. hal stop <Einheit> schreibt zuerst den Schalter, dann
docker compose --profile <Einheit> stop fuer genau die Dienste der Einheit, liest die Container noch
einmal und meldet "switched off" nur, wenn nichts mehr laeuft. Container und Volumes bleiben (kein rm,
kein down, nie socket-proxy oder traefik). Es braucht kein Terminal und kein yes; Agenten
duerfen es aufrufen (R4, D12). hal start <Einheit> hebt den Schalter auf: nur an einem Terminal und
nach dem exakten yes (der Betreiber; bis D14 eine Regel, keine Wand), loescht den Schalter erst, wenn
die Einheit laeuft (Ausnahme: Keycloak und LiteLLM ueber ein bestehendes Datenbank-Volume, s. u.),
und verweigert eine im Katalog abgeschaltete. Hat die Einheit ein run.setup,
fragt es dessen --check vor der Rueckfrage; nicht eingerichtet: Exit 3, Schalter und
Sperre bleiben. Wuerde der Start den Dienst oder die Datenbank von Keycloak oder LiteLLM ohne
Container ueber ein bestehendes Datenbank-Volume erzeugen (auch wenn nur einer von beiden fehlt),
startet hal start gar nichts: die Rueckfrage nennt das Beenden des Schalters, nach dem yes hebt
es nur Schalter und Sperre auf, nennt hal apply, das zuerst den Snapshot nimmt ("Images"), und
endet mit Exit 0. Eine Sperre, die sich nicht beweisbar entfernen laesst, endet wie bei jedem Start
mit Exit 3 und laesst den Schalter stehen. Bis hal apply die Einheit startet, fehlt der Einheit
ein Container und damit sein Router: ihr Host antwortet Traefiks einfaches 404 (ohne
X-Edge-Barrier).
Der Schalter ist eine Datei pro Einheit, ~/.local/state/edge/stopped/<Einheit>, mit der Zeit des
Ausschaltens (ISO 8601). hal apply --local startet eine ausgeschaltete Einheit nie und loescht
ihren Schalter nie ("stays stopped"); sein Ende ist allein hal start. Ein stop waehrend der
Rueckfrage von apply gewinnt (apply liest den Schalter nach dem yes noch einmal, switch.lock).
hal restore <app> --live haelt switch.lock von seiner zweiten Lesung des Schalters bis zum Ende
des Ladens (backup.md): so lange warten hal stop, hal start, hal apply und sein
Realm-Schritt. hal offline wartet nicht; nur seine zweite Notiz des globalen Schalters folgt danach.
Ein stopped/-Verzeichnis, das nicht 0700 ist, setzt hal stop auf 0700 zurueck. Exit 0 ausgeschaltet
oder schon so; 3 teilweise (Schalter notiert, aber ein Container laeuft noch, oder er liess sich nicht
notieren), verweigert oder nicht planbar.
Die Sperre vor Traefik. Was die Sperre ist, wie sie entsteht und was sie deckt:
architecture.md, Abschnitt 8, "The ingress barrier". Hier nur, was dort fehlt:
der Eingang admin auf 127.0.0.1:8090 bleibt offen; hal stop meldet Erfolg erst, wenn
Traefik fuer jeden Host innerhalb von 15 s 404 mit dem Header X-Edge-Barrier: <Einheit>
antwortet, sonst Exit 3 (Schalter und Sperre bleiben); hal start prueft nach dem Entfernen,
dass der Header weg ist. hal apply schreibt oder entfernt nie eine Sperre: eine Einheit, deren Sperre steht, bleibt
aus, auch wenn kein Schalter notiert ist; hal start entfernt die Sperre erst, wenn die Einheit laeuft (ausser bei dem Start, der nichts startet,
s. o.), und loescht den Schalter zuletzt. Der Beweis auf einem Wegwerf-Stack ist probe/switches/probe.py.
Neustart. Jeder lang laufende Dienst hat restart: unless-stopped; Docker startet einen
ausdruecklich gestoppten Container auch nach einem Neustart nicht, und der Schalter bleibt auf der
Platte.
Global. hal offline nimmt den PC sofort vom Internet. Es schreibt zuerst
~/.local/state/edge/global-stop (ein zweites Stop behaelt die erste Zeit) ohne auf switch.lock zu
warten, stoppt cloudflared.service, schreibt den Schalter unter switch.lock noch einmal und meldet
Erfolg erst, wenn systemctl is-active den Tunnel als beendet (inactive oder failed) zeigt. Die
Einheiten laufen lokal weiter, ihre Schalter bleiben. Ohne Terminal laeuft systemctl --no-ask-password.
Exit 3 heisst teilweise: "partial", wenn der Tunnel noch laeuft (Schalter bleibt;
systemctl stop cloudflared), und "incomplete", wenn das Boot-Drop-in 30-edge-global-stop.conf
fehlt (ein Neustart oeffnete den Tunnel wieder; hal setup installiert es).
hal online braucht ein Terminal und das exakte yes (Agenten nie, R4). Es beweist zuerst die
Sperre jeder noch ausgeschalteten Einheit (bei Bedarf neu geschrieben), holt die Berechtigung des
Systems (pkcheck) noch vor dem Loeschen des Schalters, liest ihn unter der Sperre noch einmal, loescht
ihn, startet den Tunnel und prueft, dass er aktiv ist. Kann eine Sperre nicht bewiesen werden, bleibt
der Tunnel zu; schlaegt die Berechtigung oder der Start fehl, steht der Schalter wieder (Exit 3). Nur
der globale Schalter faellt. Die Pruefung des Hosts davor ist noch nicht gebaut ("host check not built
yet (#50 SW3)"). edge liest nie /etc/cloudflared, aendert den Tunnel nicht und ruft nie sudo auf.
EDGE_TUNNEL_UNIT und EDGE_TUNNEL_MANAGER=user setzen fuer den Wegwerf-Beweis eine Einheit des
Benutzer-Managers an die Stelle von cloudflared.service.
tunnel/ ist der Inhalt, den der Systemschritt von hal setup mit sudo nach /etc installiert;
edge installiert nichts. 20-nonroot.conf laesst cloudflared.service ohne root laufen,
30-edge-global-stop.conf startet es nicht, solange global-stop existiert, und
polkit/10-edge-cloudflared-stop.rules erlaubt dem Betreiber nur das Stoppen ohne Passwort, auch
ueber SSH; start fragt weiter. Ein blosses systemctl stop cloudflared haelt daher nicht: nach einem
Neustart laeuft der Tunnel wieder, denn nur der Schalter ueberlebt ihn. Der Notschalter ist
hal offline.
Regel R4: Agenten rufen hal apply und hal start nie auf; hal stop duerfen sie. Die
Terminalpruefung und das yes halten versehentliche und nicht-interaktive Laeufe auf; sie erkennen den
Betreiber nicht (D14).