ADR-0046 Schritt 2: Ansible-Stand als Archiv von anode, Git als Rückfall

- ansible_stand {commit, sha256} aus dem Check-in: Archiv mit Secret im
  Header laden, Summe prüfen, mit tarfile-Datenfilter entpacken, atomar nach
  /var/lib/tuxflotte/ansible/<commit>/ (0700), aktueller + voriger Stand
  bleiben; dann ansible-playbook -c local mit denselben Tags/Extra-Vars
- Fingerabdruck nimmt den Commit aus ansible_stand statt git ls-remote
  (gleicher Wert wie bisher, kein Extra-Lauf beim Umstieg)
- ohne Stand oder bei Fehler: ansible-pull mit ansible_repo wie bisher
- Check-in meldet ansible_weg (archiv/git/git-rueckfall)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-21 09:00:47 +02:00
1 parent 931fac1c90
commit 54f76f12f9
2 files changed
+205 -30

No files matched your search

+3 -3
View File
@@ -1,16 +1,16 @@
# provisioning-agent
Der Tuxflotte Provisioning Agent läuft auf installierten Geräten, meldet sich in regelmäßigen Abständen bei `anode` und wendet die ihm zugewiesenen Ansible-Rollen per `ansible-pull` an (siehe `ansible-content/`).
Der Tuxflotte Provisioning Agent läuft auf installierten Geräten, meldet sich in regelmäßigen Abständen bei `anode` und wendet die ihm zugewiesenen Ansible-Rollen an (siehe `ansible-content/`). Den Rollen-Stand holt er seit ADR-0046 als Archiv von `anode` (`ansible_stand` im Check-in, Abruf nur mit Agent-Secret, Prüfsumme, dann `ansible-playbook` im entpackten Stand unter `/var/lib/tuxflotte/ansible/<commit>/`); fehlt der Stand oder scheitert dieser Weg, bleibt `ansible-pull` mit `ansible_repo` als Rückfall. Der genutzte Weg geht als `ansible_weg` (`archiv`/`git`/`git-rueckfall`) mit jedem Check-in an den Server.
**Stand:** Kernschleife (Check-in → ansible-pull) sowie Auftragskatalog (present/absent, Ergebnis-Report) laufen. Die Einrichtung vor dem ersten Reboot ist über `backend_postinstall()` im Fedora-Backend angebunden (`tuxflotte-installer/backends/fedora/kickstart.tpl`), ruft dabei denselben `POST /api/v1/agent/bootstrap`-Endpoint auf, der hier für die manuelle Testgerät-Einrichtung beschrieben ist.
## Auftragskatalog (ADR-0010)
Neben den additiv über den Workspace zugewiesenen Blueprints liefert der Check-in zusätzlich `auftraege`: den vollständigen present/absent-Zustand aller katalogfähigen Merkmale für das Gerät. Der Agent führt beide Listen in einem gemeinsamen `ansible-pull`-Lauf aus (`--tags` = Vereinigung aller Rollennamen, `--extra-vars` reicht `tuxflotte_auftrag_states` als Rolle→Zustand-Dict durch, siehe `ansible-content/README.md`) und meldet danach das Ergebnis je Auftragskatalog-Eintrag per `POST /api/v1/agent/report` zurück (`applied`/`apply_failed`/`removed`/`remove_failed`). Das Ergebnis ist grob granular — ein `ansible-pull`-Lauf hat einen einzigen Exit-Code für alle enthaltenen Rollen, es gibt noch keine Rolle-für-Rolle-Auswertung.
Neben den additiv über den Workspace zugewiesenen Blueprints liefert der Check-in zusätzlich `auftraege`: den vollständigen present/absent-Zustand aller katalogfähigen Merkmale für das Gerät. Der Agent führt beide Listen in einem gemeinsamen Lauf aus (`--tags` = Vereinigung aller Rollennamen, `--extra-vars` reicht `tuxflotte_auftrag_states` als Rolle→Zustand-Dict durch, siehe `ansible-content/README.md`) und meldet danach das Ergebnis je Auftragskatalog-Eintrag per `POST /api/v1/agent/report` zurück (`applied`/`apply_failed`/`removed`/`remove_failed`). Das Ergebnis ist grob granular — ein Lauf hat einen einzigen Exit-Code für alle enthaltenen Rollen, es gibt noch keine Rolle-für-Rolle-Auswertung.
## Manuelle Installation (Testgerät)
Voraussetzung: `ansible-core` ist installiert (`ansible-pull` im PATH).
Voraussetzung: `ansible-core` ist installiert (`ansible-playbook` und `ansible-pull` im PATH).
1. Gerät aktivieren und Bereitstellungsvorlage zuweisen (bestehender Provisioning-Flow, siehe `provisioning-server`)
2. Agent-Secret erzeugen:
+202 -27
View File
@@ -2,10 +2,13 @@ import hashlib
import json
import os
import random
import re
import shutil
import signal
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
@@ -77,6 +80,19 @@ KONTEN_SOLL_SCHNELL_PATH = "/run/tuxflotte/agent/persoenliche-konten-soll-schnel
KONTEN_SCHNELL_TIMEOUT_SECONDS = 120
PULL_KILL_GRACE_SECONDS = 30
DPKG_REPAIR_TIMEOUT_SECONDS = 30 * 60
# Stand von anode statt Git (ADR-0046, 21.10.2026): Schickt der Server im
# Check-in "ansible_stand" {commit, sha256}, laedt der Agent das Archiv mit
# seinem Secret von anode, prueft die Summe und fuehrt ansible-playbook im
# entpackten Stand aus - das ist, was ansible-pull nach dem Klonen auch tut.
# Fehlt der Stand oder scheitert dieser Weg, bleibt ansible-pull mit
# "ansible_repo" (Rueckfall, bis Gitea geschlossen wird). Je Stand ein
# Verzeichnis, behalten werden der aktuelle und der vorige.
ANSIBLE_STAENDE_DIR = "/var/lib/tuxflotte/ansible"
ANSIBLE_ARCHIV_TIMEOUT_SECONDS = 60
ANSIBLE_ARCHIV_MAX_BYTES = 50 * 1024 * 1024
# Zuletzt genutzter Weg ("archiv", "git", "git-rueckfall"), geht mit jedem
# Check-in an den Server - so ist ein dauerhafter Rueckfall von aussen sichtbar.
ANSIBLE_WEG_PATH = "/var/lib/tuxflotte/agent/ansible-weg"
def log(message):
@@ -203,6 +219,9 @@ def checkin(device_id, agent_secret):
summe = agent_sha256()
if summe:
body["agent_sha256"] = summe
weg = read_ansible_weg()
if weg:
body["ansible_weg"] = weg
return api_request("/api/v1/agent/checkin", agent_secret, body)
@@ -324,20 +343,156 @@ def mark_first_success():
log(f"Erstlauf-Marker konnte nicht geschrieben werden: {exc}")
def desired_state_hash(ansible_repo, blueprints, auftraege, webapps, hostname, konten):
"""
Fingerabdruck aus Soll-Zustand + aktuellem Commit des Ansible-Repos.
None, wenn der Repo-Stand nicht ermittelbar ist - dann lieber laufen.
"""
def gueltiger_stand(stand):
"""ansible_stand aus dem Check-in, wenn er wohlgeformt ist, sonst None.
Der Commit wird Verzeichnisname - deshalb streng pruefen."""
if (
isinstance(stand, dict)
and isinstance(stand.get("commit"), str)
and isinstance(stand.get("sha256"), str)
and re.fullmatch(r"[0-9a-f]{40}", stand["commit"])
and re.fullmatch(r"[0-9a-f]{64}", stand["sha256"])
):
return stand
return None
def repo_head(ansible_repo):
"""Aktueller Commit des Ansible-Repos per Git (Weg ohne ansible_stand)."""
try:
head = subprocess.run(
return subprocess.run(
["git", "ls-remote", ansible_repo, "HEAD"],
capture_output=True, text=True, timeout=30, check=True,
).stdout.split()[0]
except (subprocess.SubprocessError, OSError, IndexError):
return None
def archiv_laden(device_id, agent_secret, commit, ziel):
"""Laedt das Archiv eines Stands nach ziel und gibt seine SHA-256 zurueck.
Secret und Geraete-ID nur im Header (ADR-0046 Punkt 4)."""
req = urllib.request.Request(
f"{ANODE_URL}/api/v1/agent/ansible/{commit}.tar.gz",
headers={
"Authorization": f"Bearer {agent_secret}",
"X-Tuxflotte-Geraet": device_id,
},
)
summe = hashlib.sha256()
groesse = 0
with urllib.request.urlopen(req, timeout=ANSIBLE_ARCHIV_TIMEOUT_SECONDS) as response, open(ziel, "wb") as f:
while True:
block = response.read(64 * 1024)
if not block:
break
groesse += len(block)
if groesse > ANSIBLE_ARCHIV_MAX_BYTES:
raise ValueError(f"Archiv groesser als {ANSIBLE_ARCHIV_MAX_BYTES} Bytes")
summe.update(block)
f.write(block)
return summe.hexdigest()
def stand_bereitstellen(device_id, agent_secret, stand):
"""
Liefert das Verzeichnis des Stands, laedt und entpackt ihn bei Bedarf.
Entpackt wird in ein Arbeitsverzeichnis daneben und erst vollstaendig
umbenannt - ein abgebrochener Download hinterlaesst nie einen halben
Stand unter dem Commit-Namen. Fehler gehen als Ausnahme an den Aufrufer
(Rueckfall auf Git).
"""
commit = stand["commit"]
ziel = os.path.join(ANSIBLE_STAENDE_DIR, commit)
if os.path.isfile(os.path.join(ziel, "site.yml")):
os.utime(ziel)
return ziel
# tarfile ohne Datenfilter wuerde Pfade wie ../ oder absolute Links
# entpacken. Vorhanden ab Python 3.12 bzw. in den Sicherheits-Updates
# aelterer Fassungen - auf allen drei Distros der Fall.
if not hasattr(tarfile, "data_filter"):
raise ValueError("tarfile ohne Datenfilter (Python zu alt)")
os.makedirs(ANSIBLE_STAENDE_DIR, mode=0o700, exist_ok=True)
os.chmod(ANSIBLE_STAENDE_DIR, 0o700)
arbeit = tempfile.mkdtemp(prefix=".neu-", dir=ANSIBLE_STAENDE_DIR)
try:
archiv = os.path.join(arbeit, "stand.tar.gz")
summe = archiv_laden(device_id, agent_secret, commit, archiv)
if summe != stand["sha256"]:
raise ValueError(f"Pruefsumme passt nicht ({summe[:12]} statt {stand['sha256'][:12]})")
inhalt = os.path.join(arbeit, "inhalt")
os.mkdir(inhalt, 0o700)
with tarfile.open(archiv, "r:gz") as tar:
tar.extractall(inhalt, filter="data")
if not os.path.isfile(os.path.join(inhalt, "site.yml")):
raise ValueError("site.yml fehlt im Archiv")
if os.path.isdir(ziel):
shutil.rmtree(ziel)
os.rename(inhalt, ziel)
finally:
shutil.rmtree(arbeit, ignore_errors=True)
staende_aufraeumen(commit)
return ziel
def staende_aufraeumen(aktuell):
"""Behaelt den aktuellen und den zuletzt davor genutzten Stand (mtime,
siehe os.utime in stand_bereitstellen), loescht den Rest samt Resten
abgebrochener Downloads."""
try:
eintraege = [
e for e in os.scandir(ANSIBLE_STAENDE_DIR)
if e.is_dir(follow_symlinks=False) and e.name != aktuell
]
except OSError as exc:
log(f"Alte Staende nicht aufraeumbar: {exc}")
return
staende = sorted(
(e for e in eintraege if re.fullmatch(r"[0-9a-f]{40}", e.name)),
key=lambda e: e.stat(follow_symlinks=False).st_mtime,
reverse=True,
)
behalten = {staende[0].name} if staende else set()
for e in eintraege:
if e.name not in behalten:
shutil.rmtree(e.path, ignore_errors=True)
def read_ansible_weg():
try:
with open(ANSIBLE_WEG_PATH, "r", encoding="utf-8") as f:
return f.read().strip() or None
except OSError:
return None
def write_ansible_weg(weg):
try:
os.makedirs(os.path.dirname(ANSIBLE_WEG_PATH), exist_ok=True)
with open(ANSIBLE_WEG_PATH, "w", encoding="utf-8") as f:
f.write(weg + "\n")
except OSError as exc:
log(f"Ansible-Weg konnte nicht geschrieben werden: {exc}")
def desired_state_hash(head, blueprints, auftraege, webapps, hostname, konten):
"""
Fingerabdruck aus Soll-Zustand + Commit des Ansible-Stands (aus
ansible_stand oder per git ls-remote - derselbe Commit ergibt denselben
Wert). None, wenn der Stand nicht ermittelbar ist - dann lieber laufen.
"""
if not head:
return None
payload = json.dumps(
{
"repo": head, "blueprints": blueprints, "auftraege": auftraege, "webapps": webapps,
@@ -377,11 +532,12 @@ def write_extra_vars(extra_vars):
json.dump(extra_vars, f)
def apply_roles(ansible_repo, blueprints, auftraege, webapps, hostname, konten):
def apply_roles(ansible_repo, stand_dir, blueprints, auftraege, webapps, hostname, konten):
"""
Führt sowohl die additiv über den Workspace zugewiesenen Blueprints als
auch die Auftragskatalog-Einträge in einem gemeinsamen ansible-pull-Lauf
aus. Auftragskatalog-Rollen müssen unabhängig von present/absent immer
auch die Auftragskatalog-Einträge in einem gemeinsamen Lauf aus:
ansible-playbook im Stand-Verzeichnis von anode (stand_dir), sonst
ansible-pull mit ansible_repo. Auftragskatalog-Rollen müssen unabhängig von present/absent immer
in --tags stehen, sonst laufen ihre Tasks (inklusive des absent-Zweigs,
siehe ADR-0010) gar nicht erst.
@@ -411,7 +567,7 @@ def apply_roles(ansible_repo, blueprints, auftraege, webapps, hostname, konten):
for w in webapps
]
log(f"Applying roles via ansible-pull: {','.join(roles)}")
log(f"Applying roles via {'ansible-playbook ' + stand_dir if stand_dir else 'ansible-pull'}: {','.join(roles)}")
# Fuer die Erststart-Anzeige (firstboot.sh): "Schritt k von n", eine
# Zeile "Rolle<TAB>Schritt" je Rolle. Jedes zu installierende Merkmal ist
# ein eigener Schritt, alle abgewaehlten zusammen einer ("entfernen"),
@@ -431,6 +587,22 @@ def apply_roles(ansible_repo, blueprints, auftraege, webapps, hostname, konten):
except OSError as exc:
log(f"Rollenliste konnte nicht geschrieben werden: {exc}")
# Gleiche Optionen wie ansible-pull an ansible-playbook weitergibt
# (-c local, Inventar, Tags, Extra-Vars, Aufruf im Stand-Verzeichnis).
if stand_dir:
cmd = ["ansible-playbook", "-c", "local"]
elif ansible_repo:
cmd = ["ansible-pull", "-U", ansible_repo]
else:
log("Weder Stand von anode noch ansible_repo - kein Lauf moeglich")
return False
cmd += [
"--tags", ",".join(roles),
"-i", "localhost,",
"-e", f"@{EXTRA_VARS_PATH}",
"site.yml",
]
try:
write_extra_vars({
"tuxflotte_auftrag_states": auftrag_states,
@@ -446,17 +618,7 @@ def apply_roles(ansible_repo, blueprints, auftraege, webapps, hostname, konten):
return False
try:
returncode = run_with_time_limit(
[
"ansible-pull",
"-U", ansible_repo,
"--tags", ",".join(roles),
"-i", "localhost,",
"-e", f"@{EXTRA_VARS_PATH}",
"site.yml",
],
PULL_TIMEOUT_SECONDS,
)
returncode = run_with_time_limit(cmd, PULL_TIMEOUT_SECONDS, cwd=stand_dir)
finally:
try:
os.remove(EXTRA_VARS_PATH)
@@ -464,23 +626,23 @@ def apply_roles(ansible_repo, blueprints, auftraege, webapps, hostname, konten):
pass
if returncode is None:
log(f"ansible-pull nach {PULL_TIMEOUT_SECONDS}s abgebrochen (Zeitgrenze)")
log(f"{cmd[0]} nach {PULL_TIMEOUT_SECONDS}s abgebrochen (Zeitgrenze)")
repair_dpkg()
return False
if returncode != 0:
log(f"ansible-pull exited with status {returncode}")
log(f"{cmd[0]} exited with status {returncode}")
return returncode == 0
def run_with_time_limit(cmd, timeout_seconds):
def run_with_time_limit(cmd, timeout_seconds, cwd=None):
"""Startet cmd in eigener Prozessgruppe und gibt den Rueckgabewert
zurueck, oder None, wenn die Zeitgrenze erreicht wurde. Ein schlichtes
subprocess.run(timeout=...) toetete nur ansible-pull selbst;
ansible-playbook/apt/dpkg liefen verwaist weiter und hielten z.B. die
dpkg-Sperre."""
proc = subprocess.Popen(cmd, start_new_session=True)
proc = subprocess.Popen(cmd, start_new_session=True, cwd=cwd)
try:
return proc.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
@@ -589,7 +751,10 @@ def run_once(device_id, agent_secret):
# mit soeben entfernten Webapps oder einer neu gesetzten
# Hostnamenkonvention, muesste sonst faelschlich uebersprungen werden.
interval = result.get("poll_interval_seconds", FALLBACK_INTERVAL_SECONDS)
state_hash = desired_state_hash(result["ansible_repo"], blueprints, auftraege, webapps, hostname, konten)
stand = gueltiger_stand(result.get("ansible_stand"))
ansible_repo = result.get("ansible_repo")
head = stand["commit"] if stand else (repo_head(ansible_repo) if ansible_repo else None)
state_hash = desired_state_hash(head, blueprints, auftraege, webapps, hostname, konten)
if (
interval <= SKIP_UNCHANGED_MAX_INTERVAL_SECONDS
and state_hash is not None
@@ -598,7 +763,17 @@ def run_once(device_id, agent_secret):
return interval, agent_secret
write_status("running")
pull_succeeded = apply_roles(result["ansible_repo"], blueprints, auftraege, webapps, hostname, konten)
stand_dir = None
weg = "git"
if stand:
try:
stand_dir = stand_bereitstellen(device_id, agent_secret, stand)
weg = "archiv"
except (urllib.error.URLError, OSError, ValueError, tarfile.TarError) as exc:
log(f"Stand {stand['commit'][:12]} von anode nicht verwendbar, Rueckfall auf Git: {exc}")
weg = "git-rueckfall"
write_ansible_weg(weg)
pull_succeeded = apply_roles(ansible_repo, stand_dir, blueprints, auftraege, webapps, hostname, konten)
report_persoenliche_konten(device_id, agent_secret)
write_status("ok" if pull_succeeded else "failed")
if pull_succeeded: