"""Cluster-level semantic handlers.""" from __future__ import annotations import json from typing import Any from fastapi import Request from app.api.errors import ApiError from app.api.registry import HandlerRegistry from app.handlers.common import ( cluster_metadata, database, save_cluster_metadata, state, subdirs, values, ) from app.simulation.seed import CLUSTER_ID def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]: jobs = metadata.get("replication", []) if not isinstance(jobs, list): return [] return [dict(item) for item in jobs if isinstance(item, dict)] def register_cluster_handlers(registry: HandlerRegistry) -> None: async def cluster_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]: return subdirs( "acme", "backup", "backup-info", "config", "ha", "log", "mapping", "nextid", "notifications", "options", "replication", "sdn", "status", "tasks", ) async def cluster_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( """SELECT id, name, status FROM nodes ORDER BY name""" ) result: list[dict[str, Any]] = [] for index, row in enumerate(rows): online = str(row["status"]) == "online" result.append( { "id": str(row["id"]), "name": str(row["name"]), "nodeid": index, "online": 1 if online else 0, "local": 1 if index == 0 else 0, "ip": f"10.32.{index // 254 + 1}.{index % 254 + 10}", "level": "c", "type": "node", "quorate": 1, } ) return result async def cluster_nextid(request: Request, inputs: dict[str, Any]) -> int: requested = values(inputs).get("vmid") if requested is not None: candidate = int(requested) taken = await database(request).pool.fetchval( """SELECT EXISTS( SELECT 1 FROM resources WHERE kind IN ('qemu', 'lxc') AND external_id=$1 )""", str(candidate), ) if not taken: return candidate raise ApiError(400, f"VMID {candidate} already exists") maximum = await database(request).pool.fetchval( """SELECT COALESCE(MAX(external_id::integer), 99) FROM resources WHERE kind IN ('qemu', 'lxc')""" ) return int(maximum) + 1 async def cluster_options_get(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]: row = await database(_request).pool.fetchrow( "SELECT metadata FROM clusters WHERE id=$1", CLUSTER_ID, ) metadata = state(row["metadata"]) if row is not None else {} options = metadata.get("options", {}) if not isinstance(options, dict): options = {} return { "keyboard": options.get("keyboard", "en-us"), "email_from": options.get("email_from", "root@localhost"), "http_proxy": options.get("http_proxy", ""), "description": options.get("description", "Proxmox API emulator cluster"), **options, } async def cluster_options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: current = await cluster_options_get(request, inputs) provided = values(inputs) updated = {**current, **{key: value for key, value in provided.items() if key != "node"}} await database(request).pool.execute( """UPDATE clusters SET metadata = jsonb_set( COALESCE(metadata, '{}'::jsonb), '{options}', $2::jsonb, true ), updated_at=now() WHERE id=$1""", CLUSTER_ID, json.dumps(updated, sort_keys=True), ) return updated async def cluster_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: limit = int(values(inputs).get("max") or 50) rows = await database(request).pool.fetch( """SELECT tl.message, tl.sequence FROM task_logs tl ORDER BY tl.created_at DESC, tl.sequence DESC LIMIT $1""", limit, ) return [{"n": int(row["sequence"]), "t": str(row["message"])} for row in reversed(rows)] async def cluster_tasks(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( "SELECT upid FROM tasks ORDER BY created_at DESC LIMIT 1000" ) return [{"upid": str(row["upid"])} for row in rows] async def replication_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: metadata = await cluster_metadata(request) return _replication_jobs(metadata) async def replication_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: from app.handlers.common import require_value payload = values(inputs) guest = str(require_value(payload, "guest")) target = str(require_value(payload, "target")) job_id = str(payload.get("id") or f"repl-{guest.replace(':', '-')}") metadata = await cluster_metadata(request) jobs = _replication_jobs(metadata) if any(str(item.get("id")) == job_id for item in jobs): raise ApiError(409, "replication job already exists") job = { "id": job_id, "guest": guest, "target": target, "type": str(payload.get("type") or "local"), "schedule": str(payload.get("schedule") or "*/15"), "rate": int(payload.get("rate") or 1), "comment": str(payload.get("comment") or ""), "enabled": int(payload.get("enabled", 1)), } jobs.append(job) metadata["replication"] = jobs await save_cluster_metadata(request, metadata) return job async def replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: job_id = str(values(inputs)["id"]) for job in _replication_jobs(await cluster_metadata(request)): if str(job.get("id")) == job_id: return job raise ApiError(404, "replication job does not exist") async def replication_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: job_id = str(values(inputs)["id"]) metadata = await cluster_metadata(request) jobs = _replication_jobs(metadata) for index, job in enumerate(jobs): if str(job.get("id")) != job_id: continue payload = values(inputs) updated = { **job, **{ key: value for key, value in payload.items() if key not in {"id", "delete", "digest"} }, } jobs[index] = updated metadata["replication"] = jobs await save_cluster_metadata(request, metadata) return updated raise ApiError(404, "replication job does not exist") async def replication_delete(request: Request, inputs: dict[str, Any]) -> None: job_id = str(values(inputs)["id"]) metadata = await cluster_metadata(request) jobs = _replication_jobs(metadata) remaining = [job for job in jobs if str(job.get("id")) != job_id] if len(remaining) == len(jobs): raise ApiError(404, "replication job does not exist") metadata["replication"] = remaining await save_cluster_metadata(request, metadata) registry.register("/cluster", "GET", cluster_index) registry.register("/cluster/status", "GET", cluster_status) registry.register("/cluster/nextid", "GET", cluster_nextid) registry.register("/cluster/options", "GET", cluster_options_get) registry.register("/cluster/options", "PUT", cluster_options_put) registry.register("/cluster/log", "GET", cluster_log) registry.register("/cluster/tasks", "GET", cluster_tasks) registry.register("/cluster/replication", "GET", replication_list) registry.register("/cluster/replication", "POST", replication_create) registry.register("/cluster/replication/{id}", "GET", replication_get) registry.register("/cluster/replication/{id}", "PUT", replication_update) registry.register("/cluster/replication/{id}", "DELETE", replication_delete)