Port core API routes for sites and devices

This commit is contained in:
2025-10-22 23:43:16 -06:00
parent d0fa6929b2
commit 4bc529aaf4
22 changed files with 2092 additions and 1 deletions

View File

@@ -6,7 +6,20 @@ from flask import Flask
from Data.Engine.services.container import EngineServiceContainer
from . import admin, agents, auth, enrollment, github, health, job_management, tokens, users
from . import (
admin,
agents,
auth,
enrollment,
github,
health,
job_management,
tokens,
users,
sites,
devices,
credentials,
)
_REGISTRARS = (
health.register,
@@ -18,6 +31,9 @@ _REGISTRARS = (
auth.register,
admin.register,
users.register,
sites.register,
devices.register,
credentials.register,
)

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from flask import Blueprint, Flask, current_app, jsonify, request, session
from Data.Engine.services.container import EngineServiceContainer
blueprint = Blueprint("engine_credentials", __name__)
def register(app: Flask, _services: EngineServiceContainer) -> None:
if "engine_credentials" not in app.blueprints:
app.register_blueprint(blueprint)
def _services() -> EngineServiceContainer:
services = current_app.extensions.get("engine_services")
if services is None: # pragma: no cover - defensive
raise RuntimeError("engine services not initialized")
return services
def _credentials_service():
return _services().credential_service
def _require_admin():
username = session.get("username")
role = (session.get("role") or "").strip().lower()
if not isinstance(username, str) or not username:
return jsonify({"error": "not_authenticated"}), 401
if role != "admin":
return jsonify({"error": "forbidden"}), 403
return None
@blueprint.route("/api/credentials", methods=["GET"])
def list_credentials() -> object:
guard = _require_admin()
if guard:
return guard
site_id_param = request.args.get("site_id")
connection_type = (request.args.get("connection_type") or "").strip() or None
try:
site_id = int(site_id_param) if site_id_param not in (None, "") else None
except (TypeError, ValueError):
site_id = None
records = _credentials_service().list_credentials(
site_id=site_id,
connection_type=connection_type,
)
return jsonify({"credentials": records})
@blueprint.route("/api/credentials", methods=["POST"])
def create_credential() -> object: # pragma: no cover - placeholder
return jsonify({"error": "not implemented"}), 501
@blueprint.route("/api/credentials/<int:credential_id>", methods=["GET", "PUT", "DELETE"])
def credential_detail(credential_id: int) -> object: # pragma: no cover - placeholder
if request.method == "GET":
return jsonify({"error": "not implemented"}), 501
if request.method == "DELETE":
return jsonify({"error": "not implemented"}), 501
return jsonify({"error": "not implemented"}), 501
__all__ = ["register", "blueprint"]

View File

@@ -0,0 +1,301 @@
from __future__ import annotations
from ipaddress import ip_address
from flask import Blueprint, Flask, current_app, jsonify, request, session
from Data.Engine.services.container import EngineServiceContainer
from Data.Engine.services.devices import RemoteDeviceError
blueprint = Blueprint("engine_devices", __name__)
def register(app: Flask, _services: EngineServiceContainer) -> None:
if "engine_devices" not in app.blueprints:
app.register_blueprint(blueprint)
def _services() -> EngineServiceContainer:
services = current_app.extensions.get("engine_services")
if services is None: # pragma: no cover - defensive
raise RuntimeError("engine services not initialized")
return services
def _inventory():
return _services().device_inventory
def _views():
return _services().device_view_service
def _require_admin():
username = session.get("username")
role = (session.get("role") or "").strip().lower()
if not isinstance(username, str) or not username:
return jsonify({"error": "not_authenticated"}), 401
if role != "admin":
return jsonify({"error": "forbidden"}), 403
return None
def _is_internal_request(req: request) -> bool:
remote = (req.remote_addr or "").strip()
if not remote:
return False
try:
return ip_address(remote).is_loopback
except ValueError:
return remote in {"localhost"}
@blueprint.route("/api/devices", methods=["GET"])
def list_devices() -> object:
devices = _inventory().list_devices()
return jsonify({"devices": devices})
@blueprint.route("/api/devices/<guid>", methods=["GET"])
def get_device_by_guid(guid: str) -> object:
device = _inventory().get_device_by_guid(guid)
if not device:
return jsonify({"error": "not found"}), 404
return jsonify(device)
@blueprint.route("/api/agent_devices", methods=["GET"])
def list_agent_devices() -> object:
guard = _require_admin()
if guard:
return guard
devices = _inventory().list_agent_devices()
return jsonify({"devices": devices})
@blueprint.route("/api/ssh_devices", methods=["GET", "POST"])
def ssh_devices() -> object:
return _remote_devices_endpoint("ssh")
@blueprint.route("/api/winrm_devices", methods=["GET", "POST"])
def winrm_devices() -> object:
return _remote_devices_endpoint("winrm")
@blueprint.route("/api/ssh_devices/<hostname>", methods=["PUT", "DELETE"])
def ssh_device_detail(hostname: str) -> object:
return _remote_device_detail("ssh", hostname)
@blueprint.route("/api/winrm_devices/<hostname>", methods=["PUT", "DELETE"])
def winrm_device_detail(hostname: str) -> object:
return _remote_device_detail("winrm", hostname)
@blueprint.route("/api/agent/hash_list", methods=["GET"])
def agent_hash_list() -> object:
if not _is_internal_request(request):
remote_addr = (request.remote_addr or "unknown").strip() or "unknown"
current_app.logger.warning(
"/api/agent/hash_list denied non-local request from %s", remote_addr
)
return jsonify({"error": "forbidden"}), 403
try:
records = _inventory().collect_agent_hash_records()
except Exception as exc: # pragma: no cover - defensive logging
current_app.logger.exception("/api/agent/hash_list error: %s", exc)
return jsonify({"error": "internal error"}), 500
return jsonify({"agents": records})
@blueprint.route("/api/device_list_views", methods=["GET"])
def list_device_list_views() -> object:
views = _views().list_views()
return jsonify({"views": [view.to_dict() for view in views]})
@blueprint.route("/api/device_list_views/<int:view_id>", methods=["GET"])
def get_device_list_view(view_id: int) -> object:
view = _views().get_view(view_id)
if not view:
return jsonify({"error": "not found"}), 404
return jsonify(view.to_dict())
@blueprint.route("/api/device_list_views", methods=["POST"])
def create_device_list_view() -> object:
payload = request.get_json(silent=True) or {}
name = (payload.get("name") or "").strip()
columns = payload.get("columns") or []
filters = payload.get("filters") or {}
if not name:
return jsonify({"error": "name is required"}), 400
if name.lower() == "default view":
return jsonify({"error": "reserved name"}), 400
if not isinstance(columns, list) or not all(isinstance(x, str) for x in columns):
return jsonify({"error": "columns must be a list of strings"}), 400
if not isinstance(filters, dict):
return jsonify({"error": "filters must be an object"}), 400
try:
view = _views().create_view(name, columns, filters)
except ValueError as exc:
if str(exc) == "duplicate":
return jsonify({"error": "name already exists"}), 409
raise
response = jsonify(view.to_dict())
response.status_code = 201
return response
@blueprint.route("/api/device_list_views/<int:view_id>", methods=["PUT"])
def update_device_list_view(view_id: int) -> object:
payload = request.get_json(silent=True) or {}
updates: dict = {}
if "name" in payload:
name_val = payload.get("name")
if name_val is None:
return jsonify({"error": "name cannot be empty"}), 400
normalized = (str(name_val) or "").strip()
if not normalized:
return jsonify({"error": "name cannot be empty"}), 400
if normalized.lower() == "default view":
return jsonify({"error": "reserved name"}), 400
updates["name"] = normalized
if "columns" in payload:
columns_val = payload.get("columns")
if not isinstance(columns_val, list) or not all(isinstance(x, str) for x in columns_val):
return jsonify({"error": "columns must be a list of strings"}), 400
updates["columns"] = columns_val
if "filters" in payload:
filters_val = payload.get("filters")
if filters_val is not None and not isinstance(filters_val, dict):
return jsonify({"error": "filters must be an object"}), 400
if filters_val is not None:
updates["filters"] = filters_val
if not updates:
return jsonify({"error": "no fields to update"}), 400
try:
view = _views().update_view(
view_id,
name=updates.get("name"),
columns=updates.get("columns"),
filters=updates.get("filters"),
)
except ValueError as exc:
code = str(exc)
if code == "duplicate":
return jsonify({"error": "name already exists"}), 409
if code == "missing_name":
return jsonify({"error": "name cannot be empty"}), 400
if code == "reserved":
return jsonify({"error": "reserved name"}), 400
return jsonify({"error": "invalid payload"}), 400
except LookupError:
return jsonify({"error": "not found"}), 404
return jsonify(view.to_dict())
@blueprint.route("/api/device_list_views/<int:view_id>", methods=["DELETE"])
def delete_device_list_view(view_id: int) -> object:
if not _views().delete_view(view_id):
return jsonify({"error": "not found"}), 404
return jsonify({"status": "ok"})
def _remote_devices_endpoint(connection_type: str) -> object:
guard = _require_admin()
if guard:
return guard
if request.method == "GET":
devices = _inventory().list_remote_devices(connection_type)
return jsonify({"devices": devices})
payload = request.get_json(silent=True) or {}
hostname = (payload.get("hostname") or "").strip()
address = (
payload.get("address")
or payload.get("connection_endpoint")
or payload.get("endpoint")
or payload.get("host")
)
description = payload.get("description")
os_hint = payload.get("operating_system") or payload.get("os")
if not hostname:
return jsonify({"error": "hostname is required"}), 400
if not (address or "").strip():
return jsonify({"error": "address is required"}), 400
try:
device = _inventory().upsert_remote_device(
connection_type,
hostname,
address,
description,
os_hint,
ensure_existing_type=None,
)
except RemoteDeviceError as exc:
status = 409 if exc.code in {"conflict", "address_required"} else 500
if exc.code == "conflict":
return jsonify({"error": str(exc)}), 409
if exc.code == "address_required":
return jsonify({"error": "address is required"}), 400
return jsonify({"error": str(exc)}), status
return jsonify({"device": device}), 201
def _remote_device_detail(connection_type: str, hostname: str) -> object:
guard = _require_admin()
if guard:
return guard
normalized_host = (hostname or "").strip()
if not normalized_host:
return jsonify({"error": "invalid hostname"}), 400
if request.method == "DELETE":
try:
_inventory().delete_remote_device(connection_type, normalized_host)
except RemoteDeviceError as exc:
if exc.code == "not_found":
return jsonify({"error": "device not found"}), 404
if exc.code == "invalid_hostname":
return jsonify({"error": "invalid hostname"}), 400
return jsonify({"error": str(exc)}), 500
return jsonify({"status": "ok"})
payload = request.get_json(silent=True) or {}
address = (
payload.get("address")
or payload.get("connection_endpoint")
or payload.get("endpoint")
)
description = payload.get("description")
os_hint = payload.get("operating_system") or payload.get("os")
if address is None and description is None and os_hint is None:
return jsonify({"error": "no fields to update"}), 400
try:
device = _inventory().upsert_remote_device(
connection_type,
normalized_host,
address if address is not None else "",
description,
os_hint,
ensure_existing_type=connection_type,
)
except RemoteDeviceError as exc:
if exc.code == "not_found":
return jsonify({"error": "device not found"}), 404
if exc.code == "address_required":
return jsonify({"error": "address is required"}), 400
return jsonify({"error": str(exc)}), 500
return jsonify({"device": device})
__all__ = ["register", "blueprint"]

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from flask import Blueprint, Flask, current_app, jsonify, request
from Data.Engine.services.container import EngineServiceContainer
blueprint = Blueprint("engine_sites", __name__)
def register(app: Flask, _services: EngineServiceContainer) -> None:
if "engine_sites" not in app.blueprints:
app.register_blueprint(blueprint)
def _services() -> EngineServiceContainer:
services = current_app.extensions.get("engine_services")
if services is None: # pragma: no cover - defensive
raise RuntimeError("engine services not initialized")
return services
def _site_service():
return _services().site_service
@blueprint.route("/api/sites", methods=["GET"])
def list_sites() -> object:
records = _site_service().list_sites()
return jsonify({"sites": [record.to_dict() for record in records]})
@blueprint.route("/api/sites", methods=["POST"])
def create_site() -> object:
payload = request.get_json(silent=True) or {}
name = payload.get("name")
description = payload.get("description")
try:
record = _site_service().create_site(name or "", description or "")
except ValueError as exc:
if str(exc) == "missing_name":
return jsonify({"error": "name is required"}), 400
if str(exc) == "duplicate":
return jsonify({"error": "name already exists"}), 409
raise
response = jsonify(record.to_dict())
response.status_code = 201
return response
@blueprint.route("/api/sites/delete", methods=["POST"])
def delete_sites() -> object:
payload = request.get_json(silent=True) or {}
ids = payload.get("ids") or []
if not isinstance(ids, list):
return jsonify({"error": "ids must be a list"}), 400
deleted = _site_service().delete_sites(ids)
return jsonify({"status": "ok", "deleted": deleted})
@blueprint.route("/api/sites/device_map", methods=["GET"])
def sites_device_map() -> object:
host_param = (request.args.get("hostnames") or "").strip()
filter_set = []
if host_param:
for part in host_param.split(","):
normalized = part.strip()
if normalized:
filter_set.append(normalized)
mapping = _site_service().map_devices(filter_set or None)
return jsonify({"mapping": {hostname: entry.to_dict() for hostname, entry in mapping.items()}})
@blueprint.route("/api/sites/assign", methods=["POST"])
def assign_devices_to_site() -> object:
payload = request.get_json(silent=True) or {}
site_id = payload.get("site_id")
hostnames = payload.get("hostnames") or []
if not isinstance(hostnames, list):
return jsonify({"error": "hostnames must be a list of strings"}), 400
try:
_site_service().assign_devices(site_id, hostnames)
except ValueError as exc:
message = str(exc)
if message == "invalid_site_id":
return jsonify({"error": "invalid site_id"}), 400
if message == "invalid_hostnames":
return jsonify({"error": "hostnames must be a list of strings"}), 400
raise
except LookupError:
return jsonify({"error": "site not found"}), 404
return jsonify({"status": "ok"})
@blueprint.route("/api/sites/rename", methods=["POST"])
def rename_site() -> object:
payload = request.get_json(silent=True) or {}
site_id = payload.get("id")
new_name = payload.get("new_name") or ""
try:
record = _site_service().rename_site(site_id, new_name)
except ValueError as exc:
if str(exc) == "missing_name":
return jsonify({"error": "new_name is required"}), 400
if str(exc) == "duplicate":
return jsonify({"error": "name already exists"}), 409
raise
except LookupError:
return jsonify({"error": "site not found"}), 404
return jsonify(record.to_dict())
__all__ = ["register", "blueprint"]