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:
1 parent
931fac1c90
commit
54f76f12f9
2 files changed
+205
-30
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user