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")
|
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", "")
|
||||||
|
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
|
||||||
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
|
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")
|
||||||
|
|
||||||
@ -55,6 +56,10 @@ class AgentReportRequest(BaseModel):
|
|||||||
results: list[AgentReportEntry]
|
results: list[AgentReportEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class AuftragSelectRequest(BaseModel):
|
||||||
|
optionen: dict = {}
|
||||||
|
|
||||||
|
|
||||||
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,6 +333,17 @@ def fetch_templates(organization_id: str) -> list[dict]:
|
|||||||
for row in rows
|
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:
|
def hash_agent_secret(secret: str) -> str:
|
||||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
@ -415,7 +431,8 @@ def fetch_auftragskatalog_state(device_id: str):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT m.key, bp.ansible_role,
|
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
|
FROM merkmale m
|
||||||
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
||||||
LEFT JOIN device_merkmale dm
|
LEFT JOIN device_merkmale dm
|
||||||
@ -431,10 +448,116 @@ def fetch_auftragskatalog_state(device_id: str):
|
|||||||
"merkmal": key,
|
"merkmal": key,
|
||||||
"ansible_role": ansible_role,
|
"ansible_role": ansible_role,
|
||||||
"state": "present" if aktiv else "absent",
|
"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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {
|
return {
|
||||||
@ -723,3 +846,74 @@ def agent_report(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"success": True}
|
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