from flask import Blueprint, Response, current_app, jsonify, redirect, request from sqlalchemy import select from sqlalchemy.exc import IntegrityError from l4d2web.auth import current_user, require_login from l4d2web.db import session_scope from l4d2web.models import Blueprint as BlueprintModel from l4d2web.models import Job, Server from l4d2web.services.security import validate_instance_name bp = Blueprint("server", __name__) def _allocate_next_port(db) -> int | None: start = int(current_app.config["PORT_RANGE_START"]) end = int(current_app.config["PORT_RANGE_END"]) used = set( db.scalars( select(Server.port).where(Server.port >= start, Server.port <= end) ).all() ) for port in range(start, end + 1): if port not in used: return port return None def _resolve_port(payload, db) -> tuple[int | None, Response | None]: raw = payload.get("port") if raw is None or (isinstance(raw, str) and raw.strip() == ""): port = _allocate_next_port(db) if port is None: return None, Response("no free port available", status=409) return port, None try: port = int(raw) except (TypeError, ValueError): return None, Response("invalid port", status=400) if not 1 <= port <= 65535: return None, Response("invalid port", status=400) return port, None @bp.post("/servers") @require_login def create_server() -> Response: user = current_user() assert user is not None json_response = request.is_json payload = request.get_json(silent=True) if json_response else request.form try: name = validate_instance_name(str(payload["name"])) except (KeyError, TypeError, ValueError): return Response("invalid server name", status=400) with session_scope() as db: blueprint = db.scalar( select(BlueprintModel).where( BlueprintModel.id == int(payload["blueprint_id"]), BlueprintModel.user_id == user.id, ) ) if blueprint is None: return Response("blueprint not found", status=404) port, error = _resolve_port(payload, db) if error is not None: return error server = Server( user_id=user.id, blueprint_id=blueprint.id, name=name, port=port, desired_state="stopped", actual_state="unknown", last_error="", ) db.add(server) try: db.flush() except IntegrityError: db.rollback() return Response("port already in use", status=409) server_id = server.id if json_response: return jsonify({"id": server_id}), 201 return redirect(f"/servers/{server_id}") @bp.patch("/servers/") @require_login def update_server(server_id: int) -> Response: user = current_user() assert user is not None payload = request.get_json(silent=True) or {} with session_scope() as db: server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id)) if server is None: return Response(status=404) blueprint = db.scalar( select(BlueprintModel).where( BlueprintModel.id == int(payload["blueprint_id"]), BlueprintModel.user_id == user.id, ) ) if blueprint is None: return Response("blueprint not found", status=404) server.blueprint_id = blueprint.id return jsonify({"id": server_id}), 200 LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"} @bp.post("/servers//") @require_login def enqueue_server_operation(server_id: int, operation: str) -> Response: user = current_user() assert user is not None if operation not in LIFECYCLE_OPERATIONS: return Response(status=404) with session_scope() as db: server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id)) if server is None: return Response(status=404) db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued")) if operation == "start": server.desired_state = "running" if operation in {"stop", "delete"}: server.desired_state = "stopped" if operation == "delete": return redirect("/servers") return redirect(f"/servers/{server_id}")