ADR-0046 Schritt 1: Spiegel auf anode, Archiv je Stand, ansible_stand im Check-in
- Hintergrund-Thread spiegelt ansible-content (vorab), -stabil und
provisioning-agent alle 60 s nach data/spiegel, Archiv (tar.gz + SHA-256)
je Stand, die letzten drei je Stufe bleiben abrufbar
- Check-in liefert zusätzlich ansible_stand {commit, sha256}; ansible_repo
bleibt als Rückfall
- GET /api/v1/agent/ansible/<commit>.tar.gz und
/api/v1/agent/fassung/<commit>/<datei> nur mit Agent-Secret
(Authorization + X-Tuxflotte-Geraet), nur zustehende Stände
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d90ce99180
commit
2fae14cfe0
1 file changed
+218
-5
@@ -7,6 +7,7 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -23,7 +24,7 @@ from cryptography.fernet import Fernet, InvalidToken
|
|||||||
|
|
||||||
from fastapi import FastAPI, Header, HTTPException, Request
|
from fastapi import FastAPI, Header, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, Response
|
||||||
|
|
||||||
|
|
||||||
LOG_PATH = Path("activation.log")
|
LOG_PATH = Path("activation.log")
|
||||||
@@ -48,6 +49,23 @@ ANSIBLE_CONTENT_REPO_STABIL = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO_STA
|
|||||||
re.sub(r"(\.git)?$", r"-stabil\1", ANSIBLE_CONTENT_REPO, count=1) if ANSIBLE_CONTENT_REPO else ""
|
re.sub(r"(\.git)?$", r"-stabil\1", ANSIBLE_CONTENT_REPO, count=1) if ANSIBLE_CONTENT_REPO else ""
|
||||||
)
|
)
|
||||||
AUSROLLSTUFEN = ("stabil", "vorab")
|
AUSROLLSTUFEN = ("stabil", "vorab")
|
||||||
|
# Spiegel für Geräte (ADR-0046, 20.10.2026): anode hält Spiegel von
|
||||||
|
# ansible-content (je Ausrollstufe) und provisioning-agent, erzeugt je Stand
|
||||||
|
# ein Archiv und liefert beides nur gegen das Agent-Secret aus. Damit hängen
|
||||||
|
# die Geräte nicht mehr an einem öffentlich erreichbaren Gitea. Im Übergang
|
||||||
|
# schickt der Check-in weiter auch "ansible_repo" (Rückfall per Git).
|
||||||
|
AGENT_REPO = os.environ.get("TUXFLOTTE_AGENT_REPO", "") or (
|
||||||
|
re.sub(r"ansible-content(\.git)?$", r"provisioning-agent\1", ANSIBLE_CONTENT_REPO, count=1)
|
||||||
|
if ANSIBLE_CONTENT_REPO else ""
|
||||||
|
)
|
||||||
|
SPIEGEL_DIR = Path(os.environ.get("TUXFLOTTE_SPIEGEL_DIR", "data/spiegel")).resolve()
|
||||||
|
SPIEGEL_INTERVALL_SECONDS = 60
|
||||||
|
SPIEGEL_GIT_TIMEOUT_SECONDS = 120
|
||||||
|
# Je Stufe ausgelieferte Stände: der aktuelle und zwei vorige - ein Gerät,
|
||||||
|
# das seinen Stand kurz vor einer Freigabe erfahren hat, kann ihn noch holen.
|
||||||
|
SPIEGEL_STAENDE_JE_STUFE = 3
|
||||||
|
# Dateien, die Agent-Wechsel und Neuinstallation aus provisioning-agent holen.
|
||||||
|
AGENT_FASSUNG_DATEIEN = ("agent.py", "firstboot.sh", "tuxflotte-agent.service", "tuxflotte-firstboot.service")
|
||||||
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
|
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
|
||||||
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
|
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
|
||||||
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
|
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
|
||||||
@@ -134,6 +152,12 @@ def start_iso_build_expiry_sweep() -> None:
|
|||||||
threading.Thread(target=run_iso_build_expiry_sweep, daemon=True).start()
|
threading.Thread(target=run_iso_build_expiry_sweep, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def start_spiegel() -> None:
|
||||||
|
spiegel_stand_laden()
|
||||||
|
threading.Thread(target=run_spiegel_schleife, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
class ActivationRequest(BaseModel):
|
class ActivationRequest(BaseModel):
|
||||||
activation_code: str
|
activation_code: str
|
||||||
device_fingerprint: str
|
device_fingerprint: str
|
||||||
@@ -3772,9 +3796,8 @@ def fetch_organizations():
|
|||||||
) in cur.fetchall()
|
) in cur.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
def ansible_repo_fuer_geraet(device_id: str) -> str:
|
def ausrollstufe_fuer_geraet(device_id: str) -> str:
|
||||||
"""Adresse des Rollen-Repos nach der Ausrollstufe der Organisation.
|
"""Ausrollstufe der Organisation des Geräts. Unbekanntes Gerät: stabil."""
|
||||||
Unbekanntes Gerät: der freigegebene Stand."""
|
|
||||||
|
|
||||||
with get_database_connection() as conn:
|
with get_database_connection() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
@@ -3788,7 +3811,150 @@ def ansible_repo_fuer_geraet(device_id: str) -> str:
|
|||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
||||||
return ANSIBLE_CONTENT_REPO if row and row[0] == "vorab" else ANSIBLE_CONTENT_REPO_STABIL
|
return "vorab" if row and row[0] == "vorab" else "stabil"
|
||||||
|
|
||||||
|
|
||||||
|
def ansible_repo_fuer_geraet(device_id: str) -> str:
|
||||||
|
"""Adresse des Rollen-Repos nach der Ausrollstufe der Organisation."""
|
||||||
|
|
||||||
|
return ANSIBLE_CONTENT_REPO if ausrollstufe_fuer_geraet(device_id) == "vorab" else ANSIBLE_CONTENT_REPO_STABIL
|
||||||
|
|
||||||
|
|
||||||
|
# --- Spiegel für Geräte (ADR-0046) -------------------------------------------
|
||||||
|
# Zustand je Ausrollstufe: {"commit", "sha256", "erlaubt": [neueste zuerst]}.
|
||||||
|
# Liegt zusätzlich in stand.json, damit nach einem Neustart sofort
|
||||||
|
# ausgeliefert wird, auch wenn Gitea gerade nicht erreichbar ist.
|
||||||
|
_spiegel_lock = threading.Lock()
|
||||||
|
_spiegel_staende: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _spiegel_repos() -> dict:
|
||||||
|
return {"vorab": ANSIBLE_CONTENT_REPO, "stabil": ANSIBLE_CONTENT_REPO_STABIL, "agent": AGENT_REPO}
|
||||||
|
|
||||||
|
|
||||||
|
def _spiegel_git(*args, binaer: bool = False):
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=SPIEGEL_GIT_TIMEOUT_SECONDS,
|
||||||
|
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return ergebnis.stdout if binaer else ergebnis.stdout.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def spiegel_stand_laden() -> None:
|
||||||
|
try:
|
||||||
|
daten = json.loads((SPIEGEL_DIR / "stand.json").read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return
|
||||||
|
with _spiegel_lock:
|
||||||
|
_spiegel_staende.update({k: v for k, v in daten.items() if k in AUSROLLSTUFEN})
|
||||||
|
|
||||||
|
|
||||||
|
def _spiegel_holen(name: str, url: str) -> Path:
|
||||||
|
"""Bare-Spiegel anlegen oder aktualisieren, liefert seinen Pfad."""
|
||||||
|
|
||||||
|
pfad = SPIEGEL_DIR / f"{name}.git"
|
||||||
|
if (pfad / "HEAD").exists():
|
||||||
|
if _spiegel_git("-C", str(pfad), "remote", "get-url", "origin") != url:
|
||||||
|
_spiegel_git("-C", str(pfad), "remote", "set-url", "origin", url)
|
||||||
|
_spiegel_git("-C", str(pfad), "remote", "update", "--prune")
|
||||||
|
else:
|
||||||
|
neu = SPIEGEL_DIR / f".{name}.git.neu"
|
||||||
|
if neu.exists():
|
||||||
|
shutil.rmtree(neu)
|
||||||
|
_spiegel_git("clone", "--quiet", "--mirror", url, str(neu))
|
||||||
|
neu.rename(pfad)
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def _spiegel_archiv(stufe: str, pfad: Path) -> dict:
|
||||||
|
"""Archiv des aktuellen Stands einer Stufe (einmal je Commit) und die
|
||||||
|
Liste der auslieferbaren Stände; ältere Archive werden gelöscht."""
|
||||||
|
|
||||||
|
commit = _spiegel_git("-C", str(pfad), "rev-parse", "HEAD^{commit}")
|
||||||
|
archiv_dir = SPIEGEL_DIR / "archive" / stufe
|
||||||
|
archiv_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archiv = archiv_dir / f"{commit}.tar.gz"
|
||||||
|
if not archiv.exists():
|
||||||
|
neu = archiv_dir / f".{commit}.tar.gz.neu"
|
||||||
|
_spiegel_git("-C", str(pfad), "archive", "--format=tar.gz", "-o", str(neu), commit)
|
||||||
|
neu.rename(archiv)
|
||||||
|
sha256 = hashlib.sha256(archiv.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
with _spiegel_lock:
|
||||||
|
bisher = _spiegel_staende.get(stufe, {}).get("erlaubt", [])
|
||||||
|
erlaubt = [commit] + [c for c in bisher if c != commit][: SPIEGEL_STAENDE_JE_STUFE - 1]
|
||||||
|
for datei in archiv_dir.glob("*.tar.gz"):
|
||||||
|
if datei.name.removesuffix(".tar.gz") not in erlaubt:
|
||||||
|
datei.unlink()
|
||||||
|
return {"commit": commit, "sha256": sha256, "erlaubt": erlaubt}
|
||||||
|
|
||||||
|
|
||||||
|
def spiegel_aktualisieren() -> None:
|
||||||
|
"""Ein Durchlauf: alle Spiegel holen, Archive erzeugen, Stand sichern.
|
||||||
|
Ein Fehler bei einem Repo lässt die anderen und den letzten guten Stand
|
||||||
|
dieser Stufe unberührt."""
|
||||||
|
|
||||||
|
SPIEGEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, url in _spiegel_repos().items():
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pfad = _spiegel_holen(name, url)
|
||||||
|
if name in AUSROLLSTUFEN:
|
||||||
|
stand = _spiegel_archiv(name, pfad)
|
||||||
|
with _spiegel_lock:
|
||||||
|
vorher = _spiegel_staende.get(name, {}).get("commit")
|
||||||
|
_spiegel_staende[name] = stand
|
||||||
|
if vorher != stand["commit"]:
|
||||||
|
print(f"[spiegel] {name}: {str(vorher)[:7]} -> {stand['commit'][:7]}")
|
||||||
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
fehler = exc.stderr.decode(errors="replace").strip() if getattr(exc, "stderr", None) else exc
|
||||||
|
print(f"[spiegel] {name}: Fehler, letzter Stand bleibt: {fehler}")
|
||||||
|
|
||||||
|
with _spiegel_lock:
|
||||||
|
daten = json.dumps(_spiegel_staende, indent=2)
|
||||||
|
neu = SPIEGEL_DIR / ".stand.json.neu"
|
||||||
|
neu.write_text(daten)
|
||||||
|
neu.rename(SPIEGEL_DIR / "stand.json")
|
||||||
|
|
||||||
|
|
||||||
|
def run_spiegel_schleife() -> None:
|
||||||
|
"""Daemon-Thread wie run_iso_build_expiry_sweep(): ein Fehler darf die
|
||||||
|
Schleife nicht beenden."""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
spiegel_aktualisieren()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[spiegel] Durchlauf-Fehler (nächster Versuch in {SPIEGEL_INTERVALL_SECONDS}s): {exc}")
|
||||||
|
time.sleep(SPIEGEL_INTERVALL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def ansible_stand_fuer_geraet(device_id: str) -> dict | None:
|
||||||
|
"""Stand für den Check-in: Commit und Prüfsumme des Archivs der
|
||||||
|
Ausrollstufe. None, solange der Spiegel noch nichts hat - dann bleibt
|
||||||
|
dem Agent der Git-Weg über "ansible_repo"."""
|
||||||
|
|
||||||
|
stufe = ausrollstufe_fuer_geraet(device_id)
|
||||||
|
with _spiegel_lock:
|
||||||
|
stand = _spiegel_staende.get(stufe)
|
||||||
|
if not stand:
|
||||||
|
return None
|
||||||
|
return {"commit": stand["commit"], "sha256": stand["sha256"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _geraet_aus_kopfzeilen(authorization: str | None, geraet: str | None) -> str:
|
||||||
|
"""Gerät für die GET-Endpunkte des Spiegels: Secret im Authorization-
|
||||||
|
Header, Geräte-ID in X-Tuxflotte-Geraet (nie in der URL)."""
|
||||||
|
|
||||||
|
if not authorization or not authorization.startswith("Bearer ") or not geraet or not _ist_uuid(geraet):
|
||||||
|
raise HTTPException(status_code=401, detail="Nicht angemeldet.")
|
||||||
|
if not verify_agent_secret(geraet, authorization.removeprefix("Bearer ")):
|
||||||
|
raise HTTPException(status_code=401, detail="Nicht angemeldet.")
|
||||||
|
return geraet
|
||||||
|
|
||||||
|
|
||||||
def set_ausrollstufe(organization_id: str, ausrollstufe: str) -> bool:
|
def set_ausrollstufe(organization_id: str, ausrollstufe: str) -> bool:
|
||||||
@@ -7222,6 +7388,9 @@ def agent_checkin(
|
|||||||
# wenn er sich vom lokal tatsächlich gesetzten unterscheidet.
|
# wenn er sich vom lokal tatsächlich gesetzten unterscheidet.
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"ansible_repo": ansible_repo_fuer_geraet(payload.device_id),
|
"ansible_repo": ansible_repo_fuer_geraet(payload.device_id),
|
||||||
|
# ADR-0046: Stand als Archiv von anode. Neuere Agenten nehmen ihn
|
||||||
|
# statt "ansible_repo", ältere ignorieren das Feld.
|
||||||
|
"ansible_stand": ansible_stand_fuer_geraet(payload.device_id),
|
||||||
# Persönliche Konten (ADR-0041, Schritt 3). Ältere Agenten ignorieren
|
# Persönliche Konten (ADR-0041, Schritt 3). Ältere Agenten ignorieren
|
||||||
# das Feld; die Rolle fasst nur hier genannte Konten an.
|
# das Feld; die Rolle fasst nur hier genannte Konten an.
|
||||||
"persoenliche_konten": fetch_persoenliche_konten_fuer_agent(payload.device_id),
|
"persoenliche_konten": fetch_persoenliche_konten_fuer_agent(payload.device_id),
|
||||||
@@ -7229,6 +7398,50 @@ def agent_checkin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/agent/ansible/{datei}")
|
||||||
|
def agent_ansible_archiv(
|
||||||
|
datei: str,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_tuxflotte_geraet: str | None = Header(default=None),
|
||||||
|
):
|
||||||
|
"""Archiv eines ansible-content-Stands (ADR-0046) - nur ein Stand, der der
|
||||||
|
Ausrollstufe dieses Geräts zusteht."""
|
||||||
|
|
||||||
|
device_id = _geraet_aus_kopfzeilen(authorization, x_tuxflotte_geraet)
|
||||||
|
treffer = re.fullmatch(r"([0-9a-f]{40})\.tar\.gz", datei)
|
||||||
|
stufe = ausrollstufe_fuer_geraet(device_id)
|
||||||
|
with _spiegel_lock:
|
||||||
|
erlaubt = _spiegel_staende.get(stufe, {}).get("erlaubt", [])
|
||||||
|
if not treffer or treffer.group(1) not in erlaubt:
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannter Stand.")
|
||||||
|
archiv = SPIEGEL_DIR / "archive" / stufe / datei
|
||||||
|
if not archiv.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannter Stand.")
|
||||||
|
return FileResponse(archiv, media_type="application/gzip")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/agent/fassung/{commit}/{datei}")
|
||||||
|
def agent_fassung(
|
||||||
|
commit: str,
|
||||||
|
datei: str,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_tuxflotte_geraet: str | None = Header(default=None),
|
||||||
|
):
|
||||||
|
"""Eine Datei des Agenten in einer bestimmten Fassung (ADR-0046) - nur
|
||||||
|
Commits auf dem Hauptzweig von provisioning-agent, nur feste Dateien."""
|
||||||
|
|
||||||
|
_geraet_aus_kopfzeilen(authorization, x_tuxflotte_geraet)
|
||||||
|
pfad = SPIEGEL_DIR / "agent.git"
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", commit) or datei not in AGENT_FASSUNG_DATEIEN or not (pfad / "HEAD").exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannte Fassung.")
|
||||||
|
try:
|
||||||
|
_spiegel_git("-C", str(pfad), "merge-base", "--is-ancestor", commit, "HEAD")
|
||||||
|
inhalt = _spiegel_git("-C", str(pfad), "show", f"{commit}:{datei}", binaer=True)
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannte Fassung.")
|
||||||
|
return Response(content=inhalt, media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/agent/report")
|
@app.post("/api/v1/agent/report")
|
||||||
def agent_report(
|
def agent_report(
|
||||||
payload: AgentReportRequest,
|
payload: AgentReportRequest,
|
||||||
|
|||||||
Reference in new issue
Block a user