Compare commits
2 Commits
e02b646564
...
eb73f2b6b7
| Author | SHA1 | Date | |
|---|---|---|---|
| eb73f2b6b7 | |||
| 341ef92a7a |
265
app.py
265
app.py
@ -1,18 +1,25 @@
|
|||||||
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, Request
|
from fastapi import FastAPI, Header, 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")
|
||||||
|
|
||||||
|
|
||||||
@ -29,6 +36,25 @@ 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")
|
||||||
@ -302,6 +328,103 @@ 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 {
|
||||||
@ -450,3 +573,143 @@ 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}
|
||||||
|
|||||||
7
migrations/0006_agent_credentials.sql
Normal file
7
migrations/0006_agent_credentials.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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;
|
||||||
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