feat: Auftragskatalog-Auswahl-API und optionen-Passthrough
Neue Migrationen 0008 (device_merkmale.optionen JSONB) und 0009 (Kategorien app-store/individuelle-systemkonfiguration, Katalogfähigkeit für guest-session-ephemeral/browser-brave/office-onlyoffice). Check-in-Antwort gibt optionen je Auftrag mit. Neue admin-token-geschützte Endpoints zum Auflisten/Auswählen/Abwählen von Aufträgen pro Gerät. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
dc9bc40991
commit
05a80f0998
198
app.py
198
app.py
@ -19,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", "")
|
||||
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
|
||||
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
|
||||
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
|
||||
|
||||
@ -55,6 +56,10 @@ class AgentReportRequest(BaseModel):
|
||||
results: list[AgentReportEntry]
|
||||
|
||||
|
||||
class AuftragSelectRequest(BaseModel):
|
||||
optionen: dict = {}
|
||||
|
||||
|
||||
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,6 +333,17 @@ def fetch_templates(organization_id: str) -> list[dict]:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def require_admin_token(authorization: str | None) -> bool:
|
||||
if not ADMIN_TOKEN:
|
||||
return False
|
||||
|
||||
if authorization is None or not authorization.startswith("Bearer "):
|
||||
return False
|
||||
|
||||
provided_token = authorization.removeprefix("Bearer ")
|
||||
|
||||
return hmac.compare_digest(provided_token, ADMIN_TOKEN)
|
||||
|
||||
def hash_agent_secret(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
@ -415,7 +431,8 @@ def fetch_auftragskatalog_state(device_id: str):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.key, bp.ansible_role,
|
||||
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL)
|
||||
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL),
|
||||
COALESCE(dm.optionen, '{}'::jsonb)
|
||||
FROM merkmale m
|
||||
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
||||
LEFT JOIN device_merkmale dm
|
||||
@ -431,10 +448,116 @@ def fetch_auftragskatalog_state(device_id: str):
|
||||
"merkmal": key,
|
||||
"ansible_role": ansible_role,
|
||||
"state": "present" if aktiv else "absent",
|
||||
"optionen": optionen,
|
||||
}
|
||||
for key, ansible_role, aktiv in cur.fetchall()
|
||||
for key, ansible_role, aktiv, optionen in cur.fetchall()
|
||||
]
|
||||
|
||||
def fetch_auftragskatalog_listing(device_id: str):
|
||||
"""
|
||||
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
|
||||
Anzeige-relevante Felder (Name, Beschreibung, Kategorie) statt nur die
|
||||
für den Agenten nötigen (Rollenname, State).
|
||||
"""
|
||||
|
||||
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 None
|
||||
|
||||
workspace_id, backend_id = assignment_row
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.key, m.name, m.description,
|
||||
k.key, k.name, k.sort_order,
|
||||
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL),
|
||||
COALESCE(dm.optionen, '{}'::jsonb)
|
||||
FROM merkmale m
|
||||
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
||||
LEFT JOIN kategorien k ON k.id = m.kategorie_id
|
||||
LEFT JOIN device_merkmale dm
|
||||
ON dm.merkmal_id = m.id AND dm.device_id = %s
|
||||
LEFT JOIN workspace_merkmale wm
|
||||
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
|
||||
WHERE m.im_auftragskatalog
|
||||
ORDER BY k.sort_order NULLS LAST, m.name
|
||||
""",
|
||||
(backend_id, device_id, workspace_id),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"merkmal": key,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"kategorie": (
|
||||
{"key": kat_key, "name": kat_name, "sort_order": sort_order}
|
||||
if kat_key is not None else None
|
||||
),
|
||||
"state": "present" if aktiv else "absent",
|
||||
"optionen": optionen,
|
||||
}
|
||||
for (
|
||||
key, name, description,
|
||||
kat_key, kat_name, sort_order,
|
||||
aktiv, optionen,
|
||||
) in cur.fetchall()
|
||||
]
|
||||
|
||||
def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
|
||||
"""
|
||||
Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in
|
||||
device_merkmale) und protokolliert die Änderung als selected/deselected-
|
||||
Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key
|
||||
existiert (unbekannter Key oder im_auftragskatalog = false).
|
||||
"""
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
|
||||
(merkmal_key,),
|
||||
)
|
||||
merkmal_row = cur.fetchone()
|
||||
|
||||
if merkmal_row is None:
|
||||
return False
|
||||
|
||||
merkmal_id = merkmal_row[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (device_id, merkmal_id) DO UPDATE
|
||||
SET aktiv = EXCLUDED.aktiv,
|
||||
optionen = EXCLUDED.optionen,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
@ -723,3 +846,74 @@ def agent_report(
|
||||
)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.get("/api/v1/devices/{device_id}/auftragskatalog")
|
||||
def get_device_auftragskatalog(
|
||||
device_id: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
if not require_admin_token(authorization):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unauthorized",
|
||||
"message": "Fehlendes oder ungültiges Admin-Token.",
|
||||
}
|
||||
|
||||
katalog = fetch_auftragskatalog_listing(device_id)
|
||||
|
||||
if katalog is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "device_not_assigned",
|
||||
"message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.",
|
||||
}
|
||||
|
||||
return {"success": True, "auftragskatalog": katalog}
|
||||
|
||||
|
||||
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select")
|
||||
def select_auftrag(
|
||||
device_id: str,
|
||||
merkmal_key: str,
|
||||
payload: AuftragSelectRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
if not require_admin_token(authorization):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unauthorized",
|
||||
"message": "Fehlendes oder ungültiges Admin-Token.",
|
||||
}
|
||||
|
||||
if not set_auftrag_selection(device_id, merkmal_key, True, payload.optionen):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unknown_merkmal",
|
||||
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
|
||||
}
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect")
|
||||
def deselect_auftrag(
|
||||
device_id: str,
|
||||
merkmal_key: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
if not require_admin_token(authorization):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unauthorized",
|
||||
"message": "Fehlendes oder ungültiges Admin-Token.",
|
||||
}
|
||||
|
||||
if not set_auftrag_selection(device_id, merkmal_key, False, {}):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unknown_merkmal",
|
||||
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
|
||||
}
|
||||
|
||||
return {"success": True}
|
||||
|
||||
5
migrations/0008_device_merkmale_optionen.sql
Normal file
5
migrations/0008_device_merkmale_optionen.sql
Normal file
@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE device_merkmale ADD COLUMN optionen JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMIT;
|
||||
24
migrations/0009_auftragskatalog_seed.sql
Normal file
24
migrations/0009_auftragskatalog_seed.sql
Normal file
@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO kategorien (id, key, name, description, sort_order) VALUES
|
||||
('99999999-9999-4999-8999-999999999999', 'app-store', 'App-Store',
|
||||
'Installierbare Anwendungen.', 10),
|
||||
('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'individuelle-systemkonfiguration', 'Individuelle Systemkonfiguration',
|
||||
'Verhaltensänderungen am System, keine reinen Anwendungsinstallationen.', 20);
|
||||
|
||||
UPDATE merkmale SET
|
||||
im_auftragskatalog = true,
|
||||
kategorie_id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
WHERE key = 'guest-session-ephemeral';
|
||||
|
||||
UPDATE merkmale SET
|
||||
im_auftragskatalog = true,
|
||||
kategorie_id = '99999999-9999-4999-8999-999999999999'
|
||||
WHERE key = 'browser-brave';
|
||||
|
||||
UPDATE merkmale SET
|
||||
im_auftragskatalog = true,
|
||||
kategorie_id = '99999999-9999-4999-8999-999999999999'
|
||||
WHERE key = 'office-onlyoffice';
|
||||
|
||||
COMMIT;
|
||||
Loading…
x
Reference in New Issue
Block a user