Compare commits
No commits in common. "eb73f2b6b737711392bee76f5d8097079f54449b" and "e02b646564f039e4f60a851ce29f41da6b94d884" have entirely different histories.
eb73f2b6b7
...
e02b646564
265
app.py
265
app.py
@ -1,25 +1,18 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from fastapi import FastAPI, Header, Request
|
||||
from fastapi import FastAPI, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@ -36,25 +29,6 @@ class ResolveRequest(BaseModel):
|
||||
device_id: str
|
||||
|
||||
|
||||
class AgentBootstrapRequest(BaseModel):
|
||||
device_id: str
|
||||
|
||||
|
||||
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")
|
||||
@ -328,103 +302,6 @@ def fetch_templates(organization_id: str) -> list[dict]:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def hash_agent_secret(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
def verify_agent_secret(device_id: str, provided_secret: str) -> bool:
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT agent_secret_hash FROM devices WHERE id = %s",
|
||||
(device_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None or row[0] is None:
|
||||
return False
|
||||
|
||||
return hmac.compare_digest(row[0], hash_agent_secret(provided_secret))
|
||||
|
||||
def fetch_assigned_blueprints(device_id: str):
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT bv.workspace_id, 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 []
|
||||
|
||||
workspace_id, backend_id = assignment_row
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.key, bp.ansible_role
|
||||
FROM workspace_merkmale wm
|
||||
JOIN merkmale m ON m.id = wm.merkmal_id
|
||||
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
||||
WHERE wm.workspace_id = %s
|
||||
""",
|
||||
(backend_id, workspace_id),
|
||||
)
|
||||
return [
|
||||
{"merkmal": key, "ansible_role": ansible_role}
|
||||
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 {
|
||||
@ -573,143 +450,3 @@ def get_fedora_kickstart():
|
||||
"installers/fedora-workstation/ks.cfg",
|
||||
media_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/agent/bootstrap")
|
||||
def agent_bootstrap(payload: AgentBootstrapRequest):
|
||||
agent_secret = secrets.token_urlsafe(32)
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET agent_secret_hash = %s, agent_secret_issued_at = %s
|
||||
WHERE id = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
hash_agent_secret(agent_secret),
|
||||
datetime.now(timezone.utc),
|
||||
payload.device_id,
|
||||
),
|
||||
)
|
||||
updated = cur.fetchone()
|
||||
|
||||
if updated is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "device_not_found",
|
||||
"message": "Das angegebene Gerät wurde nicht gefunden.",
|
||||
}
|
||||
|
||||
return {"success": True, "agent_secret": agent_secret}
|
||||
|
||||
|
||||
@app.post("/api/v1/agent/checkin")
|
||||
def agent_checkin(
|
||||
payload: AgentCheckinRequest,
|
||||
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.",
|
||||
}
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE devices SET agent_last_checkin = %s WHERE id = %s",
|
||||
(datetime.now(timezone.utc), payload.device_id),
|
||||
)
|
||||
|
||||
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}
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE devices ADD COLUMN agent_secret_hash TEXT;
|
||||
ALTER TABLE devices ADD COLUMN agent_secret_issued_at TIMESTAMPTZ;
|
||||
ALTER TABLE devices ADD COLUMN agent_last_checkin TIMESTAMPTZ;
|
||||
|
||||
COMMIT;
|
||||
@ -1,52 +0,0 @@
|
||||
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