diff --git a/app.py b/app.py index 29744b9..02ece64 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import secrets from uuid import uuid4 import psycopg +from psycopg.types.json import Jsonb from fastapi import FastAPI, Header, Request from pydantic import BaseModel @@ -18,6 +19,7 @@ LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") +AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} app = FastAPI(title="Provisioning Activation Server", version="0.1.0") @@ -42,6 +44,17 @@ class AgentCheckinRequest(BaseModel): device_id: str +class AgentReportEntry(BaseModel): + merkmal: str + event_type: str + detail: dict | None = None + + +class AgentReportRequest(BaseModel): + device_id: str + results: list[AgentReportEntry] + + def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") @@ -366,6 +379,52 @@ def fetch_assigned_blueprints(device_id: str): for key, ansible_role in cur.fetchall() ] +def fetch_auftragskatalog_state(device_id: str): + """ + Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für + das Backend des Device, unabhängig von der Workspace-Zuweisung + (siehe ADR-0010). Zustandslos aus der aktuellen Auswahl abgeleitet, + keine Historie nötig. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT bv.backend_id + FROM assignments a + JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id + WHERE a.device_id = %s + """, + (device_id,), + ) + assignment_row = cur.fetchone() + + if assignment_row is None: + return [] + + (backend_id,) = assignment_row + + cur.execute( + """ + SELECT m.key, bp.ansible_role, COALESCE(dm.aktiv, FALSE) + FROM merkmale m + JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s + LEFT JOIN device_merkmale dm + ON dm.merkmal_id = m.id AND dm.device_id = %s + WHERE m.im_auftragskatalog + """, + (backend_id, device_id), + ) + return [ + { + "merkmal": key, + "ansible_role": ansible_role, + "state": "present" if aktiv else "absent", + } + for key, ansible_role, aktiv in cur.fetchall() + ] + @app.get("/health") def health(): return { @@ -578,6 +637,79 @@ def agent_checkin( return { "success": True, "blueprints": fetch_assigned_blueprints(payload.device_id), + "auftraege": fetch_auftragskatalog_state(payload.device_id), "ansible_repo": ANSIBLE_CONTENT_REPO, "poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS, } + + +@app.post("/api/v1/agent/report") +def agent_report( + payload: AgentReportRequest, + authorization: str | None = Header(default=None), +): + if authorization is None or not authorization.startswith("Bearer "): + return { + "success": False, + "error": "unauthorized", + "message": "Fehlendes oder ungültiges Authorization-Header.", + } + + provided_secret = authorization.removeprefix("Bearer ") + + if not verify_agent_secret(payload.device_id, provided_secret): + return { + "success": False, + "error": "unauthorized", + "message": "Ungültiges Agent-Secret.", + } + + for entry in payload.results: + if entry.event_type not in AGENT_REPORTABLE_EVENT_TYPES: + return { + "success": False, + "error": "invalid_event_type", + "message": f"Kein vom Agenten meldbarer event_type: {entry.event_type}", + } + + with get_database_connection() as conn: + with conn.cursor() as cur: + merkmal_ids = {} + + for entry in payload.results: + if entry.merkmal in merkmal_ids: + continue + + cur.execute( + "SELECT id FROM merkmale WHERE key = %s", + (entry.merkmal,), + ) + merkmal_row = cur.fetchone() + + if merkmal_row is None: + return { + "success": False, + "error": "unknown_merkmal", + "message": f"Unbekanntes Merkmal: {entry.merkmal}", + } + + merkmal_ids[entry.merkmal] = merkmal_row[0] + + for entry in payload.results: + cur.execute( + """ + INSERT INTO device_merkmal_events ( + id, device_id, merkmal_id, event_type, detail + ) + VALUES (%s, %s, %s, %s, %s) + """, + ( + uuid4(), + payload.device_id, + merkmal_ids[entry.merkmal], + entry.event_type, + Jsonb(entry.detail) if entry.detail is not None else None, + ), + ) + + return {"success": True} diff --git a/migrations/0007_auftragskatalog.sql b/migrations/0007_auftragskatalog.sql new file mode 100644 index 0000000..1bda932 --- /dev/null +++ b/migrations/0007_auftragskatalog.sql @@ -0,0 +1,52 @@ +BEGIN; + +CREATE TABLE kategorien ( + id UUID PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +ALTER TABLE merkmale ADD COLUMN kategorie_id UUID + REFERENCES kategorien(id) + ON DELETE SET NULL; + +ALTER TABLE merkmale ADD COLUMN im_auftragskatalog BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE device_merkmale ( + id UUID PRIMARY KEY, + device_id UUID NOT NULL + REFERENCES devices(id) + ON DELETE CASCADE, + merkmal_id UUID NOT NULL + REFERENCES merkmale(id) + ON DELETE RESTRICT, + aktiv BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (device_id, merkmal_id) +); + +CREATE TABLE device_merkmal_events ( + id UUID PRIMARY KEY, + device_id UUID NOT NULL + REFERENCES devices(id) + ON DELETE CASCADE, + merkmal_id UUID NOT NULL + REFERENCES merkmale(id) + ON DELETE RESTRICT, + event_type TEXT NOT NULL + CHECK (event_type IN ( + 'selected', + 'deselected', + 'applied', + 'apply_failed', + 'removed', + 'remove_failed' + )), + detail JSONB, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMIT;