feat: extend agent check-in with Auftragskatalog state, add report endpoint
Check-in now also returns "auftraege": the full present/absent state of every catalog-eligible Merkmal (im_auftragskatalog = true) for the device's backend, derived statelessly from device_merkmale rather than just the workspace-resolved blueprints list, which stays untouched. This is what lets a deselected Auftrag actually be reverted once the corresponding Ansible role grows an absent branch (ADR-0010) - the agent doesn't have that branch yet, so this is additive and inert until it does. Adds POST /api/v1/agent/report so the agent can write execution results (applied/apply_failed/removed/remove_failed) back into device_merkmal_events; selected/deselected is intentionally rejected here since those are meant to come from wherever Auftrag selection ends up being triggered, not from the agent itself. Migration 0007 adds the backing tables (kategorien, device_merkmale, device_merkmal_events) and the two new merkmale columns. Verified against anode: checkin toggles present/absent correctly after inserting a device_merkmale row, report endpoint accepts valid entries and rejects unknown merkmale / non-agent event types / bad secrets, and the written event round-trips through device_merkmal_events correctly as jsonb. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
341ef92a7a
commit
eb73f2b6b7
132
app.py
132
app.py
@ -8,6 +8,7 @@ import secrets
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from fastapi import FastAPI, Header, Request
|
from fastapi import FastAPI, Header, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -18,6 +19,7 @@ LOG_PATH = Path("activation.log")
|
|||||||
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
|
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
|
||||||
AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300"))
|
AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300"))
|
||||||
ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "")
|
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")
|
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
@ -42,6 +44,17 @@ class AgentCheckinRequest(BaseModel):
|
|||||||
device_id: str
|
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:
|
def write_log(entry: dict) -> None:
|
||||||
with LOG_PATH.open("a", encoding="utf-8") as f:
|
with LOG_PATH.open("a", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
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()
|
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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {
|
return {
|
||||||
@ -578,6 +637,79 @@ def agent_checkin(
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"blueprints": fetch_assigned_blueprints(payload.device_id),
|
"blueprints": fetch_assigned_blueprints(payload.device_id),
|
||||||
|
"auftraege": fetch_auftragskatalog_state(payload.device_id),
|
||||||
"ansible_repo": ANSIBLE_CONTENT_REPO,
|
"ansible_repo": ANSIBLE_CONTENT_REPO,
|
||||||
"poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS,
|
"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}
|
||||||
|
|||||||
52
migrations/0007_auftragskatalog.sql
Normal file
52
migrations/0007_auftragskatalog.sql
Normal file
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user