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 pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
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, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
|
||||||
LOG_PATH = Path("activation.log")
|
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"))
|
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
@ -36,25 +29,6 @@ class ResolveRequest(BaseModel):
|
|||||||
device_id: str
|
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:
|
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")
|
||||||
@ -328,103 +302,6 @@ def fetch_templates(organization_id: str) -> list[dict]:
|
|||||||
for row in rows
|
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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {
|
return {
|
||||||
@ -573,143 +450,3 @@ def get_fedora_kickstart():
|
|||||||
"installers/fedora-workstation/ks.cfg",
|
"installers/fedora-workstation/ks.cfg",
|
||||||
media_type="text/plain"
|
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