From 8382a0a949e3b83fcd86a62c1cc3a0e69ead8176 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Sat, 1 Aug 2026 15:55:26 +0200 Subject: [PATCH] feat: initial Provisioning Agent (check-in loop + ansible-pull) Erste lauffaehige Version: laedt Credentials, meldet sich per Bearer-Token bei anode/api/v1/agent/checkin, wendet zugewiesene Blueprints per ansible-pull an und schlaeft bis zum naechsten Intervall. --- .gitignore | 2 + README.md | 52 +++++++++++++++++++++ agent.py | 101 ++++++++++++++++++++++++++++++++++++++++ tuxflotte-agent.service | 13 ++++++ 4 files changed, 168 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent.py create mode 100644 tuxflotte-agent.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6092f0 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# 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/`). + +**Stand:** erste lauffähige Version. Die Einrichtung vor dem ersten Reboot (`backend_postinstall()`) ist noch nicht angebunden — Bootstrap läuft aktuell über den Dev-Endpoint `POST /api/v1/agent/bootstrap`. + +## Manuelle Installation (Testgerät) + +Voraussetzung: `ansible-core` ist installiert (`ansible-pull` im PATH). + +1. Gerät aktivieren und Bereitstellungsvorlage zuweisen (bestehender Provisioning-Flow, siehe `provisioning-server`) +2. Agent-Secret erzeugen: + + ``` + curl -X POST https://anode.tuxflotte.de/api/v1/agent/bootstrap \ + -H "Content-Type: application/json" \ + -d '{"device_id": ""}' + ``` + +3. Credentials ablegen: + + ``` + install -d -m 0700 /etc/tuxflotte + cat > /etc/tuxflotte/agent.credentials <", "agent_secret": ""} + EOF + chmod 0600 /etc/tuxflotte/agent.credentials + ``` + +4. Agent-Code ablegen und Dienst einrichten: + + ``` + install -d /opt/tuxflotte/agent + cp agent.py /opt/tuxflotte/agent/ + cp tuxflotte-agent.service /etc/systemd/system/ + systemctl daemon-reload + systemctl enable --now tuxflotte-agent + ``` + +5. Prüfen: + + ``` + journalctl -u tuxflotte-agent -f + ls /run/tuxflotte/agent/applied/ + ``` + +## Konfiguration + +| Umgebungsvariable | Zweck | Default | +|---|---|---| +| `TUXFLOTTE_ANODE_URL` | Basis-URL des Provisioning-Servers | `https://anode.tuxflotte.de` | +| `TUXFLOTTE_AGENT_CREDENTIALS` | Pfad zur Credentials-Datei | `/etc/tuxflotte/agent.credentials` | diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..5d68fff --- /dev/null +++ b/agent.py @@ -0,0 +1,101 @@ +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request + + +ANODE_URL = os.environ.get("TUXFLOTTE_ANODE_URL", "https://anode.tuxflotte.de") +CREDENTIALS_PATH = os.environ.get( + "TUXFLOTTE_AGENT_CREDENTIALS", "/etc/tuxflotte/agent.credentials" +) +FALLBACK_INTERVAL_SECONDS = 60 + + +def log(message): + print(f"[tuxflotte-agent] {message}", flush=True) + + +def load_credentials(): + with open(CREDENTIALS_PATH, "r", encoding="utf-8") as f: + credentials = json.load(f) + + return credentials["device_id"], credentials["agent_secret"] + + +def checkin(device_id, agent_secret): + body = json.dumps({"device_id": device_id}).encode("utf-8") + + req = urllib.request.Request( + ANODE_URL + "/api/v1/agent/checkin", + data=body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {agent_secret}", + }, + method="POST", + ) + + with urllib.request.urlopen(req, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + +def apply_blueprints(ansible_repo, blueprints): + roles = ",".join(b["ansible_role"] for b in blueprints) + + log(f"Applying roles via ansible-pull: {roles}") + + result = subprocess.run( + [ + "ansible-pull", + "-U", ansible_repo, + "--tags", roles, + "-i", "localhost,", + "site.yml", + ], + check=False, + ) + + if result.returncode != 0: + log(f"ansible-pull exited with status {result.returncode}") + + +def run_once(device_id, agent_secret): + result = checkin(device_id, agent_secret) + + if not result.get("success"): + log(f"Check-in failed: {result.get('message', result.get('error'))}") + return FALLBACK_INTERVAL_SECONDS + + blueprints = result.get("blueprints", []) + + if blueprints: + apply_blueprints(result["ansible_repo"], blueprints) + else: + log("Check-in ok, nothing to do.") + + return result.get("poll_interval_seconds", FALLBACK_INTERVAL_SECONDS) + + +def main(): + if shutil.which("ansible-pull") is None: + log("ansible-pull nicht gefunden. Bitte ansible-core installieren.") + sys.exit(1) + + device_id, agent_secret = load_credentials() + + while True: + try: + interval = run_once(device_id, agent_secret) + except (urllib.error.URLError, OSError) as exc: + log(f"Check-in-Fehler: {exc}") + interval = FALLBACK_INTERVAL_SECONDS + + time.sleep(interval) + + +if __name__ == "__main__": + main() diff --git a/tuxflotte-agent.service b/tuxflotte-agent.service new file mode 100644 index 0000000..bc28b4c --- /dev/null +++ b/tuxflotte-agent.service @@ -0,0 +1,13 @@ +[Unit] +Description=Tuxflotte Provisioning Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/tuxflotte/agent/agent.py +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target