From b3dd24e4ca607ffd10e31e7c7090d6485a425c49 Mon Sep 17 00:00:00 2001 From: chester Date: Mon, 17 Aug 2026 17:23:06 +0800 Subject: [PATCH 01/63] =?UTF-8?q?-=20PROGRESS=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9A=82=E5=AD=98#1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blueprints/container_api.py | 11 +- repositories/containers_repo.py | 21 + schemas/container_cleanup_task.py | 33 +- schemas/container_disk_check_task.py | 117 ++-- schemas/container_mount_cleanup_task.py | 24 +- schemas/container_ssh_refresh_task.py | 19 +- services/container_module/__init__.py | 0 services/container_module/exceptions.py | 27 + services/container_module/node_comms.py | 115 ++++ services/container_module/pydantic_models.py | 70 ++ services/container_module/utils.py | 81 +++ services/container_tasks.py | 673 +++---------------- test/test_container_sql.py | 6 +- utils/permissions.py | 21 + 14 files changed, 537 insertions(+), 681 deletions(-) create mode 100644 services/container_module/__init__.py create mode 100644 services/container_module/exceptions.py create mode 100644 services/container_module/node_comms.py create mode 100644 services/container_module/pydantic_models.py create mode 100644 services/container_module/utils.py create mode 100644 utils/permissions.py diff --git a/blueprints/container_api.py b/blueprints/container_api.py index ab8b554..55a3c4c 100644 --- a/blueprints/container_api.py +++ b/blueprints/container_api.py @@ -1,3 +1,4 @@ +import logging from sqlalchemy.exc import IntegrityError from flask import jsonify, request from flask import current_app @@ -10,6 +11,8 @@ from ..repositories import containers_repo, authentications_repo, user_repo from ..schemas.user_schema import user_schema, users_schema +logger = logging.getLogger(__name__) + # map known error_reason strings to HTTP status codes so we can surface them to clients REASON_STATUS_MAP = { 'container_exists': 409, @@ -37,7 +40,13 @@ def _log_failure(*, operation, target_type, target_id, operator_user_id, error_reason, detail=None): - """蓝图层失败补记:task 层直接上抛/返回 False 的失败在这里统一记一条。""" + """蓝图层失败补记:task 层直接上抛/返回 False 的失败在这里统一记一条。 + + .log 与 op-log 同源:error 级落日志文件,success=False 落操作日志表。 + """ + logger.error("operation failed: op=%s target=%s/%s user=%s reason=%s detail=%s", + getattr(operation, 'value', operation), target_type, target_id, + operator_user_id, error_reason, detail or {}) write_op_log(success=False, operator_user_id=operator_user_id, operation=operation, target_type=target_type, target_id=target_id, detail=detail or {}, error_reason=error_reason) diff --git a/repositories/containers_repo.py b/repositories/containers_repo.py index 21f289e..599c6f8 100644 --- a/repositories/containers_repo.py +++ b/repositories/containers_repo.py @@ -266,4 +266,25 @@ def check_duplicate_container_name(container_name: str, machine_id: int) -> None # Log and ignore DB lookup errors at validation level return + +def validate_create_params(machine_id: int, container: Container_info, public_key: str | None = None) -> None: + """创建容器前的参数校验序列(纯校验,无返回值)。 + + 依次校验:机器存在性 → GPU → memory/shared(shared <= memory,memory 校验在内部先跑) + → CPU → 名称长度/格式 → 重名。 + 抛 ValueError / IntegrityError,异常语义与逐条调用时一致。 + """ + # 存在性检查 + ensure_machine_exists(machine_id) + # GPU 参数检查 + validate_gpu_request(machine, container) + # memory/shared 参数检查(要求 shared <= memory;memory 校验在内部先跑) + validate_shared_request(machine, container) + # cpu 参数检查 + validate_cpu_request(machine, container) + # name/image/public_key length and format checks + validate_names_and_lengths(container, public_key) + # duplicate name check (may raise IntegrityError) + check_duplicate_container_name(container_name=container.NAME, machine_id=machine_id) + ############################### \ No newline at end of file diff --git a/schemas/container_cleanup_task.py b/schemas/container_cleanup_task.py index 8b89109..f748e97 100644 --- a/schemas/container_cleanup_task.py +++ b/schemas/container_cleanup_task.py @@ -1,6 +1,7 @@ import threading import time import json +import logging from datetime import datetime from flask import Flask, current_app @@ -10,6 +11,8 @@ from ..services import container_tasks from ..utils.mail import send as send_mail +logger = logging.getLogger(__name__) + def _parse_reminder_hours(raw: str | None) -> list[int]: values = [] @@ -74,7 +77,7 @@ def _send_cleanup_reminders_if_needed(container_id: int, info: dict, app: Flask) ) recipients = container_tasks.get_container_root_owner_emails(container_id) if not recipients: - print(f"[container-cleanup] reminder skipped for container_id={container_id}: no root owner email") + logger.info("[container-cleanup] reminder skipped for container_id=%s: no root owner email", container_id) return label = _format_hours(hours) @@ -96,7 +99,7 @@ def _send_cleanup_reminders_if_needed(container_id: int, info: dict, app: Flask) result = send_mail(to=email, subject=subject, content=content) if result.get("ok"): if container_cleanup_reminder_repo.mark_sent(container_id, reminder_key, cleanup_at, email): - print(f"[container-cleanup] reminder sent container_id={container_id} threshold={reminder_key} to={email}") + logger.info("[container-cleanup] reminder sent container_id=%s threshold=%s to=%s", container_id, reminder_key, email) from ..services.operation_log_tasks import write_operation_log as write_op_log write_op_log(success=True, operation=OperationType.SEND_CLEANUP_REMINDER, @@ -109,9 +112,9 @@ def _send_cleanup_reminders_if_needed(container_id: int, info: dict, app: Flask) }, ) else: - print(f"[container-cleanup] reminder duplicate container_id={container_id} threshold={reminder_key} to={email} (already recorded)") + logger.warning("[container-cleanup] reminder duplicate container_id=%s threshold=%s to=%s (already recorded)", container_id, reminder_key, email) else: - print(f"[container-cleanup] reminder failed container_id={container_id} threshold={reminder_key} to={email}: {result}") + logger.error("[container-cleanup] reminder failed container_id=%s threshold=%s to=%s: %s", container_id, reminder_key, email, result) def cleanup_expired_containers_once(cleanup_after_days: int) -> None: @@ -129,7 +132,7 @@ def cleanup_expired_containers_once(cleanup_after_days: int) -> None: info = container_tasks.build_cleanup_info(rec.last_ssh_login_time, cleanup_after_days) cid = int(rec.container_id) if long_term_container_repo.is_long_term(cid): - print(f"[container-cleanup] container_id={cid} is long-term, skipping cleanup") + logger.info("[container-cleanup] container_id=%s is long-term, skipping cleanup", cid) continue info_with_record = { **info, @@ -150,21 +153,17 @@ def cleanup_expired_containers_once(cleanup_after_days: int) -> None: **info_with_record, }, ) - print( - "[container-cleanup] restore_snapshot=" - + json.dumps(snapshot, ensure_ascii=False, sort_keys=True) - ) - print(f"[container-cleanup] container_id={cid} due for cleanup, removing...") + logger.debug("[container-cleanup] restore_snapshot=%s", + json.dumps(snapshot, ensure_ascii=False, sort_keys=True)) + logger.info("[container-cleanup] container_id=%s due for cleanup, removing...", cid) ok = container_tasks.remove_container(container_id=cid) if ok: - print(f"[container-cleanup] removed container_id={cid}") + logger.info("[container-cleanup] removed container_id=%s", cid) else: - print(f"[container-cleanup] remove returned False for container_id={cid}") + logger.warning("[container-cleanup] remove returned False for container_id=%s", cid) except Exception as e: - print( - f"[container-cleanup] failed for machine_id={getattr(rec, 'machine_id', '?')} " - f"container_id={getattr(rec, 'container_id', '?')}: {e}" - ) + logger.error("[container-cleanup] failed for machine_id=%s container_id=%s: %s", + getattr(rec, 'machine_id', '?'), getattr(rec, 'container_id', '?'), e) def start_container_cleanup_scheduler( @@ -199,7 +198,7 @@ def _worker(): days = int(app.config.get("CONTAINER_CLEANUP_AFTER_DAYS", 7) or 7) cleanup_expired_containers_once(days) except Exception as e: - print(f"[container-cleanup] periodic run failed: {e}") + logger.error("[container-cleanup] periodic run failed: %s", e) t = threading.Thread(target=_worker, daemon=True, name="container-cleanup") t.start() diff --git a/schemas/container_disk_check_task.py b/schemas/container_disk_check_task.py index e96e10e..0c1f66b 100644 --- a/schemas/container_disk_check_task.py +++ b/schemas/container_disk_check_task.py @@ -1,6 +1,7 @@ import json import threading import time +import logging from datetime import datetime, timedelta from flask import Flask, current_app @@ -9,6 +10,8 @@ from ..services import container_tasks from ..utils.parallel import parallel_node_calls +logger = logging.getLogger(__name__) + def check_all_containers_disk_usage_once(page_size: int = 200) -> None: """遍历所有容器,向各 Node 拉取磁盘使用数据(只读、只记日志)。""" @@ -38,10 +41,8 @@ def check_all_containers_disk_usage_once(page_size: int = 200) -> None: _raw = parallel_node_calls(_callables, timeout_per_call=22.0) for c, r in zip(containers, _raw): if isinstance(r, Exception): - print( - f"[disk-check] failed for container id={getattr(c, 'id', '?')} " - f"name={getattr(c, 'name', '?')}: {r}" - ) + logger.warning("[disk-check] failed for container id=%s name=%s: %s", + getattr(c, 'id', '?'), getattr(c, 'name', '?'), r) elif isinstance(r, dict): _evaluate_limits(c, r) else: @@ -51,10 +52,8 @@ def check_all_containers_disk_usage_once(page_size: int = 200) -> None: if isinstance(usage, dict): _evaluate_limits(c, usage) except Exception as e: - print( - f"[disk-check] failed for container id={getattr(c, 'id', '?')} " - f"name={getattr(c, 'name', '?')}: {e}" - ) + logger.warning("[disk-check] failed for container id=%s name=%s: %s", + getattr(c, 'id', '?'), getattr(c, 'name', '?'), e) if len(containers) < page_size: break @@ -98,7 +97,7 @@ def _evaluate_limits(container, usage: dict) -> None: if disk_size_gb <= 0: # 无磁盘限额配置,跳过评估 - print(f"[disk-check] skip container_id={container.id}: machine disk_size_gb not set") + logger.info("[disk-check] skip container_id=%s: machine disk_size_gb not set", container.id) return limit_bytes = int(disk_size_gb * 1024**3) @@ -127,7 +126,7 @@ def _evaluate_limits(container, usage: dict) -> None: bind_mount_path=bind_mount_path, ) except Exception as e: - print(f"[disk-check] failed to persist disk usage for container {container.id}: {e}") + logger.warning("[disk-check] failed to persist disk usage for container %s: %s", container.id, e) log_msg = ( f"[disk-check] container_id={container.id} name={getattr(container, 'name', '?')} " @@ -142,10 +141,8 @@ def _evaluate_limits(container, usage: dict) -> None: # 方便在关闭响应的情况下从日志验证行为,无影响上线。 from ..repositories.long_term_container_repo import is_long_term if not is_long_term(container.id): - print( - f"[disk-check] container {container.id} " - f"({getattr(container, 'name', '?')}) is not long-term, skip response" - ) + logger.info("[disk-check] container %s (%s) is not long-term, skip response", + container.id, getattr(container, 'name', '?')) response_enabled = False # ── 重置检查(所有容器,不区分长期/短期)── @@ -153,31 +150,28 @@ def _evaluate_limits(container, usage: dict) -> None: reset_pct = _app.config.get("CONTAINER_DISK_FREEZE_RESET_PERCENT", 95) if usage_percent < reset_pct: if freeze_state_repo.reset(container.id): - print( - f"[disk-check] freeze state reset: container {container.id} " - f"({getattr(container, 'name', '?')}) " - f"usage {usage_percent:.1f}% < {reset_pct}%" - ) - print(f"[disk-check] OK: {log_msg}") + logger.info("[disk-check] freeze state reset: container %s (%s) usage %.1f%% < %s%%", + container.id, getattr(container, 'name', '?'), usage_percent, reset_pct) + logger.info("[disk-check] OK: %s", log_msg) return # 有冻结记录且容量回落,重置后不进入任何超限判断 # 无冻结记录 → 继续正常流程(仍可能触发 soft limit) if usage_percent >= hard_limit: - print(f"[disk-check] HARD LIMIT exceeded: {log_msg}") + logger.error("[disk-check] HARD LIMIT exceeded: %s", log_msg) if response_enabled: _handle_hard_limit_with_escalation(container, usage, _app) else: # 短期容器:不做动作,但检查是否有遗留冻结状态(来自曾是长期的时期) _log_freeze_state_if_exists(container) - print(f"[disk-check] response disabled, skip action for container {container.id}") + logger.info("[disk-check] response disabled, skip action for container %s", container.id) elif usage_percent >= soft_limit: - print(f"[disk-check] SOFT LIMIT exceeded: {log_msg}") + logger.warning("[disk-check] SOFT LIMIT exceeded: %s", log_msg) if response_enabled: _handle_soft_limit(container, usage, _app) else: - print(f"[disk-check] response disabled, skip action for container {container.id}") + logger.info("[disk-check] response disabled, skip action for container %s", container.id) else: - print(f"[disk-check] OK: {log_msg}") + logger.info("[disk-check] OK: %s", log_msg) def _fmt_bytes(b: int) -> str: @@ -209,7 +203,7 @@ def _handle_soft_limit(container, usage: dict, app) -> None: emails = [] if not emails: - print(f"[disk-check] soft limit: no owner email for container {container.id}") + logger.warning("[disk-check] soft limit: no owner email for container %s", container.id) return container_data = usage.get("container", {}) @@ -227,9 +221,9 @@ def _handle_soft_limit(container, usage: dict, app) -> None: for email in emails: try: send_mail(to=email, subject=subject, content=content) - print(f"[disk-check] soft limit email sent to {email} for container {container.id}") + logger.info("[disk-check] soft limit email sent to %s for container %s", email, container.id) except Exception as e: - print(f"[disk-check] soft limit email failed to {email}: {e}") + logger.warning("[disk-check] soft limit email failed to %s: %s", email, e) last_sent[last_key] = now_ts if app: app._disk_check_cache = last_sent @@ -269,9 +263,9 @@ def _handle_hard_limit(container, usage: dict, app) -> None: for e in emails: try: send_mail(to=e, subject=subject, content=content) - print(f"[disk-check] hard limit email sent to {e} for container {container.id}") + logger.info("[disk-check] hard limit email sent to %s for container %s", e, container.id) except Exception as ex: - print(f"[disk-check] hard limit email failed to {e}: {ex}") + logger.warning("[disk-check] hard limit email failed to %s: %s", e, ex) last_sent[last_key] = now_ts if app: app._disk_check_cache = last_sent @@ -281,7 +275,7 @@ def _handle_hard_limit(container, usage: dict, app) -> None: status = getattr(container, 'container_status', None) status_val = status.value if hasattr(status, 'value') else str(status) if str(status_val).lower() not in ('online',): - print(f"[disk-check] pause skipped for container {container.id}: status={status_val}") + logger.info("[disk-check] pause skipped for container %s: status=%s", container.id, status_val) return from ..repositories.machine_repo import get_machine_ip_by_id from ..constant import ContainerStatus @@ -291,7 +285,7 @@ def _handle_hard_limit(container, usage: dict, app) -> None: sig = container_tasks.signature(payload) enc = container_tasks.encryption(payload) res = container_tasks.send(enc, sig, url, timeout=10.0) - print(f"[disk-check] pause result for container {container.id}: {res}") + logger.debug("[disk-check] pause result for container %s: %s", container.id, res) # 更新 DB 状态为 paused,防止并行检查重复 pause if isinstance(res, dict) and res.get("success") == 1: containers_repo.update_container(container.id, commit=True, @@ -301,7 +295,7 @@ def _handle_hard_limit(container, usage: dict, app) -> None: target_id=container.id, detail={"reason": "disk_hard_limit", "usage": f"{total_gb:.1f}GB/{limit_gb:.1f}GB"}) except Exception as e: - print(f"[disk-check] pause failed for container {container.id}: {e}") + logger.error("[disk-check] pause failed for container %s: %s", container.id, e) def _handle_hard_limit_with_escalation(container, usage: dict, app) -> None: @@ -317,20 +311,15 @@ def _handle_hard_limit_with_escalation(container, usage: dict, app) -> None: # ── 宽限期检查 ── if freeze_state.grace_until and datetime.utcnow() < freeze_state.grace_until: - print( - f"[disk-check] in grace period until {freeze_state.grace_until}, " - f"skip action for container {container.id} " - f"({getattr(container, 'name', '?')})" - ) + logger.info("[disk-check] in grace period until %s, skip action for container %s (%s)", + freeze_state.grace_until, container.id, getattr(container, 'name', '?')) return # 宽限期已过期,清除 if freeze_state.grace_until: freeze_state_repo.clear_grace(container.id) - print( - f"[disk-check] grace period expired for container {container.id} " - f"({getattr(container, 'name', '?')})" - ) + logger.info("[disk-check] grace period expired for container %s (%s)", + container.id, getattr(container, 'name', '?')) # ── 升级判断 ── days_frozen = (datetime.utcnow() - freeze_state.first_frozen_at).days @@ -356,12 +345,8 @@ def _log_freeze_state_if_exists(container) -> None: grace_info = ", grace active" else: grace_info = ", grace expired" - print( - f"[disk-check] container {container.id} " - f"({getattr(container, 'name', '?')}) has legacy freeze state " - f"(frozen {days_frozen}d ago{grace_info}) " - f"but is not long-term, skip action" - ) + logger.warning("[disk-check] container %s (%s) has legacy freeze state (frozen %sd ago%s) but is not long-term, skip action", + container.id, getattr(container, 'name', '?'), days_frozen, grace_info) def _handle_freeze_escalation(container, usage: dict, app, days_frozen: int) -> None: @@ -399,9 +384,9 @@ def _handle_freeze_escalation(container, usage: dict, app, days_frozen: int) -> for e in emails: try: send_mail(to=e, subject=subject, content=content) - print(f"[disk-check] escalation email sent to {e} for container {container.id}") + logger.info("[disk-check] escalation email sent to %s for container %s", e, container.id) except Exception as ex: - print(f"[disk-check] escalation email failed to {e}: {ex}") + logger.warning("[disk-check] escalation email failed to %s: %s", e, ex) last_sent[last_key] = now_ts if app: app._disk_check_cache = last_sent @@ -409,10 +394,8 @@ def _handle_freeze_escalation(container, usage: dict, app, days_frozen: int) -> # ── 删除容器 ── try: container_tasks.remove_container(container.id) - print( - f"[disk-check] escalation: removed container {container.id} " - f"({getattr(container, 'name', '?')}) after {days_frozen}d frozen" - ) + logger.warning("[disk-check] escalation: removed container %s (%s) after %sd frozen", + container.id, getattr(container, 'name', '?'), days_frozen) from ..services.operation_log_tasks import write_operation_log as write_op_log write_op_log(success=True, operation=OperationType.REMOVE_CONTAINER, @@ -428,9 +411,7 @@ def _handle_freeze_escalation(container, usage: dict, app, days_frozen: int) -> # 升级删除:立刻清理 mount(宽限期已是最后机会) _clean_mount_immediately(container) except Exception as e: - print( - f"[disk-check] escalation remove failed for container {container.id}: {e}" - ) + logger.error("[disk-check] escalation remove failed for container %s: %s", container.id, e) def _clean_mount_immediately(container) -> None: @@ -441,10 +422,8 @@ def _clean_mount_immediately(container) -> None: """ bind_mount = getattr(container, 'bind_mount_path', None) if not bind_mount: - print( - f"[disk-check] escalation: no bind_mount_path for container " - f"{getattr(container, 'id', '?')}, skip mount cleanup" - ) + logger.info("[disk-check] escalation: no bind_mount_path for container %s, skip mount cleanup", + getattr(container, 'id', '?')) return try: @@ -461,7 +440,7 @@ def _clean_mount_immediately(container) -> None: cleaned_at=dt.utcnow(), ) except Exception as e: - print(f"[disk-check] escalation: failed to record mount cleanup for {container.id}: {e}") + logger.warning("[disk-check] escalation: failed to record mount cleanup for %s: %s", container.id, e) try: from ..repositories.machine_repo import get_machine_ip_by_id @@ -471,15 +450,11 @@ def _clean_mount_immediately(container) -> None: sig = container_tasks.signature(payload) enc = container_tasks.encryption(payload) res = container_tasks.send(enc, sig, url, timeout=10.0) - print( - f"[disk-check] escalation mount cleanup for container {container.id} " - f"path={bind_mount}: {res}" - ) + logger.debug("[disk-check] escalation mount cleanup for container %s path=%s: %s", + container.id, bind_mount, res) except Exception as e: - print( - f"[disk-check] escalation mount cleanup failed for container " - f"{container.id} path={bind_mount}: {e}" - ) + logger.error("[disk-check] escalation mount cleanup failed for container %s path=%s: %s", + container.id, bind_mount, e) def _get_limit_gb(container, app) -> float: @@ -523,7 +498,7 @@ def _worker(): with app.app_context(): check_all_containers_disk_usage_once() except Exception as e: - print(f"[disk-check] periodic run failed: {e}") + logger.error("[disk-check] periodic run failed: %s", e) t = threading.Thread(target=_worker, daemon=True, name="container-disk-check") t.start() diff --git a/schemas/container_mount_cleanup_task.py b/schemas/container_mount_cleanup_task.py index 6890cca..1a9da55 100644 --- a/schemas/container_mount_cleanup_task.py +++ b/schemas/container_mount_cleanup_task.py @@ -8,6 +8,7 @@ import json import threading import time +import logging from datetime import datetime, timedelta from flask import Flask, current_app @@ -19,6 +20,8 @@ signature, ) +logger = logging.getLogger(__name__) + def run_mount_cleanup_once() -> None: """扫描并清理到期 mount 目录(执行一次)。""" @@ -34,16 +37,13 @@ def run_mount_cleanup_once() -> None: if not rows: return - print(f"[mount-cleanup] found {len(rows)} pending mount(s) older than {after_days} days") + logger.info("[mount-cleanup] found %s pending mount(s) older than %s days", len(rows), after_days) for row in rows: try: machine_ip = machine_repo.get_machine_ip_by_id(row.machine_id) if not machine_ip: - print( - f"[mount-cleanup] skip row {row.id}: " - f"machine {row.machine_id} not found" - ) + logger.warning("[mount-cleanup] skip row %s: machine %s not found", row.id, row.machine_id) continue url = get_full_url(machine_ip, "/clean_mount") @@ -54,16 +54,12 @@ def run_mount_cleanup_once() -> None: if isinstance(res, dict) and res.get("success") == 1: container_mount_cleanup_repo.mark_cleaned(row.id) - print( - f"[mount-cleanup] cleaned row {row.id}: " - f"container={row.container_name} path={row.mount_path}" - ) + logger.info("[mount-cleanup] cleaned row %s: container=%s path=%s", + row.id, row.container_name, row.mount_path) else: - print( - f"[mount-cleanup] node rejected row {row.id}: {res}" - ) + logger.error("[mount-cleanup] node rejected row %s: %s", row.id, res) except Exception as e: - print(f"[mount-cleanup] failed row {row.id}: {e}") + logger.error("[mount-cleanup] failed row %s: %s", row.id, e) def start_mount_cleanup_scheduler( @@ -95,7 +91,7 @@ def _worker(): with app.app_context(): run_mount_cleanup_once() except Exception as e: - print(f"[mount-cleanup] periodic run failed: {e}") + logger.error("[mount-cleanup] periodic run failed: %s", e) t = threading.Thread(target=_worker, daemon=True, name="mount-cleanup") t.start() diff --git a/schemas/container_ssh_refresh_task.py b/schemas/container_ssh_refresh_task.py index fb9d403..fb587db 100644 --- a/schemas/container_ssh_refresh_task.py +++ b/schemas/container_ssh_refresh_task.py @@ -1,11 +1,14 @@ import threading import time +import logging from flask import Flask, current_app from ..repositories import containers_repo from ..services import container_tasks from ..utils.parallel import parallel_node_calls +logger = logging.getLogger(__name__) + def refresh_all_containers_last_ssh_login_time_once(page_size: int = 200) -> None: """遍历所有容器,向各节点拉取并落库上次 SSH 登录时间。""" @@ -35,19 +38,15 @@ def refresh_all_containers_last_ssh_login_time_once(page_size: int = 200) -> Non _raw = parallel_node_calls(_callables, timeout_per_call=8.0) for c, r in zip(containers, _raw): if isinstance(r, Exception): - print( - f"[ssh-refresh] failed for container id={getattr(c, 'id', '?')} " - f"name={getattr(c, 'name', '?')}: {r}" - ) + logger.warning("[ssh-refresh] failed for container id=%s name=%s: %s", + getattr(c, 'id', '?'), getattr(c, 'name', '?'), r) else: for c in containers: try: container_tasks.get_container_last_ssh_login_time(c.id) except Exception as e: - print( - f"[ssh-refresh] failed for container id={getattr(c, 'id', '?')} " - f"name={getattr(c, 'name', '?')}: {e}" - ) + logger.warning("[ssh-refresh] failed for container id=%s name=%s: %s", + getattr(c, 'id', '?'), getattr(c, 'name', '?'), e) if len(containers) < page_size: break @@ -94,7 +93,7 @@ def _worker(): # 磁盘检测独立线程并行,不阻塞 SSH 刷新 threading.Thread(target=_run_disk_check, args=(app,), daemon=True).start() except Exception as e: - print(f"[ssh-refresh] periodic run failed: {e}") + logger.error("[ssh-refresh] periodic run failed: %s", e) t = threading.Thread(target=_worker, daemon=True, name="container-ssh-refresh") t.start() @@ -110,4 +109,4 @@ def _run_disk_check(app): from .container_disk_check_task import check_all_containers_disk_usage_once check_all_containers_disk_usage_once() except Exception as e: - print(f"[ssh-refresh] disk check failed: {e}") + logger.error("[ssh-refresh] disk check failed: %s", e) diff --git a/services/container_module/__init__.py b/services/container_module/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/container_module/exceptions.py b/services/container_module/exceptions.py new file mode 100644 index 0000000..554e453 --- /dev/null +++ b/services/container_module/exceptions.py @@ -0,0 +1,27 @@ +#这个纯粹是为了方便统一异常处置流程 +def _raise_on_node_error(res: dict, action: str): + ''' + 检查远端(Node)提供的响应的具体内容 + + ''' + + if not isinstance(res, dict): + raise NodeServiceError(f"NODE {action} unexpected response: {res}", reason="unexpected_response") + # network-level error + if 'error' in res: + err = res.get('error') + err_reason = res.get('error_reason') + raise NodeServiceError(f"NODE {action} failed: {err}", reason=err_reason or "NODE_error") + # Node may include error_reason even without 'error' + if 'error_reason' in res and res.get('success') != 1: + raise NodeServiceError(f"NODE {action} failed: reason={res.get('error_reason')}", reason=res.get('error_reason')) + + +class NodeServiceError(Exception): + ''' + 提高一些Node侧错误上下文。只是为了z增加可读性 + ''' + + def __init__(self, message: str, reason: str | None = None): + super().__init__(message) + self.reason = reason \ No newline at end of file diff --git a/services/container_module/node_comms.py b/services/container_module/node_comms.py new file mode 100644 index 0000000..c1edb94 --- /dev/null +++ b/services/container_module/node_comms.py @@ -0,0 +1,115 @@ +import logging +import + + +#################################################### + +def get_full_url(machine_ip:str, endpoint:str)->str: + return f"http://{machine_ip}{CommsConfig.NODE_URL_MIDDLE}{endpoint}" + +#################################################### +#发送指令到集群实体机 + +def send(ciphertext:bytes,signature:bytes,mechine_ip:str, timeout:float=5.0)->dict: + """ + 发送 POST 并返回解析后的响应(优先 JSON),出现错误时返回包含 error 字段的 dict。 + """ + try: + resp = requests.post(mechine_ip, json={ + "message": base64.b64encode(ciphertext).decode('utf-8'), + "signature": base64.b64encode(signature).decode('utf-8') + }, timeout=timeout) + + # 尝试解析为 JSON(即使是 4xx/5xx,也优先解析 body 中的 JSON,以保留 Node 返回的 error_reason) + try: + j = resp.json() + if isinstance(j, dict): + j.setdefault('status_code', resp.status_code) + return j + except ValueError: + return {"status_code": resp.status_code, "text": resp.text} + + except requests.RequestException as e: + # 网络/超时/连接等错误 + logger.error("Request error: %s", e) + return {"error": str(e)} + + +def _ensure_machine_online_for_operation(machine_id: int, operation: str = ''): + """ + 这里检查机器在线状态的主要目的是为了在执行诸如创建/删除/修改容器等操作之前,先验证目标机器是否在线,以避免不必要的远程调用和更快地反馈给用户。虽然最终的远程调用也会有类似的检查,但这个预检查可以节省资源并提供更即时的错误响应。 + """ + try: + m = machine_repo.get_by_id(machine_id) + except Exception: + m = None + if not m: + raise NodeServiceError(f"MACHINE {operation} failed: machine {machine_id} not found", reason="machine_not_found") + try: + machine_status = m.machine_status.value.lower() if hasattr(m.machine_status, 'value') else str(m.machine_status).lower() + except Exception: + machine_status = str(getattr(m, 'machine_status', '')).lower() + if machine_status == 'maintenance': + raise NodeServiceError(f"MACHINE {operation} aborted: machine is maintenance", reason="machine_maintenance") + ok = is_machine_online_remote(machine_id) + if not ok: + raise NodeServiceError(f"MACHINE {operation} aborted: remote node not reachable or not online", reason="machine_offline") + + +#返回一页容器的概要信息 +def _node_probe_container(container, machine_ip: str, _app=None) -> dict | None: + """封装单次 NodeKernel /container_status 查询。 + + 等同于原 for 循环内 ``get_container_status(machine_ip, container.name)``, + 抽取为独立函数以适配 ``parallel_node_calls``。 + + *_app* 可选传入 Flask app 实例,用于线程池内推送 app context。 + """ + try: + if _app is not None: + with _app.app_context(): + return get_container_status(machine_ip, container.name) + return get_container_status(machine_ip, container.name) + except Exception: + return None + + +#### + +def get_container_status(machine_ip: str, container_name: str, timeout: float = 5.0) -> dict: + """ + 这个方法主要是为了在服务端调用 Node 的 /container_status API 来验证容器状态的。但是这个方法不被heartbeat使用。 + """ + url = get_full_url(machine_ip, "/container_status") + payload = json.dumps({"config": {"container_name": container_name}}) + sig = signature(payload) + enc = encryption(payload) + + last_exc = None + for attempt in range(2): + try: + res = send(enc, sig, url, timeout=timeout) + # send 不抛网络异常(以 {"error": ...} 返回),按原语义对网络级失败重试 + if isinstance(res, dict) and res.get('error') and res.get('status_code') != 404: + last_exc = res.get('error') + logger.warning("get_container_status request error (attempt %s): %s", attempt + 1, last_exc) + # short backoff before retrying + if attempt == 0: + time.sleep(0.5) + continue + # 保留原 404 语义(下游以 status_code == 404 判断容器不存在) + if isinstance(res, dict) and res.get('status_code') == 404: + res.setdefault('error', 'not found') + return res + except Exception as e: + last_exc = e + logger.warning("get_container_status request error (attempt %s): %s", attempt + 1, e) + # short backoff before retrying + if attempt == 0: + time.sleep(0.5) + continue + + # both attempts failed due to network/request errors + return {"error": str(last_exc) if last_exc is not None else "unknown error"} + + diff --git a/services/container_module/pydantic_models.py b/services/container_module/pydantic_models.py new file mode 100644 index 0000000..d4cf454 --- /dev/null +++ b/services/container_module/pydantic_models.py @@ -0,0 +1,70 @@ +# 容器展示态派生:宿主机不可达时覆盖为 host_offline(仅展示,DB 状态不动) + +#API Definition +#################################################### +class container_bref_information(BaseModel): + container_id: int # 加入这个 只是为了方便调取详细信息 + container_name:str + machine_id:int + machine_ip:str + port:int + container_status:str + display_status: str | None = None # 派生展示态(如 host_offline),DB 不落库 + accounts: list[dict] = Field(default_factory=list) + is_long_term: bool = False + long_term_container_can_enable: bool = True + long_term_container_blocked_user_ids: list[int] = Field(default_factory=list) + long_term_container_remaining_by_user: dict[int, int] = Field(default_factory=dict) + last_ssh_login_time: str | None = None + cleanup_after_days: int | None = None + cleanup_at: str | None = None + seconds_until_cleanup: int | None = None + cleanup_status: str | None = None + disk_total_gb: float | None = None + disk_limit_gb: float | None = None + disk_usage_percent: float | None = None + freeze_first_frozen_at: str | None = None + freeze_grace_until: str | None = None + freeze_days_frozen: int | None = None + freeze_escalation_days: int | None = None + +class container_detail_information(BaseModel): + container_id: int # 与上方结构对称 + container_name:str + container_image:str + machine_id:int + machine_ip:str + container_status:str + memory_gb:int + shared_gb:int + gpu_number:int + cpu_number:int + port:int + owners:list[str] + accounts:list[(str,ROLE)] + is_long_term: bool = False + long_term_container_can_enable: bool = True + long_term_container_blocked_user_ids: list[int] = Field(default_factory=list) + long_term_container_remaining_by_user: dict[int, int] = Field(default_factory=dict) + disk_usage: dict | None = None + freeze_state: dict | None = None +#################################################### +# 派生状态定义 + +DISPLAY_STATUS_HOST_OFFLINE = "host_offline" + +# 派生状态辅助函数 +def _derive_display_status(container_status, machine_id: int | None) -> str: + """由"容器 DB 状态 + 机器可达性"派生展示态。 + + 规则:failed 是终态诊断不覆盖;机器不可达则一律 host_offline。 + """ + status_str = container_status.value if hasattr(container_status, 'value') else str(container_status) + if str(status_str).lower() == ContainerStatus.FAILED.value: + return status_str + if machine_id is None: + return status_str + if not get_machine_reachable(machine_id): + return DISPLAY_STATUS_HOST_OFFLINE + return status_str + diff --git a/services/container_module/utils.py b/services/container_module/utils.py new file mode 100644 index 0000000..12341e3 --- /dev/null +++ b/services/container_module/utils.py @@ -0,0 +1,81 @@ +#################################################### +# 辅助工具 + +_MONTH_ABBR_TO_NUM = { + "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, + "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12, +} + + +def _parse_last_ssh_time(raw: str | None) -> datetime | None: + """ + 尝试把 Node 返回的 last ssh 时间解析为 datetime。 + 支持: + - ISO/常见 datetime 字符串 + - syslog 风格:`Mar 20 12:34:56 ...` + - `last` 输出中的日期片段:`Fri Mar 20 12:34 ...` + """ + if not raw: + return None + s = str(raw).strip() + if not s: + return None + + # 1) 直接尝试 fromisoformat / 通用格式 + try: + v = s.replace("Z", "+00:00") + return datetime.fromisoformat(v) + except Exception: + pass + + # 2) 提取 "Mon DD HH:MM[:SS]" 片段(无年份时使用当前年) + m = re.search(r"\b([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{2}:\d{2}(?::\d{2})?)\b", s) + if not m: + return None + mon = _MONTH_ABBR_TO_NUM.get(m.group(1)) + if not mon: + return None + day = int(m.group(2)) + hhmmss = m.group(3) + parts = hhmmss.split(":") + hour = int(parts[0]) + minute = int(parts[1]) + second = int(parts[2]) if len(parts) > 2 else 0 + now = datetime.utcnow() + try: + return datetime(now.year, mon, day, hour, minute, second) + except Exception: + return None + + +def build_cleanup_info(last_ssh_login_time: str | None, cleanup_after_days: int) -> dict: + """ + 基于上次 SSH 登录时间计算清理时间信息(仅计算,不执行清理)。 + """ + # logger.debug("DEBUG: build_cleanup_info called with last_ssh_login_time='%s' and cleanup_after_days=%s", last_ssh_login_time, cleanup_after_days) + if cleanup_after_days <= 0: + cleanup_after_days = 1 + + last_dt = _parse_last_ssh_time(last_ssh_login_time) + if last_dt is None: + return { + "cleanup_after_days": cleanup_after_days, + "cleanup_at": None, + "seconds_until_cleanup": None, + "cleanup_status": "unknown", + } + + cleanup_at = last_dt + timedelta(days=cleanup_after_days) + seconds_left = int((cleanup_at - datetime.utcnow()).total_seconds()) + if seconds_left <= 0: + status = "due" + seconds_left = 0 + else: + status = "countdown" + + return { + "cleanup_after_days": cleanup_after_days, + "cleanup_at": cleanup_at.isoformat(), + "seconds_until_cleanup": seconds_left, + "cleanup_status": status, + } diff --git a/services/container_tasks.py b/services/container_tasks.py index 99d79ca..bc040b2 100644 --- a/services/container_tasks.py +++ b/services/container_tasks.py @@ -2,6 +2,7 @@ import requests import time import base64 +import logging from datetime import datetime, timedelta import traceback from cryptography.hazmat.primitives import hashes @@ -31,251 +32,9 @@ ) from ..models.containers import Container import math -import re from ..utils import sanitizer as _sanitizer -#################################################### -# 辅助工具 - -_MONTH_ABBR_TO_NUM = { - "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, - "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12, -} - - -def _parse_last_ssh_time(raw: str | None) -> datetime | None: - """ - 尝试把 Node 返回的 last ssh 时间解析为 datetime。 - 支持: - - ISO/常见 datetime 字符串 - - syslog 风格:`Mar 20 12:34:56 ...` - - `last` 输出中的日期片段:`Fri Mar 20 12:34 ...` - """ - if not raw: - return None - s = str(raw).strip() - if not s: - return None - - # 1) 直接尝试 fromisoformat / 通用格式 - try: - v = s.replace("Z", "+00:00") - return datetime.fromisoformat(v) - except Exception: - pass - - # 2) 提取 "Mon DD HH:MM[:SS]" 片段(无年份时使用当前年) - m = re.search(r"\b([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{2}:\d{2}(?::\d{2})?)\b", s) - if not m: - return None - mon = _MONTH_ABBR_TO_NUM.get(m.group(1)) - if not mon: - return None - day = int(m.group(2)) - hhmmss = m.group(3) - parts = hhmmss.split(":") - hour = int(parts[0]) - minute = int(parts[1]) - second = int(parts[2]) if len(parts) > 2 else 0 - now = datetime.utcnow() - try: - return datetime(now.year, mon, day, hour, minute, second) - except Exception: - return None - - -def build_cleanup_info(last_ssh_login_time: str | None, cleanup_after_days: int) -> dict: - """ - 基于上次 SSH 登录时间计算清理时间信息(仅计算,不执行清理)。 - """ - #print(f"DEBUG: build_cleanup_info called with last_ssh_login_time='{last_ssh_login_time}' and cleanup_after_days={cleanup_after_days}") - if cleanup_after_days <= 0: - cleanup_after_days = 1 - - last_dt = _parse_last_ssh_time(last_ssh_login_time) - if last_dt is None: - return { - "cleanup_after_days": cleanup_after_days, - "cleanup_at": None, - "seconds_until_cleanup": None, - "cleanup_status": "unknown", - } - - cleanup_at = last_dt + timedelta(days=cleanup_after_days) - seconds_left = int((cleanup_at - datetime.utcnow()).total_seconds()) - if seconds_left <= 0: - status = "due" - seconds_left = 0 - else: - status = "countdown" - - return { - "cleanup_after_days": cleanup_after_days, - "cleanup_at": cleanup_at.isoformat(), - "seconds_until_cleanup": seconds_left, - "cleanup_status": status, - } - -def _is_operator_user(user_id: int) -> bool: - try: - u = user_repo.get_by_id(user_id) - #print(f"DEBUG: checking if user {user_id} is operator: permission={getattr(u, 'permission', None)}") - perm = getattr(u, 'permission', None) if u else None - return bool(perm and getattr(perm, 'value', str(perm)).lower() == 'operator') - except Exception: - return False - - -def _can_access_machine(user_id: int, machine_id: int) -> bool: - if not user_id or not machine_id: - return False - if _is_operator_user(user_id): - return True - try: - allowed = set(machine_permission_repo.list_machine_ids_by_user(user_id)) - return machine_id in allowed - except Exception: - return False - -def _ensure_machine_online_for_operation(machine_id: int, operation: str = ''): - """ - 这里检查机器在线状态的主要目的是为了在执行诸如创建/删除/修改容器等操作之前,先验证目标机器是否在线,以避免不必要的远程调用和更快地反馈给用户。虽然最终的远程调用也会有类似的检查,但这个预检查可以节省资源并提供更即时的错误响应。 - """ - try: - m = machine_repo.get_by_id(machine_id) - except Exception: - m = None - if not m: - raise NodeServiceError(f"MACHINE {operation} failed: machine {machine_id} not found", reason="machine_not_found") - try: - machine_status = m.machine_status.value.lower() if hasattr(m.machine_status, 'value') else str(m.machine_status).lower() - except Exception: - machine_status = str(getattr(m, 'machine_status', '')).lower() - if machine_status == 'maintenance': - raise NodeServiceError(f"MACHINE {operation} aborted: machine is maintenance", reason="machine_maintenance") - ok = is_machine_online_remote(machine_id) - if not ok: - raise NodeServiceError(f"MACHINE {operation} aborted: remote node not reachable or not online", reason="machine_offline") - - -#################################################### -#发送指令到集群实体机 - - -def send(ciphertext:bytes,signature:bytes,mechine_ip:str, timeout:float=5.0)->dict: - """ - 发送 POST 并返回解析后的响应(优先 JSON),出现错误时返回包含 error 字段的 dict。 - """ - try: - resp = requests.post(mechine_ip, json={ - "message": base64.b64encode(ciphertext).decode('utf-8'), - "signature": base64.b64encode(signature).decode('utf-8') - }, timeout=timeout) - - # 尝试解析为 JSON(即使是 4xx/5xx,也优先解析 body 中的 JSON,以保留 Node 返回的 error_reason) - try: - j = resp.json() - if isinstance(j, dict): - j.setdefault('status_code', resp.status_code) - return j - except ValueError: - return {"status_code": resp.status_code, "text": resp.text} - - except requests.RequestException as e: - # 网络/超时/连接等错误 - print(f"Request error: {e}") - return {"error": str(e)} - -#这个纯粹是为了方便统一异常处置流程 -def _raise_on_node_error(res: dict, action: str): - ''' - 检查远端(Node)提供的响应的具体内容 - - ''' - - if not isinstance(res, dict): - raise NodeServiceError(f"NODE {action} unexpected response: {res}", reason="unexpected_response") - # network-level error - if 'error' in res: - err = res.get('error') - err_reason = res.get('error_reason') - raise NodeServiceError(f"NODE {action} failed: {err}", reason=err_reason or "NODE_error") - # Node may include error_reason even without 'error' - if 'error_reason' in res and res.get('success') != 1: - raise NodeServiceError(f"NODE {action} failed: reason={res.get('error_reason')}", reason=res.get('error_reason')) - - -class NodeServiceError(Exception): - ''' - 提高一些Node侧错误上下文。只是为了z增加可读性 - ''' - - def __init__(self, message: str, reason: str | None = None): - super().__init__(message) - self.reason = reason - -def get_full_url(machine_ip:str, endpoint:str)->str: - return f"http://{machine_ip}{CommsConfig.NODE_URL_MIDDLE}{endpoint}" - - -def get_container_status(machine_ip: str, container_name: str, timeout: float = 5.0) -> dict: - """ - 这个方法主要是为了在服务端调用 Node 的 /container_status API 来验证容器状态的。但是这个方法不被heartbeat使用。 - """ - url = get_full_url(machine_ip, "/container_status") - payload = json.dumps({"config": {"container_name": container_name}}) - sig = signature(payload) - enc = encryption(payload) - - last_exc = None - for attempt in range(2): - try: - resp = requests.post(url, json={ - "message": base64.b64encode(enc).decode('utf-8'), - "signature": base64.b64encode(sig).decode('utf-8') - }, timeout=timeout) - # Do not raise_for_status() here; inspect status code - try: - if resp.status_code == 200: - try: - return resp.json() - except ValueError: - return {"status_code": resp.status_code, "text": resp.text} - elif resp.status_code == 404: - return {"status_code": 404, "error": "not found", "text": resp.text} - else: - return {"status_code": resp.status_code, "text": resp.text} - except Exception as e: - return {"error": str(e)} - except requests.RequestException as e: - last_exc = e - print(f"get_container_status request error (attempt {attempt+1}): {e}") - # short backoff before retrying - if attempt == 0: - time.sleep(0.5) - continue - - # both attempts failed due to network/request errors - return {"error": str(last_exc) if last_exc is not None else "unknown error"} - - -# 容器展示态派生:宿主机不可达时覆盖为 host_offline(仅展示,DB 状态不动) -DISPLAY_STATUS_HOST_OFFLINE = "host_offline" - - -def _derive_display_status(container_status, machine_id: int | None) -> str: - """由"容器 DB 状态 + 机器可达性"派生展示态。 - - 规则:failed 是终态诊断不覆盖;机器不可达则一律 host_offline。 - """ - status_str = container_status.value if hasattr(container_status, 'value') else str(container_status) - if str(status_str).lower() == ContainerStatus.FAILED.value: - return status_str - if machine_id is None: - return status_str - if not get_machine_reachable(machine_id): - return DISPLAY_STATUS_HOST_OFFLINE - return status_str +logger = logging.getLogger(__name__) def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) -> str | None: @@ -287,13 +46,13 @@ def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) - try: container_id = int(container_id) except Exception: - print(f"Invalid container id for SSH login time query: {container_id}") + logger.warning("Invalid container id for SSH login time query: %s", container_id) return None try: container = containers_repo.get_by_id(container_id) except Exception: - print(f"Error querying container info for id={container_id}: {traceback.format_exc()}") + logger.error("Error querying container info for id=%s: %s", container_id, traceback.format_exc()) return None if not container: @@ -303,7 +62,7 @@ def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) - machine_ip = get_machine_ip_by_id(machine_id) url = get_full_url(machine_ip, "/container_last_ssh_time") except Exception: - print(f"Error retrieving machine info for container id={container_id}: {traceback.format_exc()}") + logger.error("Error retrieving machine info for container id=%s: %s", container_id, traceback.format_exc()) return None container_name = getattr(container, 'name', None) @@ -313,12 +72,12 @@ def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) - enc = encryption(payload) res = send(enc, sig, url, timeout=timeout) except Exception as e: - print(f"Error sending request to {url}: {e}") + logger.error("Error sending request to %s: %s", url, e) # Node 不可达时,以 DB 已有记录兜底 record = container_ssh_login_repo.get_by_machine_container(machine_id, container.id) return record.last_ssh_login_time if record else None - #print(f"DEBUG: get_container_last_ssh_login_time: sent request to {url} with payload {payload}") - #print(f"get_container_last_ssh_login_time: NODE response: {res}") + logger.debug("DEBUG: get_container_last_ssh_login_time: sent request to %s with payload %s", url, payload) + logger.debug("get_container_last_ssh_login_time: NODE response: %s", res) if not isinstance(res, dict): raise NodeServiceError( @@ -367,7 +126,7 @@ def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) - last_ssh_login_time=last_time, ) except Exception as e: - print(f"Warning: failed to persist last ssh login time for container {container.id}: {e}") + logger.warning("failed to persist last ssh login time for container %s: %s", container.id, e) # Node 返回空值时,以 DB 已有记录兜底 if last_time is None: @@ -401,7 +160,7 @@ def unpause_container(container_id: int, operator_user_id: int | None = None) -> enc = encryption(payload) res = send(enc, sig, url, timeout=10.0) except Exception as e: - print(f"unpause_container send error: {e}") + logger.error("unpause_container send error: %s", e) write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, target_type="container", target_id=container.id, detail={"name": container.name, "machine_id": machine_id}, @@ -413,8 +172,8 @@ def unpause_container(container_id: int, operator_user_id: int | None = None) -> # 更新本地状态为 online try: update_container(container.id, container_status=ContainerStatus.ONLINE) - except Exception: - pass + except Exception as e: + logger.warning("unpause: failed to update container %s status to ONLINE: %s", container.id, e) write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, target_type="container", target_id=container.id, detail={"name": container.name, "machine_id": machine_id}) @@ -427,13 +186,12 @@ def unpause_container(container_id: int, operator_user_id: int | None = None) -> from flask import current_app grace_days = current_app.config.get("CONTAINER_DISK_FREEZE_GRACE_DAYS", 3) container_disk_freeze_state_repo.set_grace(container_id, grace_days) - print( - f"[disk-check] grace period set for container {container_id} " - f"({getattr(container, 'name', '?')}) " - f"({grace_days} days, until {freeze_state.grace_until})" + logger.info( + "[disk-check] grace period set for container %s (%s) (%s days, until %s)", + container_id, getattr(container, 'name', '?'), grace_days, freeze_state.grace_until, ) except Exception as e: - print(f"[disk-check] failed to set grace for container {container_id}: {e}") + logger.warning("[disk-check] failed to set grace for container %s: %s", container_id, e) return True return False @@ -448,13 +206,13 @@ def get_container_disk_usage(container_id: int, timeout: float = 20.0) -> dict | try: container_id = int(container_id) except Exception: - print(f"Invalid container id for disk usage query: {container_id}") + logger.warning("Invalid container id for disk usage query: %s", container_id) return None try: container = containers_repo.get_by_id(container_id) except Exception: - print(f"Error querying container info for id={container_id}: {traceback.format_exc()}") + logger.error("Error querying container info for id=%s: %s", container_id, traceback.format_exc()) return None if not container: @@ -464,7 +222,7 @@ def get_container_disk_usage(container_id: int, timeout: float = 20.0) -> dict | machine_ip = get_machine_ip_by_id(machine_id) url = get_full_url(machine_ip, "/check_disk_usage") except Exception: - print(f"Error retrieving machine info for container id={container_id}: {traceback.format_exc()}") + logger.error("Error retrieving machine info for container id=%s: %s", container_id, traceback.format_exc()) return None container_name = getattr(container, 'name', None) @@ -474,78 +232,27 @@ def get_container_disk_usage(container_id: int, timeout: float = 20.0) -> dict | enc = encryption(payload) res = send(enc, sig, url, timeout=timeout) except Exception as e: - print(f"Error sending disk check request to {url}: {e}") + logger.error("Error sending disk check request to %s: %s", url, e) return None if not isinstance(res, dict): - print(f"get_container_disk_usage: unexpected response type: {type(res)}") + logger.error("get_container_disk_usage: unexpected response type: %s", type(res)) return None _raise_on_node_error(res, 'check_disk') if res.get('success') != 1: - print(f"get_container_disk_usage: Node returned failure: {res}") + logger.error("get_container_disk_usage: Node returned failure: %s", res) return None cd = res.get("container", {}) _errs = {k: cd[k] for k in ("overlay_rw_error", "bind_mount_error", "bind_mount_path") if k in cd} - print(f"[disk-check] ctrl received: container={container_name} " - f"overlay={cd.get('overlay_rw_bytes')}B bind={cd.get('bind_mount_bytes')}B " - f"total={cd.get('total_bytes')}B errs={_errs}") + logger.info("[disk-check] ctrl received: container=%s overlay=%sB bind=%sB total=%sB errs=%s", + container_name, cd.get('overlay_rw_bytes'), cd.get('bind_mount_bytes'), cd.get('total_bytes'), _errs) return res #################################################### -#API Definition -#################################################### -class container_bref_information(BaseModel): - container_id: int # 加入这个 只是为了方便调取详细信息 - container_name:str - machine_id:int - machine_ip:str - port:int - container_status:str - display_status: str | None = None # 派生展示态(如 host_offline),DB 不落库 - accounts: list[dict] = Field(default_factory=list) - is_long_term: bool = False - long_term_container_can_enable: bool = True - long_term_container_blocked_user_ids: list[int] = Field(default_factory=list) - long_term_container_remaining_by_user: dict[int, int] = Field(default_factory=dict) - last_ssh_login_time: str | None = None - cleanup_after_days: int | None = None - cleanup_at: str | None = None - seconds_until_cleanup: int | None = None - cleanup_status: str | None = None - disk_total_gb: float | None = None - disk_limit_gb: float | None = None - disk_usage_percent: float | None = None - freeze_first_frozen_at: str | None = None - freeze_grace_until: str | None = None - freeze_days_frozen: int | None = None - freeze_escalation_days: int | None = None - -class container_detail_information(BaseModel): - container_id: int # 与上方结构对称 - container_name:str - container_image:str - machine_id:int - machine_ip:str - container_status:str - memory_gb:int - shared_gb:int - gpu_number:int - cpu_number:int - port:int - owners:list[str] - accounts:list[(str,ROLE)] - is_long_term: bool = False - long_term_container_can_enable: bool = True - long_term_container_blocked_user_ids: list[int] = Field(default_factory=list) - long_term_container_remaining_by_user: dict[int, int] = Field(default_factory=dict) - disk_usage: dict | None = None - freeze_state: dict | None = None -#################################################### - #Function Implementation @@ -553,7 +260,7 @@ class container_detail_information(BaseModel): # 将user_id作为admin,创建新容器 -def Create_container(owner_name:str,machine_id:int,container:Container_info,public_key=None, debug=False, operator_user_id:int|None=None)->bool: +def Create_container(owner_name:str,machine_id:int,container:Container_info,public_key=None, operator_user_id:int|None=None)->bool: if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): raise NodeServiceError(f'Machine {machine_id} not accessible for user {operator_user_id}', reason='machine_permission_denied') # ensure machine is online before attempting creation @@ -561,33 +268,13 @@ def Create_container(owner_name:str,machine_id:int,container:Container_info,publ machine_ip=get_machine_ip_by_id(machine_id) full_url = get_full_url(machine_ip, "/create_container") - - free_port = get_the_first_free_port(machine_id=machine_id) container.set_port(free_port) - ### 参数检查 (delegated to repositories.container_repo helpers) ### try: - # 存在性检查 - print(f"DEBUG: ensuring machine {machine_id} exists for container {container.NAME}") - machine = container_repo.ensure_machine_exists(machine_id) - # GPU 参数检查 - print(f"DEBUG: validating GPU request for machine {machine_id} and container {container.NAME}") - container_repo.validate_gpu_request(machine, container) - # memory 参数检查(必须先于 shared) - print(f"DEBUG: validating memory request for machine {machine_id} and container {container.NAME}") - requested_memory = container_repo.validate_memory_request(machine, container) - # shared 参数检查(要求 shared <= memory) - print(f"DEBUG: validating shared request for machine {machine_id} and container {container.NAME}") - requested_shared = container_repo.validate_shared_request(machine, container, requested_memory) - # cpu 参数检查 - print(f"DEBUG: validating CPU request for machine {machine_id} and container {container.NAME}") - requested_cpus = container_repo.validate_cpu_request(machine, container) - # name/image/public_key length and format checks - container_repo.validate_names_and_lengths(container, public_key) - # duplicate name check (may raise IntegrityError) - container_repo.check_duplicate_container_name(container_name=container.NAME, machine_id=machine_id) + logger.debug("DEBUG: validating create params for container %s on machine %s", container.NAME, machine_id) + container_repo.validate_create_params(machine_id, container, public_key) except IntegrityError: # let DB integrity errors bubble up as-is so callers (blueprints) can handle duplicate entries raise @@ -610,16 +297,7 @@ def Create_container(owner_name:str,machine_id:int,container:Container_info,publ if public_key: container_info['public_key']=public_key container_info=json.dumps(container_info) - # 防御性检查:限制字段长度,防止过长输入导致数据库异常或远程调用异常 - if container.NAME and len(container.NAME) > 115: - raise ValueError(f"container name too long (max 115): length={len(container.NAME)}") - if container.image and len(container.image) > 195: - raise ValueError(f"container image name too long (max 195): length={len(container.image)}") - if public_key and len(public_key) > 495: - raise ValueError(f"public_key too long (max 495): length={len(public_key)}") - # 只允许字母数字下划线 - if not re.fullmatch(r'[A-Za-z0-9_]+', container.NAME): - raise ValueError(f"invalid container name: '{container.NAME}'. Allowed characters: A-Z a-z 0-9 _") + # 名称/长度/格式等校验已在参数检查阶段由 container_repo.validate_create_params 完成 # check duplicate container name on this machine before sending to Node try: @@ -633,46 +311,27 @@ def Create_container(owner_name:str,machine_id:int,container:Container_info,publ raise except Exception as e: # If the check fails unexpectedly, log and continue to avoid blocking creation due to DB issues - print(f"Warning: failed to check existing container name: {e}") + logger.warning("failed to check existing container name: %s", e) signatured_message=signature(container_info) - + encryptioned_message=encryption(container_info) res=send(encryptioned_message,signatured_message,full_url) - print(f"Create_container: NODE response: {res}") + logger.debug("Create_container: NODE response: %s", res) # 检查Node是否返回错误,如果有则抛出异常;如果没有则继续后续流程(写DB记录、建立绑定、启动心跳等) _raise_on_node_error(res, 'create') if res.get('success') != 1: # unexpected response from Node; abort to avoid DB inconsistency raise NodeServiceError(f"NODE create returned failure or unexpected response: {res}", reason=res.get('error_reason') or "unexpected_response") - if debug: - ####### - # DEBUG PURPOSE - Key=False - original_dict = json.loads(container_info) # 把原始 JSON 字符串解析成 dict - server_decrypted_dict = res.get('decrypted_message') # 直接取解密后的 dict - if server_decrypted_dict == original_dict: - print("验证成功:服务端返回的解密内容与原始明文一致") - # (可选)如果验证通过,再执行实际的容器创建逻辑 - # 这里放原有的容器创建、数据库写入等代码 - Key=True - else: - raise Exception("验证失败:解密内容不一致: \n原始:"+ str(original_dict) - + "\n回应:" + str(res)) - # DEBUG PURPOSE - ####### - else: - Key=True - gpu_list = getattr(container, 'GPU_LIST', None) gpu_count = len(gpu_list) if gpu_list else 0 - # 写入容器记录 + # 写入容器记录 create_container(name=container.NAME, image=container.image, machine_id=machine_id, memory_gb=container.MEMORY, - shared_gb=requested_shared, + shared_gb=int(getattr(container, 'SHARED_MEMORY', getattr(container, 'shared_memory', 0)) or 0), gpu_number=gpu_count, cpu_number=container.CPU_NUMBER, port=free_port, @@ -699,31 +358,30 @@ def Create_container(owner_name:str,machine_id:int,container:Container_info,publ try: container_starting_status_heartbeat(machine_ip, container.NAME, container_id=container_id, timeout=180, interval=3) - except Exception: - print(f"Warning: Heartbeat for container {container_id} failed to start or encountered an error. Container may be stuck in CREATING status.") - return False + except Exception as e: + # 容器已创建成功(Node/DB/绑定均已落),心跳只是状态推进器; + # 失败不应让调用方误判创建失败(重试会撞重名),状态由后续查询的实时探测纠正。 + logger.warning("Heartbeat for container %s failed to start or encountered an error: %s. Container may be stuck in CREATING status.", container_id, e) - if Key: - write_op_log(success=True, - operator_user_id=operator_user_id, - operation=OperationType.CREATE_CONTAINER, - target_type="container", - target_id=container_id, - detail={ - "name": container.NAME, - "machine_id": machine_id, - "image": container.image, - "port": free_port, - "memory_gb": container.MEMORY, - "cpu_number": container.CPU_NUMBER, - "gpu_number": gpu_count, - }, - ) - return True - return False + write_op_log(success=True, + operator_user_id=operator_user_id, + operation=OperationType.CREATE_CONTAINER, + target_type="container", + target_id=container_id, + detail={ + "name": container.NAME, + "machine_id": machine_id, + "image": container.image, + "port": free_port, + "memory_gb": container.MEMORY, + "cpu_number": container.CPU_NUMBER, + "gpu_number": gpu_count, + }, + ) + return True #删除容器并删除其所有者记录 -def remove_container(container_id:int, debug=False, operator_user_id:int|None=None)->bool: +def remove_container(container_id:int, operator_user_id:int|None=None)->bool: machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): raise NodeServiceError(f'Machine {machine_id} not accessible for user {operator_user_id}', reason='machine_permission_denied') @@ -745,7 +403,7 @@ def remove_container(container_id:int, debug=False, operator_user_id:int|None=No signatured_message=signature(container_info) encryptioned_message=encryption(container_info) res=send(encryptioned_message,signatured_message,full_url) - print(f"remove_container: NODE response: {res}") + logger.debug("remove_container: NODE response: %s", res) # 先看看远程调用层面是否有错误(网络/请求/远程处理错误等),如果有则抛出异常;如果没有则根据 Node 的返回内容来决定是否继续本地删除(Node 返回 NOTFOUND 则本地也删除,Node 返回 FAILED 则不删除并抛出异常) _raise_on_node_error(res, 'remove') # Node remove_container currently returns numeric code in 'success': 0=SUCCESS,1=NOTFOUND,2=FAILED @@ -757,27 +415,9 @@ def remove_container(container_id:int, debug=False, operator_user_id:int|None=No raise NodeServiceError(f"NODE remove reported failure: {res}", reason=res.get('error_reason') or 'remove_failed') # treat 0 (SUCCESS) and 1 (NOTFOUND) as acceptable success for local cleanup - if debug: - ####### - # DEBUG PURPOSE - Key=False - original_dict = json.loads(container_info) # 把原始 JSON 字符串解析成 dict - server_decrypted_dict = res.get('decrypted_message') # 直接取解密后的 dict - if server_decrypted_dict == original_dict: - print("验证成功:服务端返回的解密内容与原始明文一致") - # (可选)如果验证通过,再执行实际的容器创建逻辑 - # 这里放原有的容器创建、数据库写入等代码 - Key=True - else: - raise Exception("验证失败:解密内容不一致: \n原始:"+ str(original_dict) - + "\n回应:" + str(res)) - # DEBUG PURPOSE - ####### - else: - if 'error' in res: - print(f"远程调用失败: {res['error']}") - raise Exception(f"远程调用失败: {res['error']}") - Key=True + if 'error' in res: + logger.error("远程调用失败: %s", res['error']) + raise Exception(f"远程调用失败: {res['error']}") # 记录操作日志(删前写,保留容器名称等信息) try: @@ -818,13 +458,11 @@ def remove_container(container_id:int, debug=False, operator_user_id:int|None=No escalation=False, removed_at=dt.utcnow(), ) - print(f"remove_container: mount cleanup recorded for container {container_id} path={_bind_mount}") + logger.info("remove_container: mount cleanup recorded for container %s path=%s", container_id, _bind_mount) except Exception as e: - print(f"remove_container: failed to record mount cleanup for {container_id}: {e}") + logger.warning("remove_container: failed to record mount cleanup for %s: %s", container_id, e) - if Key: - return True - return False + return True def build_container_restore_snapshot(container_id: int, cleanup_context: dict | None = None) -> dict: @@ -991,7 +629,7 @@ def set_long_term_container(container_id: int, is_long_term: bool, operator_user } #将container_id对应的容器新增user_id作为collaborator,其权限为role -def add_collaborator(container_id:int,user_id:int,role:ROLE, debug=False, operator_user_id:int|None=None)->bool: +def add_collaborator(container_id:int,user_id:int,role:ROLE, operator_user_id:int|None=None)->bool: machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): raise NodeServiceError(f'Machine {machine_id} not accessible for user {operator_user_id}', reason='machine_permission_denied') @@ -1033,27 +671,9 @@ def add_collaborator(container_id:int,user_id:int,role:ROLE, debug=False, operat encryptioned_message=encryption(container_info) res=send(encryptioned_message,signatured_message,full_url) - if debug: - ####### - # DEBUG PURPOSE - Key=False - original_dict = json.loads(container_info) # 把原始 JSON 字符串解析成 dict - server_decrypted_dict = res.get('decrypted_message') # 直接取解密后的 dict - if server_decrypted_dict == original_dict: - print("验证成功:服务端返回的解密内容与原始明文一致") - # (可选)如果验证通过,再执行实际的容器创建逻辑 - # 这里放原有的容器创建、数据库写入等代码 - Key=True - else: - raise Exception("验证失败:解密内容不一致: \n原始:"+ str(original_dict) - + "\n回应:" + str(res)) - # DEBUG PURPOSE - ####### - else: - _raise_on_node_error(res, 'add_collaborator') - if res.get('success') not in (1, True): - raise NodeServiceError(f"NODE add_collaborator returned failure: {res}", reason=res.get('error_reason') or 'add_failed') - Key=True + _raise_on_node_error(res, 'add_collaborator') + if res.get('success') not in (1, True): + raise NodeServiceError(f"NODE add_collaborator returned failure: {res}", reason=res.get('error_reason') or 'add_failed') # 直接通过绑定表建立关联 add_binding(user_id=user_id, container_id=container_id, @@ -1061,22 +681,15 @@ def add_collaborator(container_id:int,user_id:int,role:ROLE, debug=False, operat public_key=None, role=role) - if Key: - write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.ADD_COLLABORATOR, - target_type="container", target_id=container_id, - detail={"user_id": user_id, "username": user_name, - "role": role.value if hasattr(role, 'value') else str(role), - "container_name": container_name}) - return True - write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.ADD_COLLABORATOR, + write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.ADD_COLLABORATOR, target_type="container", target_id=container_id, detail={"user_id": user_id, "username": user_name, - "container_name": container_name}, - error_reason="add_collaborator_failed") - return False + "role": role.value if hasattr(role, 'value') else str(role), + "container_name": container_name}) + return True #从container_id中移除user_id对应的用户访问权 -def remove_collaborator(container_id:int,user_id:int,debug=False, operator_user_id:int|None=None)->bool: +def remove_collaborator(container_id:int,user_id:int,operator_user_id:int|None=None)->bool: machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): raise NodeServiceError(f'Machine {machine_id} not accessible for user {operator_user_id}', reason='machine_permission_denied') @@ -1123,46 +736,21 @@ def remove_collaborator(container_id:int,user_id:int,debug=False, operator_user_ encryptioned_message=encryption(container_info) res=send(encryptioned_message,signatured_message,full_url) - if debug: - ####### - # DEBUG PURPOSE - Key=False - original_dict = json.loads(container_info) # 把原始 JSON 字符串解析成 dict - server_decrypted_dict = res.get('decrypted_message') # 直接取解密后的 dict - if server_decrypted_dict == original_dict: - print("验证成功:服务端返回的解密内容与原始明文一致") - # (可选)如果验证通过,再执行实际的容器创建逻辑 - # 这里放原有的容器创建、数据库写入等代码 - Key=True - else: - raise Exception("验证失败:解密内容不一致: \n原始:"+ str(original_dict) - + "\n回应:" + str(res)) - # DEBUG PURPOSE - ####### - else: - _raise_on_node_error(res, 'remove_collaborator') - if res.get('success') not in (1, True): - raise NodeServiceError(f"NODE remove_collaborator returned failure: {res}", reason=res.get('error_reason') or 'remove_failed') - Key=True + _raise_on_node_error(res, 'remove_collaborator') + if res.get('success') not in (1, True): + raise NodeServiceError(f"NODE remove_collaborator returned failure: {res}", reason=res.get('error_reason') or 'remove_failed') # 仅删除绑定 remove_binding(user_id,container_id) - if Key: - write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.REMOVE_COLLABORATOR, - target_type="container", target_id=container_id, - detail={"user_id": user_id, "username": user_name, - "container_name": container_name}) - return True - write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.REMOVE_COLLABORATOR, + write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.REMOVE_COLLABORATOR, target_type="container", target_id=container_id, detail={"user_id": user_id, "username": user_name, - "container_name": container_name}, - error_reason="remove_collaborator_failed") - return False + "container_name": container_name}) + return True #修改user_id对container_id的访问权 -def update_role(container_id:int,user_id:int,updated_role:ROLE,debug=False, operator_user_id:int|None=None)->bool: +def update_role(container_id:int,user_id:int,updated_role:ROLE,operator_user_id:int|None=None)->bool: machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): raise NodeServiceError(f'Machine {machine_id} not accessible for user {operator_user_id}', reason='machine_permission_denied') @@ -1201,27 +789,9 @@ def update_role(container_id:int,user_id:int,updated_role:ROLE,debug=False, oper # 使用 machine_ip 发送 res=send(encryptioned_message,signatured_message,full_url) - if debug: - ####### - # DEBUG PURPOSE - Key=False - original_dict = json.loads(container_info) # 把原始 JSON 字符串解析成 dict - server_decrypted_dict = res.get('decrypted_message') # 直接取解密后的 dict - if server_decrypted_dict == original_dict: - print("验证成功:服务端返回的解密内容与原始明文一致") - # (可选)如果验证通过,再执行实际的容器创建逻辑 - # 这里放原有的容器创建、数据库写入等代码 - Key=True - else: - raise Exception("验证失败:解密内容不一致: \n原始:"+ str(original_dict) - + "\n回应:" + str(res)) - # DEBUG PURPOSE - ####### - else: - _raise_on_node_error(res, 'update_role') - if res.get('success') not in (1, True): - raise NodeServiceError(f"NODE update_role returned failure: {res}", reason=res.get('error_reason') or 'update_failed') - Key=True + _raise_on_node_error(res, 'update_role') + if res.get('success') not in (1, True): + raise NodeServiceError(f"NODE update_role returned failure: {res}", reason=res.get('error_reason') or 'update_failed') if updated_role == ROLE.ROOT: # 强制使用 root 作为用户名 username = 'root' @@ -1238,25 +808,16 @@ def update_role(container_id:int,user_id:int,updated_role:ROLE,debug=False, oper # 更新绑定时同时传入 username 和 role,确保数据库中的 username 在变更为 ROOT 时被设置为 'root' update_binding(user_id, container_id, username=username, role=updated_role) - if Key: - write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.UPDATE_COLLABORATOR_ROLE, - target_type="container", target_id=container_id, - detail={"user_id": user_id, "username": user_name, - "old_role": old_role, - "new_role": updated_role.value if hasattr(updated_role, 'value') else str(updated_role), - "container_name": container_name}) - return True - write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.UPDATE_COLLABORATOR_ROLE, + write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.UPDATE_COLLABORATOR_ROLE, target_type="container", target_id=container_id, detail={"user_id": user_id, "username": user_name, "old_role": old_role, "new_role": updated_role.value if hasattr(updated_role, 'value') else str(updated_role), - "container_name": container_name}, - error_reason="update_role_failed") - return False + "container_name": container_name}) + return True -def start_container(container_id:int, debug=False, operator_user_id:int|None=None)->bool: +def start_container(container_id:int, operator_user_id:int|None=None)->bool: """发送start到对应容器所在node,启动后心跳机制监控状态,直到状态变为ONLINE或失败""" machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): @@ -1274,7 +835,7 @@ def start_container(container_id:int, debug=False, operator_user_id:int|None=Non encryptioned_message = encryption(container_info) res = send(encryptioned_message, signatured_message, full_url) - print(f"start_container: NODE response: {res}") + logger.debug("start_container: NODE response: %s", res) # Check node-level errors _raise_on_node_error(res, 'start') @@ -1284,7 +845,7 @@ def start_container(container_id:int, debug=False, operator_user_id:int|None=Non try: container_starting_status_heartbeat(machine_ip, container_name, container_id=container_id) except Exception as e: - print(f"Failed to start start-heartbeat: {e}") + logger.warning("Failed to start start-heartbeat: %s", e) write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.START_CONTAINER, target_type="container", target_id=container_id, detail={"name": container_name}) @@ -1293,7 +854,7 @@ def start_container(container_id:int, debug=False, operator_user_id:int|None=Non raise NodeServiceError(f"NODE start returned failure: {res}", reason=res.get('error_reason') or 'start_failed') -def stop_container(container_id:int, debug=False, operator_user_id:int|None=None)->bool: +def stop_container(container_id:int, operator_user_id:int|None=None)->bool: """发送stop到对应容器所在node,停止后心跳机制监控状态,直到状态变为OFFLINE或失败""" machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): @@ -1311,7 +872,7 @@ def stop_container(container_id:int, debug=False, operator_user_id:int|None=None encryptioned_message = encryption(container_info) res = send(encryptioned_message, signatured_message, full_url) - print(f"stop_container: NODE response: {res}") + logger.debug("stop_container: NODE response: %s", res) _raise_on_node_error(res, 'stop') if res.get('success') in (1, True): @@ -1319,7 +880,7 @@ def stop_container(container_id:int, debug=False, operator_user_id:int|None=None try: container_stopping_status_heartbeat(machine_ip, container_name, container_id=container_id) except Exception as e: - print(f"Failed to start stop-heartbeat: {e}") + logger.warning("Failed to start stop-heartbeat: %s", e) write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.STOP_CONTAINER, target_type="container", target_id=container_id, detail={"name": container_name}) @@ -1327,7 +888,7 @@ def stop_container(container_id:int, debug=False, operator_user_id:int|None=None raise NodeServiceError(f"NODE stop returned failure: {res}", reason=res.get('error_reason') or 'stop_failed') -def restart_container(container_id:int, debug=False, operator_user_id:int|None=None)->bool: +def restart_container(container_id:int, operator_user_id:int|None=None)->bool: """发送restart到对应容器所在node,重启后心跳机制监控状态,直到状态变为ONLINE或失败""" machine_id = get_machine_id_by_container_id(container_id) if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): @@ -1345,7 +906,7 @@ def restart_container(container_id:int, debug=False, operator_user_id:int|None=N encryptioned_message = encryption(container_info) res = send(encryptioned_message, signatured_message, full_url) - print(f"restart_container: NODE response: {res}") + logger.debug("restart_container: NODE response: %s", res) _raise_on_node_error(res, 'restart') if res.get('success') in (1, True): @@ -1353,12 +914,12 @@ def restart_container(container_id:int, debug=False, operator_user_id:int|None=N try: update_container(container_id, container_status=ContainerStatus.OFFLINE) except Exception as e: - print(f"Warning: failed to mark container {container_id} as OFFLINE before restart-heartbeat: {e}") + logger.warning("failed to mark container %s as OFFLINE before restart-heartbeat: %s", container_id, e) # start controller-side heartbeat to watch for ONLINE after restart try: container_restart_status_heartbeat(machine_ip, container_name, container_id=container_id) except Exception as e: - print(f"Failed to start restart-heartbeat: {e}") + logger.warning("Failed to start restart-heartbeat: %s", e) write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.RESTART_CONTAINER, target_type="container", target_id=container_id, detail={"name": container_name}) @@ -1397,17 +958,17 @@ def get_container_detail_information(container_id:int)->container_detail_informa try: remove_binding(0, container_id, all=True) except Exception as e: - print(f"Warning: failed to remove bindings for {container_id}: {e}") + logger.warning("failed to remove bindings for %s: %s", container_id, e) try: delete_container(container_id) except Exception as e: - print(f"Warning: failed to delete container {container_id} from DB: {e}") + logger.warning("failed to delete container %s from DB: %s", container_id, e) raise ValueError("Container not found") except Exception as e: # 如果是 ValueError(通常是因为 Node 返回 404 导致的),则需要抛出以终止并返回 not found;如果是其他类型的异常(网络错误、超时、解析错误等),则应该捕获并忽略,以继续返回数据库中的信息。 if isinstance(e, ValueError): raise - print(f"get_container_detail_information: ignored NODE check error: {e}") + logger.warning("get_container_detail_information: ignored NODE check error: %s", e) # If Node returned a status payload, and it's not a 404, try to persist container_status to DB try: @@ -1426,9 +987,10 @@ def get_container_detail_information(container_id:int)->container_detail_informa try: update_container(container.id, container_status=new_status) except Exception as e: - print(f"Warning: failed to update container status for {container.id}: {e}") + logger.warning("failed to update container status for %s: %s", container.id, e) except Exception as e: - print(f"Warning: error while attempting to persist Node status for {container.id if 'container' in locals() and container else '?'}: {e}") + logger.warning("error while attempting to persist Node status for %s: %s", + container.id if 'container' in locals() and container else '?', e) owener_bindings= get_container_bindings(container_id) long_term_state = _build_long_term_container_state(container.id, owener_bindings) @@ -1448,7 +1010,7 @@ def get_container_detail_information(container_id:int)->container_detail_informa "usage_percent": round((d_total / limit_bytes * 100) if limit_bytes > 0 else 0, 1), } except Exception as e: - print(f"Warning: failed to read DB disk snapshot for container {container.id}: {e}") + logger.warning("failed to read DB disk snapshot for container %s: %s", container.id, e) # 冻结升级状态 freeze_state_val = None @@ -1467,7 +1029,7 @@ def get_container_detail_information(container_id:int)->container_detail_informa "escalation_days": escalation_days, } except Exception as e: - print(f"Warning: failed to read freeze state for container {container.id}: {e}") + logger.warning("failed to read freeze state for container %s: %s", container.id, e) res={ "container_id": container.id, @@ -1499,24 +1061,6 @@ def get_container_detail_information(container_id:int)->container_detail_informa -#返回一页容器的概要信息 -def _node_probe_container(container, machine_ip: str, _app=None) -> dict | None: - """封装单次 NodeKernel /container_status 查询。 - - 等同于原 for 循环内 ``get_container_status(machine_ip, container.name)``, - 抽取为独立函数以适配 ``parallel_node_calls``。 - - *_app* 可选传入 Flask app 实例,用于线程池内推送 app context。 - """ - try: - if _app is not None: - with _app.app_context(): - return get_container_status(machine_ip, container.name) - return get_container_status(machine_ip, container.name) - except Exception: - return None - - def list_all_container_bref_information(machine_id:int, request_user_id:int, page_number:int, page_size:int, user_id:int = None)->dict: # 非管理员用户必须先通过机器权限表过滤可见机器 if not _is_operator_user(request_user_id): @@ -1610,11 +1154,11 @@ def list_all_container_bref_information(machine_id:int, request_user_id:int, pag try: remove_binding(0, container.id, all=True) except Exception as e: - print(f"Warning: failed to remove bindings for {container.id}: {e}") + logger.warning("failed to remove bindings for %s: %s", container.id, e) try: delete_container(container.id) except Exception as e: - print(f"Warning: failed to delete container {container.id} from DB: {e}") + logger.warning("failed to delete container %s from DB: %s", container.id, e) _deleted.add(container.id) continue else: @@ -1633,9 +1177,9 @@ def list_all_container_bref_information(machine_id:int, request_user_id:int, pag try: update_container(container.id, container_status=new_status) except Exception as e: - print(f"Warning: failed to update container status for {container.id}: {e}") + logger.warning("failed to update container status for %s: %s", container.id, e) except Exception as e: - print(f"list_all_container_bref_information: ignored error while persisting status for {container.name}: {e}") + logger.warning("list_all_container_bref_information: ignored error while persisting status for %s: %s", container.name, e) bindings = get_container_bindings(container.id) or [] long_term_state = _build_long_term_container_state(container.id, bindings) @@ -1652,15 +1196,16 @@ def list_all_container_bref_information(machine_id:int, request_user_id:int, pag try: from flask import current_app freeze_escalation_days = int(current_app.config.get("CONTAINER_DISK_FREEZE_ESCALATION_DAYS", 7) or 7) - except Exception: + except Exception as e: + logger.debug("failed to read CONTAINER_DISK_FREEZE_ESCALATION_DAYS, fallback to 7: %s", e) freeze_escalation_days = 7 ssh_record = container_ssh_login_repo.get_by_machine_container(container.machine_id, container.id) cleanup_days = 7 try: from flask import current_app cleanup_days = int(current_app.config.get("CONTAINER_CLEANUP_AFTER_DAYS", 7) or 7) - except Exception: - pass + except Exception as e: + logger.debug("failed to read CONTAINER_CLEANUP_AFTER_DAYS, fallback to 7: %s", e) cleanup_info = build_cleanup_info( ssh_record.last_ssh_login_time if ssh_record else None, cleanup_days, diff --git a/test/test_container_sql.py b/test/test_container_sql.py index 89fd0f0..bab61d7 100644 --- a/test/test_container_sql.py +++ b/test/test_container_sql.py @@ -106,8 +106,7 @@ def test_Create_container(): cpu_number=2, memory=2048 ), - public_key="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7...", - debug=True + public_key="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7..." ) assert result is True, "Create_container 应该返回 True" @@ -222,7 +221,7 @@ def test_remove_container(): assert user_container_count_before > 0, "测试前应该有用户容器绑定存在" try: - result = remove_container(container_id=container_id, debug=True, operator_user_id=user.id) + result = remove_container(container_id=container_id, operator_user_id=user.id) assert result is True or result is False, "remove_container 应该返回布尔值" except NodeServiceError as e: print(f"NodeServiceError (expected in test environment): {e}") @@ -328,7 +327,6 @@ def test_add_collaborator(): container_id=container.id, user_id=collaborator_user.id, role=ROLE.COLLABORATOR, - debug=True, operator_user_id=owner_user.id ) if result is not None: diff --git a/utils/permissions.py b/utils/permissions.py new file mode 100644 index 0000000..85d8200 --- /dev/null +++ b/utils/permissions.py @@ -0,0 +1,21 @@ +def _can_access_machine(user_id: int, machine_id: int) -> bool: + if not user_id or not machine_id: + return False + if _is_operator_user(user_id): + return True + try: + allowed = set(machine_permission_repo.list_machine_ids_by_user(user_id)) + return machine_id in allowed + except Exception as e: + logger.warning("_can_access_machine: machine permission check failed for user %s machine %s: %s", user_id, machine_id, e) + return False + +def _is_operator_user(user_id: int) -> bool: + try: + u = user_repo.get_by_id(user_id) + # logger.debug("DEBUG: checking if user %s is operator: permission=%s", user_id, getattr(u, 'permission', None)) + perm = getattr(u, 'permission', None) if u else None + return bool(perm and getattr(perm, 'value', str(perm)).lower() == 'operator') + except Exception as e: + logger.warning("_is_operator_user: permission check failed for user %s: %s", user_id, e) + return False From febe50622d4f7557801e070e399033a61ee19e02 Mon Sep 17 00:00:00 2001 From: chester Date: Tue, 18 Aug 2026 14:30:57 +0800 Subject: [PATCH 02/63] =?UTF-8?q?-=20PROGRESS=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9A=82=E5=AD=98|=E5=88=86=E5=8C=85?= =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E5=AE=8C=E6=88=90|=E5=B7=B2=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=9B=9E=E5=BD=92=20#2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 6 +- blueprints/container_api.py | 9 +- blueprints/user_api.py | 1 - repositories/containers_repo.py | 40 +- repositories/long_term_container_repo.py | 13 + .../container_cleanup_task.py | 0 .../container_disk_check_task.py | 22 +- .../container_mount_cleanup_task.py | 0 .../container_ssh_refresh_task.py | 0 services/container_module/node_comms.py | 13 +- services/container_module/pydantic_models.py | 4 + services/container_module/utils.py | 3 + services/container_tasks.py | 355 +++++++++--------- services/operation_log_tasks.py | 13 + test/__init__.py | 6 - test/conftest.py | 2 +- test/container/conftest.py | 2 +- .../test_container_cleanup_reminders.py | 2 +- test/container/test_container_cleanup_task.py | 2 +- test/container/test_container_common.py | 4 +- .../test_container_disk_check_task.py | 2 +- .../test_container_mount_cleanup_task.py | 2 +- .../test_container_ssh_refresh_task.py | 2 +- .../test_container_tasks_information.py | 8 +- .../test_container_tasks_lifecycle.py | 9 +- test/container/test_display_status.py | 10 +- test/e2e/test_ctrl_cleanup_reminder_flow.py | 2 +- .../test_ctrl_user_machine_container_flow.py | 3 +- test/test_testing_platform.py | 2 +- utils/permissions.py | 7 + 30 files changed, 316 insertions(+), 228 deletions(-) rename {schemas => schedulers}/container_cleanup_task.py (100%) rename {schemas => schedulers}/container_disk_check_task.py (94%) rename {schemas => schedulers}/container_mount_cleanup_task.py (100%) rename {schemas => schedulers}/container_ssh_refresh_task.py (100%) diff --git a/__init__.py b/__init__.py index ccfe857..fa8ed11 100644 --- a/__init__.py +++ b/__init__.py @@ -11,9 +11,9 @@ from .extensions import db from .config import get_config, build_allowed_origins from .blueprints import register_blueprints -from .schemas.container_ssh_refresh_task import start_container_ssh_refresh_scheduler -from .schemas.container_cleanup_task import start_container_cleanup_scheduler -from .schemas.container_mount_cleanup_task import start_mount_cleanup_scheduler +from .schedulers.container_ssh_refresh_task import start_container_ssh_refresh_scheduler +from .schedulers.container_cleanup_task import start_container_cleanup_scheduler +from .schedulers.container_mount_cleanup_task import start_mount_cleanup_scheduler from .utils.logging_config import configure_daily_logging diff --git a/blueprints/container_api.py b/blueprints/container_api.py index 55a3c4c..cbf7054 100644 --- a/blueprints/container_api.py +++ b/blueprints/container_api.py @@ -1,4 +1,3 @@ -import logging from sqlalchemy.exc import IntegrityError from flask import jsonify, request from flask import current_app @@ -11,8 +10,6 @@ from ..repositories import containers_repo, authentications_repo, user_repo from ..schemas.user_schema import user_schema, users_schema -logger = logging.getLogger(__name__) - # map known error_reason strings to HTTP status codes so we can surface them to clients REASON_STATUS_MAP = { 'container_exists': 409, @@ -42,11 +39,9 @@ def _log_failure(*, operation, target_type, target_id, operator_user_id, error_reason, detail=None): """蓝图层失败补记:task 层直接上抛/返回 False 的失败在这里统一记一条。 - .log 与 op-log 同源:error 级落日志文件,success=False 落操作日志表。 + .log 记录由 write_operation_log 内部统一完成(success=False → error 级), + 此处只负责补写 op-log 表。 """ - logger.error("operation failed: op=%s target=%s/%s user=%s reason=%s detail=%s", - getattr(operation, 'value', operation), target_type, target_id, - operator_user_id, error_reason, detail or {}) write_op_log(success=False, operator_user_id=operator_user_id, operation=operation, target_type=target_type, target_id=target_id, detail=detail or {}, error_reason=error_reason) diff --git a/blueprints/user_api.py b/blueprints/user_api.py index 550ceb6..6f1d97b 100644 --- a/blueprints/user_api.py +++ b/blueprints/user_api.py @@ -27,7 +27,6 @@ def register(): } ''' """用户注册 API""" - print("Register Called") recived_data = request.get_json(silent=True) if not recived_data: return jsonify({"success": 0, "message": "invalid json"}), 400 diff --git a/repositories/containers_repo.py b/repositories/containers_repo.py index 599c6f8..503c299 100644 --- a/repositories/containers_repo.py +++ b/repositories/containers_repo.py @@ -7,7 +7,7 @@ from ..utils.Container import Container_info from ..constant import ROLE from sqlalchemy.exc import IntegrityError -from . import machine_repo +from . import machine_repo, usercontainer_repo, user_repo from .machine_repo import get_max_gpu_number, get_max_shared_gb, get_max_cpu_core_number, get_max_memory_gb @@ -275,7 +275,7 @@ def validate_create_params(machine_id: int, container: Container_info, public_ke 抛 ValueError / IntegrityError,异常语义与逐条调用时一致。 """ # 存在性检查 - ensure_machine_exists(machine_id) + machine = ensure_machine_exists(machine_id) # GPU 参数检查 validate_gpu_request(machine, container) # memory/shared 参数检查(要求 shared <= memory;memory 校验在内部先跑) @@ -287,4 +287,38 @@ def validate_create_params(machine_id: int, container: Container_info, public_ke # duplicate name check (may raise IntegrityError) check_duplicate_container_name(container_name=container.NAME, machine_id=machine_id) -############################### \ No newline at end of file +############################### + +def get_container_root_owner_emails(container_id: int) -> list[str]: + bindings = usercontainer_repo.get_container_bindings(container_id) or [] + emails = [] + seen = set() + for binding in bindings: + if _binding_role_value(binding).upper() != ROLE.ROOT.value: + continue + user_id = binding.get("user_id") + if user_id is None: + continue + user = user_repo.get_by_id(int(user_id)) + email = getattr(user, "email", None) + if email and email not in seen: + emails.append(email) + seen.add(email) + return emails + +########################################### +# 视图 + +def _binding_role_value(binding: dict) -> str: + role = binding.get("role") if isinstance(binding, dict) else None + return role.value if isinstance(role, ROLE) else str(role or "") + + +def _root_user_ids_from_bindings(bindings: list | None) -> set[int]: + return { + int(b["user_id"]) + for b in (bindings or []) + if isinstance(b, dict) + and b.get("user_id") is not None + and _binding_role_value(b).upper() == ROLE.ROOT.value + } \ No newline at end of file diff --git a/repositories/long_term_container_repo.py b/repositories/long_term_container_repo.py index 0c7264a..5d693e1 100644 --- a/repositories/long_term_container_repo.py +++ b/repositories/long_term_container_repo.py @@ -55,3 +55,16 @@ def remove(container_id: int, commit: bool = True) -> bool: if commit: db.session.commit() return True + + +def _get_long_term_container_limit() -> int: + try: + from flask import current_app + return max(0, int(current_app.config.get("LONG_TERM_CONTAINER_LIMIT", 1) or 1)) + except Exception: + return 1 + +def get_long_term_container_remaining(user_id: int) -> int: + limit = _get_long_term_container_limit() + used = count_by_user(user_id) + return max(0, limit - used) \ No newline at end of file diff --git a/schemas/container_cleanup_task.py b/schedulers/container_cleanup_task.py similarity index 100% rename from schemas/container_cleanup_task.py rename to schedulers/container_cleanup_task.py diff --git a/schemas/container_disk_check_task.py b/schedulers/container_disk_check_task.py similarity index 94% rename from schemas/container_disk_check_task.py rename to schedulers/container_disk_check_task.py index 0c1f66b..0552361 100644 --- a/schemas/container_disk_check_task.py +++ b/schedulers/container_disk_check_task.py @@ -277,23 +277,11 @@ def _handle_hard_limit(container, usage: dict, app) -> None: if str(status_val).lower() not in ('online',): logger.info("[disk-check] pause skipped for container %s: status=%s", container.id, status_val) return - from ..repositories.machine_repo import get_machine_ip_by_id - from ..constant import ContainerStatus - machine_ip = get_machine_ip_by_id(container.machine_id) - url = container_tasks.get_full_url(machine_ip, "/pause_container") - payload = json.dumps({"config": {"container_name": container.name, "action": "pause"}}) - sig = container_tasks.signature(payload) - enc = container_tasks.encryption(payload) - res = container_tasks.send(enc, sig, url, timeout=10.0) - logger.debug("[disk-check] pause result for container %s: %s", container.id, res) - # 更新 DB 状态为 paused,防止并行检查重复 pause - if isinstance(res, dict) and res.get("success") == 1: - containers_repo.update_container(container.id, commit=True, - container_status=ContainerStatus.PAUSED) - from ..services.operation_log_tasks import write_operation_log as write_op_log - write_op_log(success=True, operation=OperationType.PAUSE_CONTAINER, target_type="container", - target_id=container.id, - detail={"reason": "disk_hard_limit", "usage": f"{total_gb:.1f}GB/{limit_gb:.1f}GB"}) + ok = container_tasks.pause_container( + container.id, + extra_detail={"reason": "disk_hard_limit", "usage": f"{total_gb:.1f}GB/{limit_gb:.1f}GB"}, + ) + logger.debug("[disk-check] pause result for container %s: %s", container.id, ok) except Exception as e: logger.error("[disk-check] pause failed for container %s: %s", container.id, e) diff --git a/schemas/container_mount_cleanup_task.py b/schedulers/container_mount_cleanup_task.py similarity index 100% rename from schemas/container_mount_cleanup_task.py rename to schedulers/container_mount_cleanup_task.py diff --git a/schemas/container_ssh_refresh_task.py b/schedulers/container_ssh_refresh_task.py similarity index 100% rename from schemas/container_ssh_refresh_task.py rename to schedulers/container_ssh_refresh_task.py diff --git a/services/container_module/node_comms.py b/services/container_module/node_comms.py index c1edb94..4b1b86a 100644 --- a/services/container_module/node_comms.py +++ b/services/container_module/node_comms.py @@ -1,5 +1,16 @@ +import json import logging -import +import time +import base64 +import requests +import traceback + +from ...config import CommsConfig +from ...repositories import machine_repo +from ..machine_tasks import is_machine_online_remote +from ...utils.CheckKeys import signature, encryption +from ...utils.parallel import parallel_node_calls +from .exceptions import NodeServiceError #################################################### diff --git a/services/container_module/pydantic_models.py b/services/container_module/pydantic_models.py index d4cf454..4047e5c 100644 --- a/services/container_module/pydantic_models.py +++ b/services/container_module/pydantic_models.py @@ -1,5 +1,9 @@ # 容器展示态派生:宿主机不可达时覆盖为 host_offline(仅展示,DB 状态不动) +from pydantic import BaseModel, Field +from ...constant import ROLE, ContainerStatus +from ..machine_tasks import get_machine_reachable + #API Definition #################################################### class container_bref_information(BaseModel): diff --git a/services/container_module/utils.py b/services/container_module/utils.py index 12341e3..553cbbf 100644 --- a/services/container_module/utils.py +++ b/services/container_module/utils.py @@ -1,6 +1,9 @@ #################################################### # 辅助工具 +import re +from datetime import datetime, timedelta + _MONTH_ABBR_TO_NUM = { "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12, diff --git a/services/container_tasks.py b/services/container_tasks.py index bc040b2..a2bf25a 100644 --- a/services/container_tasks.py +++ b/services/container_tasks.py @@ -3,14 +3,13 @@ import time import base64 import logging -from datetime import datetime, timedelta +from datetime import datetime import traceback from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey from pydantic import BaseModel, Field -from ..config import CommsConfig from ..constant import * from ..utils.parallel import parallel_node_calls from sqlalchemy.exc import IntegrityError @@ -18,7 +17,6 @@ from .operation_log_tasks import write_operation_log as write_op_log from ..repositories import containers_repo as container_repo from ..repositories import container_ssh_login_repo -from .machine_tasks import is_machine_online_remote, get_machine_reachable from ..repositories.machine_repo import * from ..repositories.user_repo import * from ..utils.CheckKeys import * @@ -34,6 +32,25 @@ import math from ..utils import sanitizer as _sanitizer +from .container_module.node_comms import ( + send, + get_full_url, + get_container_status, + _ensure_machine_online_for_operation, + _node_probe_container, +) +from .container_module.exceptions import NodeServiceError, _raise_on_node_error +from .container_module.pydantic_models import ( + container_bref_information, + container_detail_information, + _derive_display_status, + DISPLAY_STATUS_HOST_OFFLINE, +) +from .container_module.utils import _parse_last_ssh_time, build_cleanup_info +from ..utils.permissions import _can_access_machine, _is_operator_user +from ..repositories.long_term_container_repo import get_long_term_container_remaining, _get_long_term_container_limit +from ..repositories.containers_repo import _binding_role_value, _root_user_ids_from_bindings + logger = logging.getLogger(__name__) @@ -137,123 +154,6 @@ def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) - return last_time -def unpause_container(container_id: int, operator_user_id: int | None = None) -> bool: - """解冻因磁盘超限被 pause 的容器。""" - try: - container_id = int(container_id) - except Exception: - return False - - container = containers_repo.get_by_id(container_id) - if not container: - return False - - machine_id = container.machine_id - if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): - raise NodeServiceError(f'Machine {machine_id} not accessible', reason='machine_permission_denied') - - machine_ip = get_machine_ip_by_id(machine_id) - url = get_full_url(machine_ip, "/pause_container") - payload = json.dumps({"config": {"container_name": container.name, "action": "unpause"}}) - try: - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=10.0) - except Exception as e: - logger.error("unpause_container send error: %s", e) - write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, - target_type="container", target_id=container.id, - detail={"name": container.name, "machine_id": machine_id}, - error_reason=getattr(e, 'reason', None) or str(e)) - return False - - _raise_on_node_error(res, 'unpause') - if res.get('success') == 1: - # 更新本地状态为 online - try: - update_container(container.id, container_status=ContainerStatus.ONLINE) - except Exception as e: - logger.warning("unpause: failed to update container %s status to ONLINE: %s", container.id, e) - write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, - target_type="container", target_id=container.id, - detail={"name": container.name, "machine_id": machine_id}) - - # 磁盘超限冻结宽限期:管理员解冻后给予宽限 - try: - from ..repositories import container_disk_freeze_state_repo - freeze_state = container_disk_freeze_state_repo.get(container_id) - if freeze_state is not None: - from flask import current_app - grace_days = current_app.config.get("CONTAINER_DISK_FREEZE_GRACE_DAYS", 3) - container_disk_freeze_state_repo.set_grace(container_id, grace_days) - logger.info( - "[disk-check] grace period set for container %s (%s) (%s days, until %s)", - container_id, getattr(container, 'name', '?'), grace_days, freeze_state.grace_until, - ) - except Exception as e: - logger.warning("[disk-check] failed to set grace for container %s: %s", container_id, e) - - return True - return False - - -def get_container_disk_usage(container_id: int, timeout: float = 20.0) -> dict | None: - """ - 通过 Node 查询容器磁盘使用情况(只读)。 - 入参: container_id - 返回: dict 包含 machine_disk 和 container 信息,或 None - """ - try: - container_id = int(container_id) - except Exception: - logger.warning("Invalid container id for disk usage query: %s", container_id) - return None - - try: - container = containers_repo.get_by_id(container_id) - except Exception: - logger.error("Error querying container info for id=%s: %s", container_id, traceback.format_exc()) - return None - - if not container: - return None - try: - machine_id = container.machine_id - machine_ip = get_machine_ip_by_id(machine_id) - url = get_full_url(machine_ip, "/check_disk_usage") - except Exception: - logger.error("Error retrieving machine info for container id=%s: %s", container_id, traceback.format_exc()) - return None - - container_name = getattr(container, 'name', None) - payload = json.dumps({"config": {"container_name": container_name}}) - try: - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=timeout) - except Exception as e: - logger.error("Error sending disk check request to %s: %s", url, e) - return None - - if not isinstance(res, dict): - logger.error("get_container_disk_usage: unexpected response type: %s", type(res)) - return None - - _raise_on_node_error(res, 'check_disk') - if res.get('success') != 1: - logger.error("get_container_disk_usage: Node returned failure: %s", res) - return None - - cd = res.get("container", {}) - _errs = {k: cd[k] for k in ("overlay_rw_error", "bind_mount_error", "bind_mount_path") if k in cd} - logger.info("[disk-check] ctrl received: container=%s overlay=%sB bind=%sB total=%sB errs=%s", - container_name, cd.get('overlay_rw_bytes'), cd.get('bind_mount_bytes'), cd.get('total_bytes'), _errs) - return res - - -#################################################### - - #Function Implementation #################################################### @@ -464,10 +364,176 @@ def remove_container(container_id:int, operator_user_id:int|None=None)->bool: return True +def pause_container(container_id: int, operator_user_id: int | None = None, extra_detail: dict | None = None) -> bool: + """冻结容器(磁盘超限等场景)。与 unpause_container 对称。 + + *extra_detail* 供调用方补充操作详情(如磁盘处置的 reason/usage),合并进 op-log。 + """ + try: + container_id = int(container_id) + except Exception: + return False + + container = containers_repo.get_by_id(container_id) + if not container: + return False + + machine_id = container.machine_id + if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): + raise NodeServiceError(f'Machine {machine_id} not accessible', reason='machine_permission_denied') + + machine_ip = get_machine_ip_by_id(machine_id) + url = get_full_url(machine_ip, "/pause_container") + payload = json.dumps({"config": {"container_name": container.name, "action": "pause"}}) + try: + sig = signature(payload) + enc = encryption(payload) + res = send(enc, sig, url, timeout=10.0) + except Exception as e: + logger.error("pause_container send error: %s", e) + write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.PAUSE_CONTAINER, + target_type="container", target_id=container.id, + detail={"name": container.name, "machine_id": machine_id, **(extra_detail or {})}, + error_reason=getattr(e, 'reason', None) or str(e)) + return False + + _raise_on_node_error(res, 'pause') + if res.get('success') == 1: + # 更新本地状态为 paused + try: + update_container(container.id, container_status=ContainerStatus.PAUSED) + except Exception as e: + logger.warning("pause: failed to update container %s status to PAUSED: %s", container.id, e) + write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.PAUSE_CONTAINER, + target_type="container", target_id=container.id, + detail={"name": container.name, "machine_id": machine_id, **(extra_detail or {})}) + return True + return False + + +def unpause_container(container_id: int, operator_user_id: int | None = None) -> bool: + """解冻因磁盘超限被 pause 的容器。""" + try: + container_id = int(container_id) + except Exception: + return False + + container = containers_repo.get_by_id(container_id) + if not container: + return False + + machine_id = container.machine_id + if operator_user_id is not None and not _can_access_machine(operator_user_id, machine_id): + raise NodeServiceError(f'Machine {machine_id} not accessible', reason='machine_permission_denied') + + machine_ip = get_machine_ip_by_id(machine_id) + url = get_full_url(machine_ip, "/pause_container") + payload = json.dumps({"config": {"container_name": container.name, "action": "unpause"}}) + try: + sig = signature(payload) + enc = encryption(payload) + res = send(enc, sig, url, timeout=10.0) + except Exception as e: + logger.error("unpause_container send error: %s", e) + write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, + target_type="container", target_id=container.id, + detail={"name": container.name, "machine_id": machine_id}, + error_reason=getattr(e, 'reason', None) or str(e)) + return False + + _raise_on_node_error(res, 'unpause') + if res.get('success') == 1: + # 更新本地状态为 online + try: + update_container(container.id, container_status=ContainerStatus.ONLINE) + except Exception as e: + logger.warning("unpause: failed to update container %s status to ONLINE: %s", container.id, e) + write_op_log(success=True, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, + target_type="container", target_id=container.id, + detail={"name": container.name, "machine_id": machine_id}) + + # 磁盘超限冻结宽限期:管理员解冻后给予宽限 + try: + from ..repositories import container_disk_freeze_state_repo + freeze_state = container_disk_freeze_state_repo.get(container_id) + if freeze_state is not None: + from flask import current_app + grace_days = current_app.config.get("CONTAINER_DISK_FREEZE_GRACE_DAYS", 3) + container_disk_freeze_state_repo.set_grace(container_id, grace_days) + logger.info( + "[disk-check] grace period set for container %s (%s) (%s days, until %s)", + container_id, getattr(container, 'name', '?'), grace_days, freeze_state.grace_until, + ) + except Exception as e: + logger.warning("[disk-check] failed to set grace for container %s: %s", container_id, e) + + return True + return False + + +def get_container_disk_usage(container_id: int, timeout: float = 20.0) -> dict | None: + """ + 通过 Node 查询容器磁盘使用情况(只读)。 + 入参: container_id + 返回: dict 包含 machine_disk 和 container 信息,或 None + """ + try: + container_id = int(container_id) + except Exception: + logger.warning("Invalid container id for disk usage query: %s", container_id) + return None + + try: + container = containers_repo.get_by_id(container_id) + except Exception: + logger.error("Error querying container info for id=%s: %s", container_id, traceback.format_exc()) + return None + + if not container: + return None + try: + machine_id = container.machine_id + machine_ip = get_machine_ip_by_id(machine_id) + url = get_full_url(machine_ip, "/check_disk_usage") + except Exception: + logger.error("Error retrieving machine info for container id=%s: %s", container_id, traceback.format_exc()) + return None + + container_name = getattr(container, 'name', None) + payload = json.dumps({"config": {"container_name": container_name}}) + try: + sig = signature(payload) + enc = encryption(payload) + res = send(enc, sig, url, timeout=timeout) + except Exception as e: + logger.error("Error sending disk check request to %s: %s", url, e) + return None + + if not isinstance(res, dict): + logger.error("get_container_disk_usage: unexpected response type: %s", type(res)) + return None + + _raise_on_node_error(res, 'check_disk') + if res.get('success') != 1: + logger.error("get_container_disk_usage: Node returned failure: %s", res) + return None + + cd = res.get("container", {}) + _errs = {k: cd[k] for k in ("overlay_rw_error", "bind_mount_error", "bind_mount_path") if k in cd} + logger.info("[disk-check] ctrl received: container=%s overlay=%sB bind=%sB total=%sB errs=%s", + container_name, cd.get('overlay_rw_bytes'), cd.get('bind_mount_bytes'), cd.get('total_bytes'), _errs) + return res + + +#################################################### + def build_container_restore_snapshot(container_id: int, cleanup_context: dict | None = None) -> dict: """ Build a pre-removal snapshot with enough metadata to recreate the container and bindings. + + # 注:字段集将来可能被 image 蓝图(Dockerfile + 脚本 + pre_build)参考, + # image 域(FuxiYu_Global/fuxi平台继续开发.md「新增需求」)落地时评估是否吸收。 """ container = get_by_id(container_id) if not container: @@ -519,53 +585,6 @@ def build_container_restore_snapshot(container_id: int, cleanup_context: dict | return snapshot -def get_container_root_owner_emails(container_id: int) -> list[str]: - bindings = get_container_bindings(container_id) or [] - emails = [] - seen = set() - for binding in bindings: - if _binding_role_value(binding).upper() != ROLE.ROOT.value: - continue - user_id = binding.get("user_id") - if user_id is None: - continue - user = user_repo.get_by_id(int(user_id)) - email = getattr(user, "email", None) - if email and email not in seen: - emails.append(email) - seen.add(email) - return emails - - -def _get_long_term_container_limit() -> int: - try: - from flask import current_app - return max(0, int(current_app.config.get("LONG_TERM_CONTAINER_LIMIT", 1) or 1)) - except Exception: - return 1 - - -def get_long_term_container_remaining(user_id: int) -> int: - limit = _get_long_term_container_limit() - used = long_term_container_repo.count_by_user(user_id) - return max(0, limit - used) - - -def _binding_role_value(binding: dict) -> str: - role = binding.get("role") if isinstance(binding, dict) else None - return role.value if isinstance(role, ROLE) else str(role or "") - - -def _root_user_ids_from_bindings(bindings: list | None) -> set[int]: - return { - int(b["user_id"]) - for b in (bindings or []) - if isinstance(b, dict) - and b.get("user_id") is not None - and _binding_role_value(b).upper() == ROLE.ROOT.value - } - - def _build_long_term_container_state(container_id: int, bindings: list | None = None) -> dict: bindings = bindings if bindings is not None else (get_container_bindings(container_id) or []) is_long_term = long_term_container_repo.is_long_term(container_id) @@ -1059,8 +1078,6 @@ def get_container_detail_information(container_id:int)->container_detail_informa } return res - - def list_all_container_bref_information(machine_id:int, request_user_id:int, page_number:int, page_size:int, user_id:int = None)->dict: # 非管理员用户必须先通过机器权限表过滤可见机器 if not _is_operator_user(request_user_id): diff --git a/services/operation_log_tasks.py b/services/operation_log_tasks.py index a43b2c5..28650ad 100644 --- a/services/operation_log_tasks.py +++ b/services/operation_log_tasks.py @@ -1,5 +1,7 @@ """操作日志服务层:蓝图 → service → repo 的分层入口。""" +import logging + from ..repositories import machine_repo, user_repo, containers_repo, usercontainer_repo from ..repositories.operation_log_repo import ( list_logs as _repo_list, @@ -9,6 +11,8 @@ ) from ..constant import ROLE +logger = logging.getLogger(__name__) + # TODO def _maybe_raise_alert( *, @@ -46,7 +50,16 @@ def write_operation_log( """写操作日志统一入口。 本身不抛异常,log失败只打 print,不影响主流程。 + .log 与 op-log 表同源:本函数是唯一写入点,成功/失败按级别落日志文件。 """ + _op = getattr(operation, 'value', operation) + if success: + logger.info("op success: op=%s target=%s/%s user=%s detail=%s", + _op, target_type, target_id, operator_user_id, detail) + else: + logger.error("op failed: op=%s target=%s/%s user=%s reason=%s detail=%s", + _op, target_type, target_id, operator_user_id, error_reason, detail) + result = _repo_write( operator_user_id=operator_user_id, operation=operation, diff --git a/test/__init__.py b/test/__init__.py index f316c71..e69de29 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,6 +0,0 @@ -#TODO:完成业务级别的单元测试 -#测试逻辑:1. 数据库是否正确插入 2.docker指令发送是否正确 - - - - diff --git a/test/conftest.py b/test/conftest.py index 626cc92..dda2486 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -119,7 +119,7 @@ def is_alive(self): monkeypatch.setattr("FuxiYu_CtrKernel.services.user_tasks.send_mail", _mail_send) monkeypatch.setattr("FuxiYu_CtrKernel.services.announcement_tasks.send_mail", _mail_send) monkeypatch.setattr("FuxiYu_CtrKernel.services.announcement_tasks.send_batch", _mail_send_batch) - monkeypatch.setattr("FuxiYu_CtrKernel.schemas.container_cleanup_task.send_mail", _mail_send) + monkeypatch.setattr("FuxiYu_CtrKernel.schedulers.container_cleanup_task.send_mail", _mail_send) monkeypatch.setattr("FuxiYu_CtrKernel.utils.heartbeat.start_machine_maintenance_transition_heartbeat", _fake_thread) monkeypatch.setattr("FuxiYu_CtrKernel.utils.heartbeat.container_starting_status_heartbeat", _fake_thread) monkeypatch.setattr("FuxiYu_CtrKernel.utils.heartbeat.container_stopping_status_heartbeat", _fake_thread) diff --git a/test/container/conftest.py b/test/container/conftest.py index 09db880..3b38b66 100644 --- a/test/container/conftest.py +++ b/test/container/conftest.py @@ -28,7 +28,7 @@ @pytest.fixture(autouse=True) def mock_container_machine_online(monkeypatch): monkeypatch.setattr( - "FuxiYu_CtrKernel.services.container_tasks.is_machine_online_remote", + "FuxiYu_CtrKernel.services.container_module.node_comms.is_machine_online_remote", lambda machine_id: True, ) diff --git a/test/container/test_container_cleanup_reminders.py b/test/container/test_container_cleanup_reminders.py index e7d897f..2bbe44a 100644 --- a/test/container/test_container_cleanup_reminders.py +++ b/test/container/test_container_cleanup_reminders.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from ...repositories import container_cleanup_reminder_repo -from ...schemas import container_cleanup_task +from ...schedulers import container_cleanup_task from ..factories import create_container_graph diff --git a/test/container/test_container_cleanup_task.py b/test/container/test_container_cleanup_task.py index 728441d..db1b3c7 100644 --- a/test/container/test_container_cleanup_task.py +++ b/test/container/test_container_cleanup_task.py @@ -3,7 +3,7 @@ from ...extensions import db from ...models.container_ssh_login import ContainerSSHLogin from ...repositories import long_term_container_repo -from ...schemas import container_cleanup_task +from ...schedulers import container_cleanup_task from ..factories import create_container_graph diff --git a/test/container/test_container_common.py b/test/container/test_container_common.py index c3835ba..65bea3c 100644 --- a/test/container/test_container_common.py +++ b/test/container/test_container_common.py @@ -49,6 +49,8 @@ def test_container_heartbeats_are_mocked_by_default(container_graph): def test_machine_online_check_is_mocked_by_default(container_graph): + from ...services.container_module import node_comms + _root, machine, _container = container_graph - assert container_tasks.is_machine_online_remote(machine.id) is True + assert node_comms.is_machine_online_remote(machine.id) is True diff --git a/test/container/test_container_disk_check_task.py b/test/container/test_container_disk_check_task.py index e948269..2852193 100644 --- a/test/container/test_container_disk_check_task.py +++ b/test/container/test_container_disk_check_task.py @@ -17,7 +17,7 @@ container_disk_freeze_state_repo, long_term_container_repo, ) -from ...schemas import container_disk_check_task +from ...schedulers import container_disk_check_task from ..factories import create_container_graph diff --git a/test/container/test_container_mount_cleanup_task.py b/test/container/test_container_mount_cleanup_task.py index 472a765..fff8f0e 100644 --- a/test/container/test_container_mount_cleanup_task.py +++ b/test/container/test_container_mount_cleanup_task.py @@ -4,7 +4,7 @@ from ...extensions import db from ...repositories import container_mount_cleanup_repo -from ...schemas import container_mount_cleanup_task +from ...schedulers import container_mount_cleanup_task class TestMountCleanupTask: diff --git a/test/container/test_container_ssh_refresh_task.py b/test/container/test_container_ssh_refresh_task.py index f77e294..756ad98 100644 --- a/test/container/test_container_ssh_refresh_task.py +++ b/test/container/test_container_ssh_refresh_task.py @@ -1,4 +1,4 @@ -from ...schemas import container_ssh_refresh_task +from ...schedulers import container_ssh_refresh_task from ..factories import create_container, create_machine diff --git a/test/container/test_container_tasks_information.py b/test/container/test_container_tasks_information.py index a973ced..ae33fcc 100644 --- a/test/container/test_container_tasks_information.py +++ b/test/container/test_container_tasks_information.py @@ -111,8 +111,10 @@ def test_list_container_bref_non_operator_filters_by_machine_permission(monkeypa def test_list_container_bref_node_404_removes_and_skips_container(monkeypatch, db_session, container_graph): + from ...services.container_module import node_comms + root, _machine, container = container_graph - monkeypatch.setattr(container_tasks, "get_container_status", lambda *args, **kwargs: NODE_STATUS_404) + monkeypatch.setattr(node_comms, "get_container_status", lambda *args, **kwargs: NODE_STATUS_404) result = container_tasks.list_all_container_bref_information( machine_id=None, @@ -127,8 +129,10 @@ def test_list_container_bref_node_404_removes_and_skips_container(monkeypatch, d def test_list_container_bref_includes_cleanup_info_from_ssh_record(monkeypatch, db_session, container_graph): + from ...services.container_module import node_comms + root, machine, container = container_graph - monkeypatch.setattr(container_tasks, "get_container_status", lambda *args, **kwargs: NODE_STATUS_ONLINE) + monkeypatch.setattr(node_comms, "get_container_status", lambda *args, **kwargs: NODE_STATUS_ONLINE) last_time = (datetime.utcnow() - timedelta(days=1)).isoformat() container_ssh_login_repo.upsert_last_ssh_login_time(machine.id, container.id, last_time) diff --git a/test/container/test_container_tasks_lifecycle.py b/test/container/test_container_tasks_lifecycle.py index 1aaf9d8..e3c7e11 100644 --- a/test/container/test_container_tasks_lifecycle.py +++ b/test/container/test_container_tasks_lifecycle.py @@ -69,9 +69,11 @@ def test_create_container_rejects_machine_maintenance(db_session, container_info def test_create_container_rejects_machine_offline(monkeypatch, db_session, container_info): + from ...services.container_module import node_comms + owner = create_user() machine = create_machine() - monkeypatch.setattr(container_tasks, "is_machine_online_remote", lambda machine_id: False) + monkeypatch.setattr(node_comms, "is_machine_online_remote", lambda machine_id: False) with pytest.raises(container_tasks.NodeServiceError) as excinfo: container_tasks.Create_container(owner.username, machine.id, container_info) @@ -124,7 +126,7 @@ def test_create_container_node_failure_does_not_create_local_record( assert Container.query.filter_by(name=container_info.NAME).first() is None -def test_create_container_heartbeat_failure_returns_false_after_local_write( +def test_create_container_heartbeat_failure_keeps_creation_success( monkeypatch, db_session, container_info, @@ -140,7 +142,8 @@ def test_create_container_heartbeat_failure_returns_false_after_local_write( lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("heartbeat")), ) - assert container_tasks.Create_container(owner.username, machine.id, container_info) is False + # 心跳失败不再阻断创建:容器已建成功(Node/DB/绑定已落),返回 True + assert container_tasks.Create_container(owner.username, machine.id, container_info) is True assert Container.query.filter_by(name=container_info.NAME).first() is not None diff --git a/test/container/test_display_status.py b/test/container/test_display_status.py index a902048..b258c67 100644 --- a/test/container/test_display_status.py +++ b/test/container/test_display_status.py @@ -1,12 +1,12 @@ """容器展示态派生规则测试(DB 状态不动,仅派生 display_status)。""" -from ...services import container_tasks from ...services.container_tasks import _derive_display_status, DISPLAY_STATUS_HOST_OFFLINE +from ...services.container_module import pydantic_models from ...constant import ContainerStatus def test_host_offline_overrides_running_states(monkeypatch): - monkeypatch.setattr(container_tasks, "get_machine_reachable", lambda mid: False) + monkeypatch.setattr(pydantic_models, "get_machine_reachable", lambda mid: False) assert _derive_display_status(ContainerStatus.ONLINE, 3) == DISPLAY_STATUS_HOST_OFFLINE assert _derive_display_status(ContainerStatus.OFFLINE, 3) == DISPLAY_STATUS_HOST_OFFLINE assert _derive_display_status(ContainerStatus.CREATING, 3) == DISPLAY_STATUS_HOST_OFFLINE @@ -14,16 +14,16 @@ def test_host_offline_overrides_running_states(monkeypatch): def test_failed_not_masked_by_host_offline(monkeypatch): """failed 是终态诊断,即使宿主机不可达也不覆盖。""" - monkeypatch.setattr(container_tasks, "get_machine_reachable", lambda mid: False) + monkeypatch.setattr(pydantic_models, "get_machine_reachable", lambda mid: False) assert _derive_display_status(ContainerStatus.FAILED, 3) == ContainerStatus.FAILED.value def test_normal_when_reachable(monkeypatch): - monkeypatch.setattr(container_tasks, "get_machine_reachable", lambda mid: True) + monkeypatch.setattr(pydantic_models, "get_machine_reachable", lambda mid: True) assert _derive_display_status(ContainerStatus.ONLINE, 3) == ContainerStatus.ONLINE.value assert _derive_display_status(ContainerStatus.OFFLINE, 3) == ContainerStatus.OFFLINE.value def test_no_machine_id_returns_raw_status(monkeypatch): - monkeypatch.setattr(container_tasks, "get_machine_reachable", lambda mid: False) + monkeypatch.setattr(pydantic_models, "get_machine_reachable", lambda mid: False) assert _derive_display_status(ContainerStatus.ONLINE, None) == ContainerStatus.ONLINE.value diff --git a/test/e2e/test_ctrl_cleanup_reminder_flow.py b/test/e2e/test_ctrl_cleanup_reminder_flow.py index 77a514f..8acdbbc 100644 --- a/test/e2e/test_ctrl_cleanup_reminder_flow.py +++ b/test/e2e/test_ctrl_cleanup_reminder_flow.py @@ -6,7 +6,7 @@ from ...models.container_ssh_login import ContainerSSHLogin from ...models.container_cleanup_reminder import ContainerCleanupReminder from ...repositories import long_term_container_repo -from ...schemas import container_cleanup_task +from ...schedulers import container_cleanup_task from ..factories import create_container_graph diff --git a/test/e2e/test_ctrl_user_machine_container_flow.py b/test/e2e/test_ctrl_user_machine_container_flow.py index 1d79bef..1f88f92 100644 --- a/test/e2e/test_ctrl_user_machine_container_flow.py +++ b/test/e2e/test_ctrl_user_machine_container_flow.py @@ -7,6 +7,7 @@ from ...models.containers import Container from ...repositories import machine_permission_repo from ...services import container_tasks +from ...services.container_module import node_comms pytestmark = pytest.mark.e2e @@ -24,7 +25,7 @@ def test_ctrl_e2e_user_login_machine_permission_container_create_and_list( login_resp = client.post("/api/login", json={"username": "e2e_user", "password": "Password_123"}) mocks.mock_node_response(monkeypatch, container_tasks, {"success": 1}) mocks.mock_container_crypto(monkeypatch, container_tasks) - monkeypatch.setattr(container_tasks, "is_machine_online_remote", lambda machine_id: True) + monkeypatch.setattr(node_comms, "is_machine_online_remote", lambda machine_id: True) heartbeat_calls = [] monkeypatch.setattr( container_tasks, diff --git a/test/test_testing_platform.py b/test/test_testing_platform.py index 7edc5db..7d78075 100644 --- a/test/test_testing_platform.py +++ b/test/test_testing_platform.py @@ -63,7 +63,7 @@ def test_mock_node_response_records_calls(monkeypatch): def test_mock_mail_success_records_recipient_subject_content(monkeypatch): - from ..schemas import container_cleanup_task + from ..schedulers import container_cleanup_task calls = mocks.mock_mail_success(monkeypatch, container_cleanup_task) diff --git a/utils/permissions.py b/utils/permissions.py index 85d8200..93f74cc 100644 --- a/utils/permissions.py +++ b/utils/permissions.py @@ -1,3 +1,10 @@ +import logging + +from ..repositories import user_repo, machine_permission_repo + +logger = logging.getLogger(__name__) + + def _can_access_machine(user_id: int, machine_id: int) -> bool: if not user_id or not machine_id: return False From 3338bf73a3e627787beecda8972bf57617a7ba6c Mon Sep 17 00:00:00 2001 From: chester Date: Wed, 19 Aug 2026 15:43:32 +0800 Subject: [PATCH 03/63] =?UTF-8?q?-=20PROGRESS=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9A=82=E5=AD=98|WSS=E5=92=8CHTTPS=E7=9A=84?= =?UTF-8?q?SSL=E5=9F=BA=E6=9C=AC=E6=90=AD=E5=BB=BA=20-=20machine=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=A8=A1=E5=BC=8F=E5=8F=98=E5=8A=A8(=E9=99=8D?= =?UTF-8?q?=E4=BD=8E=E4=BA=BA=E5=B7=A5=E8=BE=93=E5=85=A5=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E9=87=8F=EF=BC=89=20#3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blueprints/machine_api.py | 34 +- migrations/2026-08_tls_enrollment.sql | 22 + models/machine.py | 10 +- repositories/containers_repo.py | 8 + repositories/machine_repo.py | 8 +- services/container_module/node_comms.py | 462 +++++++++++++++++- .../container/test_container_tasks_helpers.py | 3 +- utils/cert_utils.py | 160 ++++++ 8 files changed, 699 insertions(+), 8 deletions(-) create mode 100644 migrations/2026-08_tls_enrollment.sql create mode 100644 utils/cert_utils.py diff --git a/blueprints/machine_api.py b/blueprints/machine_api.py index 3f27f26..60468ff 100644 --- a/blueprints/machine_api.py +++ b/blueprints/machine_api.py @@ -1,6 +1,7 @@ from flask import jsonify, request from . import api_bp from ..services import machine_tasks as machine_service +from ..services.container_module import node_comms from ..repositories import user_repo, authentications_repo from ..schemas.user_schema import user_schema, users_schema from ..constant import PERMISSION @@ -83,7 +84,38 @@ def add_machine_api(): return jsonify({"success": 1, "message": "Machine created successfully"}), 201 else: return jsonify({"success": 0, "message": "Failed to create machine", "error_reason": "create_failed"}), 500 - + + +@api_bp.post("/machines/register_machine") +def register_machine_api(): + '''TOFU 接入机器:HTTPS 首连 → TLS 层取 Node 证书指纹 → 颁发 UID → 下发 → 落库双凭据。 + + 发送格式:{"machine_id": 1} + 返回格式:{"success": 1, "uid": "xxx", "certificate_fingerprint": "xxx"} + ''' + if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): + return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 + if (not user_repo.check_permission(request.cookies.get("auth_token", ""), required_permission=PERMISSION.OPERATOR)): + return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 + + data = request.get_json() or {} + try: + machine_id = int(data.get("machine_id", 0)) + except Exception: + return jsonify({"success": 0, "message": "machine_id must be an integer", "error_reason": "invalid_machine_id"}), 400 + if machine_id <= 0: + return jsonify({"success": 0, "message": "machine_id required", "error_reason": "invalid_machine_id"}), 400 + + try: + result = node_comms.register_machine(machine_id) + except Exception as e: + err_reason = getattr(e, 'error_reason', None) + if err_reason: + return jsonify({"success": 0, "message": str(e), "error_reason": err_reason}), 422 + return jsonify({"success": 0, "message": f"Internal error: {str(e)}", "error_reason": "internal_error"}), 500 + + return jsonify({"success": 1, "message": "Machine enrolled successfully", + "uid": result["uid"], "certificate_fingerprint": result["certificate_fingerprint"]}), 200 @api_bp.post("/machines/remove_machine") def remove_machine_api(): ''' diff --git a/migrations/2026-08_tls_enrollment.sql b/migrations/2026-08_tls_enrollment.sql new file mode 100644 index 0000000..7471869 --- /dev/null +++ b/migrations/2026-08_tls_enrollment.sql @@ -0,0 +1,22 @@ +-- ───────────────────────────────────────────────────────────── +-- 现网迁移:TOFU 接入凭据字段(TLS 方案,2026-08) +-- 对应 models/machine.py 新增三列;create_all 只影响新库,现网用本脚本。 +-- 双凭据:node_uid(应用层标识)+ node_cert_fingerprint(传输层凭证), +-- 均唯一、可独立吊销轮换;未接入(未首连)时为 NULL。 +-- ───────────────────────────────────────────────────────────── + +ALTER TABLE machines + ADD COLUMN node_uid VARCHAR(128) NULL COMMENT 'Ctrl 首连颁发的高熵 UID(应用层标识)', + ADD COLUMN node_cert_fingerprint VARCHAR(128) NULL COMMENT 'Node 自签证书 SHA-256 指纹(传输层凭证)', + ADD COLUMN cert_pinned_at DATETIME NULL COMMENT 'TOFU 首连 pin 时间'; + +ALTER TABLE machines + ADD UNIQUE INDEX uq_machines_node_uid (node_uid), + ADD UNIQUE INDEX uq_machines_node_cert_fingerprint (node_cert_fingerprint); + +-- 回滚: +-- ALTER TABLE machines DROP INDEX uq_machines_node_cert_fingerprint, +-- DROP INDEX uq_machines_node_uid; +-- ALTER TABLE machines DROP COLUMN cert_pinned_at, +-- DROP COLUMN node_cert_fingerprint, +-- DROP COLUMN node_uid; diff --git a/models/machine.py b/models/machine.py index 4126b1a..b9395f1 100644 --- a/models/machine.py +++ b/models/machine.py @@ -1,3 +1,4 @@ +from datetime import datetime from ..extensions import db from ..constant import * @@ -20,9 +21,16 @@ class Machine(db.Model): max_shared_gb: int = db.Column(db.Integer, nullable=True) disk_size_gb: int = db.Column(db.Integer, nullable=True) machine_description: str = db.Column(db.String(500), nullable=True) - max_memory_gb: int = db.Column(db.Integer, nullable=True) + max_memory_gb: int = db.Column(db.Integer, nullable=True) max_gpu_number: int = db.Column(db.Integer, nullable=True) max_cpu_core_number: int = db.Column(db.Integer, nullable=True) + # ── TOFU 接入凭据(TLS 方案,2026-08) ── + # uid:Ctrl 首连颁发的高熵 UID(应用层标识,可独立吊销轮换) + # node_cert_fingerprint:Node 自签证书 SHA-256 指纹(传输层凭证,Ctrl 从 TLS 层计算) + # 双凭据均唯一;未接入(未首连)时为 None + node_uid: str | None = db.Column(db.String(128), unique=True, nullable=True, index=True) + node_cert_fingerprint: str | None = db.Column(db.String(128), unique=True, nullable=True, index=True) + cert_pinned_at: datetime | None = db.Column(db.DateTime, nullable=True) # 与 Container 的一对多关系(containers 表里有 machine_id 外键) containers = db.relationship( "Container", back_populates="machine", cascade="all, delete-orphan" diff --git a/repositories/containers_repo.py b/repositories/containers_repo.py index 503c299..49ec805 100644 --- a/repositories/containers_repo.py +++ b/repositories/containers_repo.py @@ -18,6 +18,14 @@ def get_id_by_name_machine(container_name: str, machine_id: int) -> int | None: container = Container.query.filter_by(name=container_name, machine_id=machine_id).first() return container.id if container else None +def get_by_container_name(container_name: str) -> Container | None: + """按容器名查询(宿主机内唯一)。Node 侧快照以 name 为键,解析层按名归位。 + + 注意:不命名为 get_by_name —— user_repo 已有同名函数(按用户名), + container_tasks 等模块 `import *` 两处时会撞名覆盖。 + """ + return Container.query.filter_by(name=container_name).first() + def get_machine_id_by_container_id(container_id: int) -> int | None: container = get_by_id(container_id) return container.machine_id if container else None diff --git a/repositories/machine_repo.py b/repositories/machine_repo.py index 027d8f3..2c0fa5b 100644 --- a/repositories/machine_repo.py +++ b/repositories/machine_repo.py @@ -13,6 +13,11 @@ def get_id_by_ip(machine_ip:str): machine = Machine.query.filter_by(machine_ip=machine_ip).first() return machine.id if machine else None +def get_by_uid(uid: str): + """按 Ctrl 颁发的 UID 查机器(WSS 接收器身份归位用)。""" + return Machine.query.filter_by(node_uid=uid).first() + + def get_machine_ip_by_id(machine_id:int)->str: machine = get_by_id(machine_id) if not machine: @@ -102,7 +107,8 @@ def update_machine(machine_id: int, *, commit: bool = True, **fields) -> bool: allowed = {"machine_name", "machine_ip", "machine_type", "machine_status", "cpu_core_number", "memory_size_gb", "gpu_number", "gpu_type", "disk_size_gb", "machine_description", "shared_size_gb", "max_shared_gb", - "max_memory_gb", "max_gpu_number", "max_cpu_core_number"} + "max_memory_gb", "max_gpu_number", "max_cpu_core_number", + "node_uid", "node_cert_fingerprint", "cert_pinned_at"} dirty = False for k, v in fields.items(): if k not in allowed: diff --git a/services/container_module/node_comms.py b/services/container_module/node_comms.py index 4b1b86a..483fd21 100644 --- a/services/container_module/node_comms.py +++ b/services/container_module/node_comms.py @@ -1,35 +1,81 @@ import json import logging +import os +import secrets import time import base64 +import datetime +import ssl +from pathlib import Path import requests import traceback from ...config import CommsConfig -from ...repositories import machine_repo +from ...constant import ContainerStatus +from ...repositories import machine_repo, containers_repo +from ...repositories.container_ssh_login_repo import upsert_last_ssh_login_time from ..machine_tasks import is_machine_online_remote from ...utils.CheckKeys import signature, encryption from ...utils.parallel import parallel_node_calls from .exceptions import NodeServiceError +from .utils import _parse_last_ssh_time + +logger = logging.getLogger(__name__) #################################################### def get_full_url(machine_ip:str, endpoint:str)->str: - return f"http://{machine_ip}{CommsConfig.NODE_URL_MIDDLE}{endpoint}" + """Node URL 组装(TLS 方案:https;Node uvicorn 已挂 ssl)。""" + return f"https://{machine_ip}{CommsConfig.NODE_URL_MIDDLE}{endpoint}" #################################################### #发送指令到集群实体机 -def send(ciphertext:bytes,signature:bytes,mechine_ip:str, timeout:float=5.0)->dict: +# ── TLS pin 管理(TOFU 方案) ───────────────────────── +# Node 自签证书 pin 文件:首连时 Ctrl 从 TLS 层取对端证书导出为 PEM, +# 存 pinned_certs/{machine_ip}.pem,之后 send 以 verify=该文件做证书 pin。 +PINNED_CERTS_DIR = os.getenv("CTRL_PINNED_CERTS_DIR", str(Path(__file__).resolve().parents[2] / "pinned_certs")) + + +def _pin_file(machine_ip: str) -> Path: + return Path(PINNED_CERTS_DIR) / f"{machine_ip}.pem" + + +def _resolve_tls(machine_ip: str, cert=None, verify=None): + """解析 send 的 TLS 参数。 + + - cert 默认 Ctrl 客户端证书(cert_utils 已生成时) + - verify 默认对端 pin 文件;未接入(未 pin)时降级 verify=False(TOFU 过渡,警告) + """ + if cert is None: + from ...utils.cert_utils import ctrl_certificate_paths + paths = ctrl_certificate_paths() + if paths.cert_file.exists() and paths.key_file.exists(): + cert = (str(paths.cert_file), str(paths.key_file)) + if verify is None: + pin = _pin_file(machine_ip) + if pin.exists(): + verify = str(pin) + else: + logger.warning("send to %s: no pinned cert (machine not enrolled yet); TLS verify disabled", machine_ip) + verify = False + return cert, verify + + +def send(ciphertext:bytes,signature:bytes,mechine_ip:str, timeout:float=5.0, *, cert=None, verify=None)->dict: """ 发送 POST 并返回解析后的响应(优先 JSON),出现错误时返回包含 error 字段的 dict。 + + TLS:https + Ctrl 客户端证书(cert)+ 对端证书 pin(verify), + 显式传入 cert/verify 可覆盖默认(TOFU 首连时 verify=False)。 """ + cert, verify = _resolve_tls(mechine_ip, cert=cert, verify=verify) try: resp = requests.post(mechine_ip, json={ "message": base64.b64encode(ciphertext).decode('utf-8'), "signature": base64.b64encode(signature).decode('utf-8') - }, timeout=timeout) + }, timeout=timeout, cert=cert, verify=verify) # 尝试解析为 JSON(即使是 4xx/5xx,也优先解析 body 中的 JSON,以保留 Node 返回的 error_reason) try: @@ -124,3 +170,411 @@ def get_container_status(machine_ip: str, container_name: str, timeout: float = return {"error": str(last_exc) if last_exc is not None else "unknown error"} +#################################################### +# ══════════════ TOFU 接入(register_machine)═══════════════════ +# 流程(fuxi平台继续开发.md「Node 通信层 · 决策」): +# 管理员填 IP/name(信任锚)→ Ctrl HTTPS 首连 → TLS 层取对端证书指纹(唯一来源, +# Node 不回传指纹)→ 生成高熵 UID → /issue_uid 下发 → 导出对端证书为 pin 文件 → 落库双凭据。 +# Node 侧端点:/api/node_identity/enrollment_profile(GET)、/api/node_identity/issue_uid(POST)。 +# 这两个端点是明文 JSON(无信封),身份由 TLS 承担;操作指令通道(send)仍走信封。 + +def _fetch_peer_cert(machine_ip: str, timeout: float = 5.0) -> tuple[str, bytes]: + """TLS 层握手取对端 Node 证书 → (SHA-256 指纹, DER)。 + + 这是指纹的唯一来源(TOFU pin 依据):不验证对端(首连信任锚 = 人工填 IP), + 仅取证书本身。DER 后续导出为 pin 文件。 + """ + host, _, port_str = machine_ip.partition(":") + port = int(port_str) if port_str else CommsConfig.NODE_PORT + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with ctx.wrap_socket(ssl.create_connection((host, port), timeout=timeout), server_hostname=host) as sock: + der = sock.getpeercert(binary_form=True) + if not der: + raise NodeServiceError(f"peer cert not available for {machine_ip}", reason="peer_cert_unavailable") + from ...utils.cert_utils import der_cert_sha256_fingerprint + return der_cert_sha256_fingerprint(der), der + + +def register_machine(machine_id: int, timeout: float = 8.0) -> dict: + """TOFU 接入一台机器:首连 → TLS 层取指纹 → 颁发 UID → 下发 → pin → 落库。 + + 返回 {"success": True, "uid": str, "certificate_fingerprint": str}; + 失败抛 NodeServiceError(reason 区分阶段)。 + """ + from ...utils.cert_utils import ensure_ctrl_certificates, ctrl_certificate_paths, der_cert_to_pem + + machine = None + try: + machine = machine_repo.get_by_id(machine_id) + except Exception: + machine = None + if not machine: + raise NodeServiceError(f"register_machine failed: machine {machine_id} not found", reason="machine_not_found") + machine_ip = getattr(machine, 'machine_ip', None) + if not machine_ip: + raise NodeServiceError(f"register_machine failed: machine {machine_id} has no ip", reason="machine_no_ip") + + # Ctrl 证书先就绪(mTLS 客户端证书;Node 侧校验调用者用) + try: + ensure_ctrl_certificates() + paths = ctrl_certificate_paths() + client_cert = (str(paths.cert_file), str(paths.key_file)) + except Exception as e: + logger.warning("ctrl cert not ready during register_machine: %s", e) + client_cert = None + + # 1. TLS 层取对端证书指纹(唯一来源,Node 不回传) + try: + fingerprint, cert_der = _fetch_peer_cert(machine_ip, timeout=timeout) + except Exception as e: + raise NodeServiceError(f"register_machine failed: cannot reach {machine_ip} over TLS: {e}", + reason="machine_unreachable") from e + + # 2. 首连登记资料(只读身份状态,不返回指纹) + try: + profile_url = get_full_url(machine_ip, "/node_identity/enrollment_profile") + profile_resp = requests.get(profile_url, timeout=timeout, verify=False, cert=client_cert) + profile = profile_resp.json() + except Exception as e: + raise NodeServiceError(f"register_machine failed: enrollment_profile error from {machine_ip}: {e}", + reason="enrollment_failed") from e + if not isinstance(profile, dict): + raise NodeServiceError(f"register_machine failed: bad enrollment_profile from {machine_ip}", + reason="enrollment_failed") + + # 3. 生成高熵 UID 并下发 + uid = secrets.token_urlsafe(24) + try: + issue_url = get_full_url(machine_ip, "/node_identity/issue_uid") + issue_resp = requests.post(issue_url, json={"uid": uid}, timeout=timeout, verify=False, cert=client_cert) + issue = issue_resp.json() + except Exception as e: + raise NodeServiceError(f"register_machine failed: issue_uid error from {machine_ip}: {e}", + reason="issue_uid_failed") from e + if not (isinstance(issue, dict) and issue.get("success") == 1): + raise NodeServiceError(f"register_machine failed: issue_uid rejected by {machine_ip}: {issue}", + reason="issue_uid_rejected") + + # 4. 导出对端证书为 pin 文件(后续 send 以 verify=该文件做证书 pin) + try: + _pin_file(machine_ip).parent.mkdir(parents=True, exist_ok=True) + _pin_file(machine_ip).write_bytes(der_cert_to_pem(cert_der)) + except Exception as e: + logger.warning("register_machine: failed to persist pin file for %s: %s", machine_ip, e) + + # 5. 落库双凭据 + try: + machine_repo.update_machine( + machine_id, + node_uid=uid, + node_cert_fingerprint=fingerprint, + cert_pinned_at=datetime.datetime.utcnow(), + ) + except Exception as e: + raise NodeServiceError(f"register_machine failed: persist credentials for machine {machine_id}: {e}", + reason="persist_failed") from e + + logger.info("machine %s (%s) enrolled: uid=%s fingerprint=%s", + machine_id, machine_ip, uid, fingerprint) + return {"success": True, "uid": uid, "certificate_fingerprint": fingerprint} + + +#################################################### +# ══════════════ WSS 快照解析层 ═══════════════════ +# 传输无关:HTTP 回退探测与 WSS 推送走同一份解析函数。 +# Node 推送形状(wss.py build_snapshot_batch): +# {"type": "snapshot_batch", "node_uid", "certificate_fingerprint", +# "payload": [{"type": "snapshot", "topic": "container_status"|"last_ssh"|"disk_usage", "payload": ...}]} +# container_status: {name: {"source", "status", "error_reason"?, "cache_updated_at"?}} +# last_ssh: {name: {"last_ssh_connect_time", "updated_at"}} +# disk_usage: {"machine_disk": {...}, "containers": {name: {"overlay_rw_bytes", "bind_mount_bytes", "bind_mount_path", "total_bytes"}}} + +# Node 应用状态字符串 → Ctrl ContainerStatus 枚举;unknown/不可映射 → 跳过(保持 DB 旧值) +_NODE_STATUS_TO_CTRL = { + "online": ContainerStatus.ONLINE, + "offline": ContainerStatus.OFFLINE, + "creating": ContainerStatus.CREATING, + "starting": ContainerStatus.STARTING, + "stopping": ContainerStatus.STOPPING, + "paused": ContainerStatus.PAUSED, + "failed": ContainerStatus.FAILED, +} + + +def _container_by_name(name: str): + """快照按 name 归位到 DB 容器;未登记(Node 有、Ctrl 无)→ None,由 delete 事件语义处理。""" + try: + return containers_repo.get_by_container_name(name) + except Exception: + return None + + +def apply_container_status_snapshot(data: dict) -> dict: + """解析 container_status 快照 → 落库容器状态。返回 {"updated", "skipped"}。""" + updated = skipped = 0 + if not isinstance(data, dict): + return {"updated": updated, "skipped": skipped} + for name, entry in data.items(): + try: + ctrl_status = _NODE_STATUS_TO_CTRL.get(str((entry or {}).get("status", ""))) + if ctrl_status is None: + skipped += 1 + continue + container = _container_by_name(name) + if container is None: + skipped += 1 + continue + containers_repo.update_container(container.id, commit=False, + container_status=ctrl_status) + updated += 1 + except Exception as e: + logger.warning("apply status snapshot failed for %s: %s", name, e) + skipped += 1 + if updated: + try: + from ...extensions import db + db.session.commit() + except Exception as e: + logger.warning("commit status snapshot failed: %s", e) + return {"updated": updated, "skipped": skipped} + + +def apply_last_ssh_snapshot(data: dict) -> dict: + """解析 last_ssh 快照 → 落库。空值不覆写(保护初始创建时间,与现 getter 语义一致)。""" + updated = skipped = 0 + if not isinstance(data, dict): + return {"updated": updated, "skipped": skipped} + for name, entry in data.items(): + try: + last_time = (entry or {}).get("last_ssh_connect_time") + if not last_time: + skipped += 1 + continue + container = _container_by_name(name) + if container is None: + skipped += 1 + continue + parsed = _parse_last_ssh_time(str(last_time)) + if parsed is not None: + last_time = parsed.strftime('%Y-%m-%dT%H:%M:%S') + upsert_last_ssh_login_time( + machine_id=container.machine_id, + container_id=container.id, + last_ssh_login_time=last_time, + ) + updated += 1 + except Exception as e: + logger.warning("apply last_ssh snapshot failed for %s: %s", name, e) + skipped += 1 + return {"updated": updated, "skipped": skipped} + + +def apply_disk_usage_snapshot(data: dict) -> dict: + """解析 disk_usage 快照 → 落库 disk_* 字段(阈值评估/告警归 disk_check 调度,本层只存值)。""" + updated = skipped = 0 + if not isinstance(data, dict): + return {"updated": updated, "skipped": skipped} + containers = data.get("containers") or {} + for name, usage in containers.items(): + try: + if not isinstance(usage, dict): + skipped += 1 + continue + container = _container_by_name(name) + if container is None: + skipped += 1 + continue + containers_repo.update_container( + container.id, + commit=False, + disk_overlay_rw_bytes=usage.get("overlay_rw_bytes"), + disk_bind_mount_bytes=usage.get("bind_mount_bytes"), + disk_total_bytes=usage.get("total_bytes"), + bind_mount_path=usage.get("bind_mount_path"), + disk_checked_at=datetime.datetime.utcnow(), + ) + updated += 1 + except Exception as e: + logger.warning("apply disk snapshot failed for %s: %s", name, e) + skipped += 1 + if updated: + try: + from ...extensions import db + db.session.commit() + except Exception as e: + logger.warning("commit disk snapshot failed: %s", e) + return {"updated": updated, "skipped": skipped} + + +def apply_snapshot_batch(batch: dict) -> dict: + """解析 snapshot_batch 帧 → 按 topic 分发到三个 apply_*。返回按 topic 的统计。 + + HTTP 回退轮询与 WSS 推送共用本函数(传输无关)。 + """ + result = {} + if not isinstance(batch, dict): + return result + frames = batch.get("payload") or [] + for frame in frames: + if not isinstance(frame, dict) or frame.get("type") != "snapshot": + continue + topic = frame.get("topic") + data = frame.get("payload") + if topic == "container_status": + result[topic] = apply_container_status_snapshot(data) + elif topic == "last_ssh": + result[topic] = apply_last_ssh_snapshot(data) + elif topic == "disk_usage": + result[topic] = apply_disk_usage_snapshot(data) + else: + logger.warning("apply_snapshot_batch: unknown topic %r", topic) + return result + + +# ══════════════ WSS 断线回退 · 连通性探测 ═══════════════════ +# 文档约定:WSS 断开 → HTTP 回退只做连通性探测(container_status), +# 连续 attempts 次网络不达判宿主机离线;数据靠 WSS 重连后的全量快照补齐,不靠 HTTP 捞。 +CONNECTIVITY_PROBE_ATTEMPTS = 2 + + +def probe_machine_connectivity(machine_id: int, attempts: int = CONNECTIVITY_PROBE_ATTEMPTS) -> bool: + """WSS 断线回退:HTTP 探测宿主机连通性(只看通不通,不看内容)。 + + - 有容器 → 打任一容器的 /container_status;任何响应(含 404)都算通,仅网络级失败算不达 + - 无容器 → 退化打 /machine_status(等价 is_machine_online_remote 语义) + - 连续 attempts 次网络不达 → False(判宿主机离线,容器派生 offline 由展示层完成) + """ + try: + machine = machine_repo.get_by_id(machine_id) + except Exception: + machine = None + if not machine: + return False + machine_ip = getattr(machine, 'machine_ip', None) + if not machine_ip: + return False + + probe_name = None + try: + probe_containers = containers_repo.list_containers( + limit=1, offset=0, machine_id=machine_id) + if probe_containers: + probe_name = getattr(probe_containers[0], 'name', None) + except Exception: + pass + + fails = 0 + for _ in range(max(1, attempts)): + try: + if probe_name is not None: + res = get_container_status(machine_ip, probe_name, timeout=2.0) + # 任何响应(含 404:容器不存在但机器在)都算连通;仅网络级 error 算不达 + if isinstance(res, dict) and not res.get('error'): + return True + else: + if is_machine_online_remote(machine_id, timeout=2.0): + return True + fails += 1 + except Exception: + fails += 1 + logger.warning("probe_machine_connectivity: machine %s unreachable after %s attempts", machine_id, attempts) + return False + + +# ══════════════ WSS 接收层(FastAPI 形态) ═══════════════════ +# 挂载要求(Ctrl ASGI/FastAPI 落地时): +# 1. ssl context:certfile/keyfile = Ctrl 证书(ensure_ctrl_certificates), +# verify_mode=REQUIRED + ca_certs=rebuild_pinned_chain()(见下)—— +# TLS 层校验 Node 必须持有已 pin 的自签证书私钥(传输层凭证,双凭据之一) +# 2. 注册 @app.websocket("/ws/node") 指向本函数 +# 3. 断线 → 由挂载方调用 probe_machine_connectivity 回退探测(连续两次不达判宿主机离线) +# 应用层 session:落库需 Flask app context(apply_* 用 db.session),挂载方包一层 ctx。 + +def rebuild_pinned_chain() -> Path | None: + """重建 pin chain 文件:pinned_certs/*.pem 拼接为一个 bundle。 + + Node 证书自签(不走 Ctrl CA),自身即信任锚——把已 pin 的 Node 证书 + 直接作为 ca_certs 喂给 WSS 服务端 ssl context,握手时只有持有对应 + 私钥的 Node 能通过(等价证书指纹校验,且无 CA 需求)。 + 返回 bundle 路径;无任何 pin 文件时返回 None(此时不应开启 REQUIRED)。 + """ + pins_dir = Path(PINNED_CERTS_DIR) + if not pins_dir.exists(): + return None + pem_files = sorted(pins_dir.glob("*.pem")) + if not pem_files: + return None + bundle = pins_dir / "_chain_bundle.pem" + try: + contents = [p.read_bytes() for p in pem_files if p.name != "_chain_bundle.pem"] + bundle.write_bytes(b"\n".join(contents)) + except Exception as e: + logger.warning("rebuild_pinned_chain failed: %s", e) + return None + return bundle + + +async def handle_node_ws(websocket) -> None: + """Node → Ctrl `/ws/node` WebSocket 接收处理器。 + + 身份校验(双凭据): + 1. TLS 层:挂载方 ssl context(REQUIRED + ca_certs=rebuild_pinned_chain())—— + Node 必须持有已 pin 证书私钥才能完成握手(传输层凭证) + 2. 应用层:?uid= 查询参数 → machine_repo.get_by_uid 归位(应用层标识) + 帧分发:snapshot_batch → apply_snapshot_batch(缓冲批处理落库)。 + event / delete 帧:后续按 WSS 协议硬项扩展(容器消失感知、运行事件)。 + """ + from urllib.parse import parse_qs + + scope = getattr(websocket, "scope", {}) or {} + query = parse_qs(scope.get("query_string", b"").decode("utf-8", errors="ignore")) + uid = (query.get("uid") or [None])[0] + + if not uid: + await websocket.close(code=4401) # 无 UID:应用层凭证缺失 + return + + try: + machine = machine_repo.get_by_uid(uid) + except Exception as e: + logger.warning("handle_node_ws: get_by_uid failed: %s", e) + machine = None + + if machine is None: + logger.warning("handle_node_ws: rejected connection with unknown uid %r", uid) + await websocket.close(code=4403) # UID 未归位:拒绝 + return + + machine_ip = getattr(machine, 'machine_ip', '?') + logger.info("node WSS connected: uid=%s machine=%s ip=%s", uid, machine.id, machine_ip) + + try: + await websocket.accept() + except Exception as e: + logger.warning("handle_node_ws: accept failed for uid=%s: %s", uid, e) + return + + try: + while True: + frame = json.loads(await websocket.receive_text()) + if not isinstance(frame, dict): + logger.warning("handle_node_ws: non-dict frame from %s", uid) + continue + if frame.get("type") == "snapshot_batch": + # 落库需 Flask app context——挂载方(ASGI 桥接)负责包 ctx + apply_snapshot_batch(frame) + elif frame.get("type") in ("event", "delete"): + logger.info("handle_node_ws: frame type %r not yet handled (uid=%s)", frame.get("type"), uid) + else: + logger.warning("handle_node_ws: unknown frame type %r (uid=%s)", frame.get("type"), uid) + except Exception as e: + logger.info("handle_node_ws: connection closed for uid=%s: %s", uid, e) + finally: + try: + await websocket.close() + except Exception: + pass + + diff --git a/test/container/test_container_tasks_helpers.py b/test/container/test_container_tasks_helpers.py index 996b1f5..c6b3271 100644 --- a/test/container/test_container_tasks_helpers.py +++ b/test/container/test_container_tasks_helpers.py @@ -69,7 +69,8 @@ def test_build_cleanup_info_clamps_invalid_cleanup_days_to_one(): def test_get_full_url_uses_node_middle_path(): + # TLS 方案:Node uvicorn 已挂 ssl,URL 统一 https assert ( container_tasks.get_full_url("127.0.0.1", "/create_container") - == f"http://127.0.0.1{CommsConfig.NODE_URL_MIDDLE}/create_container" + == f"https://127.0.0.1{CommsConfig.NODE_URL_MIDDLE}/create_container" ) diff --git a/utils/cert_utils.py b/utils/cert_utils.py new file mode 100644 index 0000000..c0666f9 --- /dev/null +++ b/utils/cert_utils.py @@ -0,0 +1,160 @@ +# utils/cert_utils.py(Ctrl 侧) +"""Ctrl TLS 证书管理:自签 CA + Ctrl 服务端证书。 + +TOFU 方案中 Ctrl 是「单证书双角色」: +- WSS 服务器证书(Node→Ctrl WSS 连接,Node 校验 Ctrl) +- HTTPS mTLS 客户端证书(Ctrl→Node 操作通道,Node 校验调用者) + +Node 侧信任 Ctrl 的方式:保存本模块生成的 Ctrl CA 证书(或 Ctrl 证书指纹), +由部署时人工拷贝到 Node(`NODE_CTRL_CA_FILE`),不在线上流转。 + +证书默认放在 CtrKernel/certs 下,可用环境变量覆盖路径。 +""" +import datetime +import ipaddress +import logging +import os +from dataclasses import dataclass +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +logger = logging.getLogger(__name__) + +# 默认证书目录:本文件上两级的 CtrKernel/certs +_DEFAULT_CERTS_DIR = Path(__file__).resolve().parents[1] / "certs" + + +@dataclass(frozen=True) +class CtrlCertificateFiles: + ca_cert: Path + ca_key: Path + cert_file: Path + key_file: Path + + +def _certs_dir() -> Path: + return Path(os.getenv("CTRL_CERTS_DIR", str(_DEFAULT_CERTS_DIR))) + + +def _certificate_files() -> CtrlCertificateFiles: + d = _certs_dir() + return CtrlCertificateFiles( + ca_cert=Path(os.getenv("CTRL_CA_CERT_FILE", str(d / "ctrl_ca.pem"))), + ca_key=Path(os.getenv("CTRL_CA_KEY_FILE", str(d / "ctrl_ca_key.pem"))), + cert_file=Path(os.getenv("CTRL_CERT_FILE", str(d / "ctrl_cert.pem"))), + key_file=Path(os.getenv("CTRL_KEY_FILE", str(d / "ctrl_key.pem"))), + ) + + +def _generate_self_signed_ca(ca_cert: Path, ca_key: Path, common_name: str) -> None: + """生成自签 CA(仅本地私有信任域,用于签发 Ctrl 证书)。""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=int(os.getenv("CTRL_CA_VALID_DAYS", "3650")))) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension(x509.KeyUsage( + digital_signature=True, content_commitment=False, key_encipherment=False, + data_encipherment=False, key_agreement=False, key_cert_sign=True, + crl_sign=True, encipher_only=False, decipher_only=False, + ), critical=True) + .sign(private_key, hashes.SHA256()) + ) + ca_key.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + ca_cert.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + +def _issue_ctrl_cert(ca_cert: Path, ca_key: Path, cert_file: Path, key_file: Path, common_name: str) -> None: + """用 CA 签发 Ctrl 服务端证书(serverAuth + clientAuth,双角色一张证书)。""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ca_cert_obj = x509.load_pem_x509_certificate(ca_cert.read_bytes()) + ca_key_obj = serialization.load_pem_private_key(ca_key.read_bytes(), password=None) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert_obj.subject) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=int(os.getenv("CTRL_CERT_VALID_DAYS", "3650")))) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.ExtendedKeyUsage( + [x509.oid.ExtendedKeyUsageOID.SERVER_AUTH, x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH] + ), + critical=False, + ) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .sign(ca_key_obj, hashes.SHA256()) + ) + key_file.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + +def ensure_ctrl_certificates() -> CtrlCertificateFiles: + """确保 Ctrl 有一套 CA + 服务端证书(缺失即生成,幂等)。""" + files = _certificate_files() + if files.cert_file.exists() and files.key_file.exists(): + return files + + files.ca_cert.parent.mkdir(parents=True, exist_ok=True) + if not (files.ca_cert.exists() and files.ca_key.exists()): + _generate_self_signed_ca(files.ca_cert, files.ca_key, os.getenv("CTRL_CA_COMMON_NAME", "FuxiYu Ctrl CA")) + logger.info("generated Ctrl CA: %s", files.ca_cert) + _issue_ctrl_cert( + files.ca_cert, files.ca_key, files.cert_file, files.key_file, + os.getenv("CTRL_CERT_COMMON_NAME", "FuxiYu Ctrl Server"), + ) + logger.info("generated Ctrl server cert: %s", files.cert_file) + return files + + +def ctrl_certificate_paths() -> CtrlCertificateFiles: + """返回 Ctrl 证书文件路径(不触发生成;缺失时由部署流程先调用 ensure_*)。""" + return _certificate_files() + + +def certificate_sha256_fingerprint(cert_file: Path) -> str: + """计算证书文件的 SHA-256 指纹(十六进制小写冒号分隔)。""" + cert = x509.load_pem_x509_certificate(cert_file.read_bytes()) + return der_cert_sha256_fingerprint(cert.public_bytes(serialization.Encoding.DER)) + + +def der_cert_sha256_fingerprint(cert_der: bytes) -> str: + """计算 DER 证书的 SHA-256 指纹(十六进制小写冒号分隔)。TOFU pin 依据。""" + digest = hashes.Hash(hashes.SHA256()) + digest.update(cert_der) + return digest.finalize().hex() + + +def der_cert_to_pem(cert_der: bytes) -> bytes: + """DER 证书 → PEM(导出对端证书为 pin 文件用)。""" + return x509.load_der_x509_certificate(cert_der).public_bytes(serialization.Encoding.PEM) From e520b865d3a82b925943a43fd7ecf2bda12284e8 Mon Sep 17 00:00:00 2001 From: chester Date: Wed, 19 Aug 2026 16:30:48 +0800 Subject: [PATCH 04/63] =?UTF-8?q?-=20PROGRESS=20blueprints=20->=20api?= =?UTF-8?q?=E3=80=81FastAPI=20=E5=A4=96=E5=A3=B3=E3=80=81machine=20router?= =?UTF-8?q?=E3=80=81schema=20#4=20-=20NEW=20register=5Fmachine=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E5=BC=95=E5=85=A5=EF=BC=9Badd=5Fmachine=E7=9A=84?= =?UTF-8?q?=E9=80=80=E5=BD=B9=E8=A2=AB=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 114 ++++++--- api/__init__.py | 32 +++ {blueprints => api}/announcement_api.py | 0 {blueprints => api}/container_api.py | 0 api/deps.py | 41 +++ api/machine_api.py | 274 ++++++++++++++++++++ {blueprints => api}/operation_log_api.py | 0 {blueprints => api}/user_api.py | 0 blueprints/__init__.py | 15 -- blueprints/machine_api.py | 308 ----------------------- requirements.txt | 4 +- run.py | 37 +-- run_wss.py | 71 ++++++ schemas/__init__.py | 69 +++++ schemas/common.py | 43 ++++ schemas/machine.py | 264 +++++++++++++++++++ services/container_module/node_comms.py | 162 ++++++++++-- 17 files changed, 1043 insertions(+), 391 deletions(-) create mode 100644 api/__init__.py rename {blueprints => api}/announcement_api.py (100%) rename {blueprints => api}/container_api.py (100%) create mode 100644 api/deps.py create mode 100644 api/machine_api.py rename {blueprints => api}/operation_log_api.py (100%) rename {blueprints => api}/user_api.py (100%) delete mode 100644 blueprints/__init__.py delete mode 100644 blueprints/machine_api.py create mode 100644 run_wss.py create mode 100644 schemas/common.py create mode 100644 schemas/machine.py diff --git a/__init__.py b/__init__.py index fa8ed11..f3bef4c 100644 --- a/__init__.py +++ b/__init__.py @@ -1,55 +1,111 @@ -# yourapp/__init__.py +from contextlib import asynccontextmanager from pathlib import Path +import warnings + from dotenv import load_dotenv _DOTENV_PATH = Path(__file__).resolve().parent / ".env" load_dotenv(_DOTENV_PATH, override=True) -import os +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from flask import Flask from flask_cors import CORS + +warnings.filterwarnings("ignore", message="starlette.middleware.wsgi is deprecated.*") +from starlette.middleware.wsgi import WSGIMiddleware + +from .api import register_api, register_legacy_api +from .config import build_allowed_origins, get_config from .extensions import db -from .config import get_config, build_allowed_origins -from .blueprints import register_blueprints -from .schedulers.container_ssh_refresh_task import start_container_ssh_refresh_scheduler from .schedulers.container_cleanup_task import start_container_cleanup_scheduler from .schedulers.container_mount_cleanup_task import start_mount_cleanup_scheduler +from .schedulers.container_ssh_refresh_task import start_container_ssh_refresh_scheduler from .utils.logging_config import configure_daily_logging -def create_app(config: str | None = None, overrides: dict | None = None): +def _create_flask_runtime_app( + config: str | None = None, + overrides: dict | None = None, + *, + register_legacy_routes: bool = True, +) -> Flask: + """创建迁移期 Flask runtime。 + + FastAPI 端点通过它提供 Flask-SQLAlchemy app context;尚未迁移的 API + 也继续由它通过 WSGI middleware 承接。 + """ + if not overrides: load_dotenv(_DOTENV_PATH, override=True) - app = Flask(__name__) - app.config.from_object(get_config(config)) + + flask_app = Flask(__name__) + flask_app.config.from_object(get_config(config)) if overrides: - app.config.update(overrides) - configure_daily_logging(app) - # Configure CORS for API routes. 统一由 build_allowed_origins() 生成: - # 只枚举 https 变体 + WEB_IP/127.0.0.1/localhost 三种写法,尾斜杠归一化。 - # When credentials are used, do NOT set origins to * — specify exact origins. + flask_app.config.update(overrides) + + configure_daily_logging(flask_app) origins = build_allowed_origins() - CORS(app, supports_credentials=True, resources={r"/api/*": {"origins": origins}}) + CORS(flask_app, supports_credentials=True, resources={r"/api/*": {"origins": origins}}) - db.init_app(app) - with app.app_context(): + db.init_app(flask_app) + with flask_app.app_context(): from . import models + db.create_all() + if register_legacy_routes: + register_legacy_api(flask_app) + + return flask_app + + +def _should_start_background_tasks(flask_app: Flask) -> bool: + """判断是否启动 Ctrl 后台任务。""" + + return not flask_app.config.get("TESTING") and not flask_app.config.get("DISABLE_BACKGROUND_TASKS") + + +def _start_background_tasks(flask_app: Flask) -> None: + """启动 Ctrl 后台任务。 + + 任务内部仍按 Flask app context 编写;FastAPI lifespan 只负责启动位置迁移。 + """ + + start_container_ssh_refresh_scheduler(flask_app, interval_seconds=300) + start_container_cleanup_scheduler(flask_app, interval_seconds=1200) + start_mount_cleanup_scheduler(flask_app) + + +def create_app(config: str | None = None, overrides: dict | None = None) -> FastAPI: + """创建 Ctrl FastAPI 应用。 + + 当前是增量迁移形态:FastAPI 承接已迁移 API,未迁移 API 通过 legacy + Flask WSGI app 兜底;service/repository 暂不改。 + """ + + flask_app = _create_flask_runtime_app(config, overrides, register_legacy_routes=True) + + @asynccontextmanager + async def lifespan(_: FastAPI): + if _should_start_background_tasks(flask_app): + _start_background_tasks(flask_app) + yield + + app = FastAPI(title="FuxiYu CtrlKernel API", lifespan=lifespan) + app.state.flask_app = flask_app + app.state.db = db - register_blueprints(app) + app.add_middleware( + CORSMiddleware, + allow_origins=build_allowed_origins(), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) - # 启动“每5分钟刷新容器上次 SSH 登录时间”的后台任务。 - # Flask debug 模式下父进程和子进程都会执行 create_app,这里仅在 reloader 子进程启动任务,避免重复线程。 - if ( - not app.config.get("TESTING") - and not app.config.get("DISABLE_BACKGROUND_TASKS") - and ((not app.debug) or os.environ.get("WERKZEUG_RUN_MAIN") == "true") - ): - start_container_ssh_refresh_scheduler(app, interval_seconds=300) - # 启动容器定时清理任务(每20分钟扫描一次到期容器并释放) - start_container_cleanup_scheduler(app, interval_seconds=1200) - # 启动已删除容器 mount 清理任务(每天一次) - start_mount_cleanup_scheduler(app) + register_api(app) + # 未迁移 API 兜底。必须最后挂载,让 FastAPI 已迁移路由优先匹配。 + app.mount("/", WSGIMiddleware(flask_app)) return app diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..c9f8353 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter +from flask import Blueprint + +router = APIRouter(prefix="/api") +api_bp = Blueprint("api", __name__, url_prefix="/api") + +# 已迁移到 FastAPI 的路由 +from . import machine_api + +router.include_router(machine_api.router) + +# 尚未迁移的 Flask API 模块,继续挂在 legacy Blueprint 上。 +from . import user_api +from . import container_api +from . import announcement_api +from . import operation_log_api + + +def register_api(app): + """注册 Ctrl FastAPI 路由。""" + + app.include_router(router) + + +def register_legacy_api(app): + """注册尚未迁移的 Flask Blueprint 路由。""" + + app.register_blueprint(api_bp) + + +# 兼容旧调用名,后续整体切完 FastAPI 时再清。 +register_blueprints = register_legacy_api diff --git a/blueprints/announcement_api.py b/api/announcement_api.py similarity index 100% rename from blueprints/announcement_api.py rename to api/announcement_api.py diff --git a/blueprints/container_api.py b/api/container_api.py similarity index 100% rename from blueprints/container_api.py rename to api/container_api.py diff --git a/api/deps.py b/api/deps.py new file mode 100644 index 0000000..555c791 --- /dev/null +++ b/api/deps.py @@ -0,0 +1,41 @@ +from fastapi import Cookie, Depends, HTTPException, Request + +from ..constant import PERMISSION +from ..repositories import authentications_repo, user_repo + + +def auth_token_from_cookie(auth_token: str = Cookie(default="")) -> str: + """读取 Ctrl 现有 opaque token cookie。""" + + return auth_token or "" + + +def require_current_user( + request: Request, + auth_token: str = Depends(auth_token_from_cookie), +) -> int: + """校验登录态并返回 user_id。""" + + with request.app.state.flask_app.app_context(): + if not authentications_repo.is_token_valid(auth_token): + raise HTTPException( + status_code=401, + detail={"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}, + ) + return authentications_repo.get_user_id_by_token(auth_token) + + +def require_operator( + request: Request, + user_id: int = Depends(require_current_user), + auth_token: str = Depends(auth_token_from_cookie), +) -> int: + """校验 operator 权限并返回 user_id。""" + + with request.app.state.flask_app.app_context(): + if not user_repo.check_permission(auth_token, required_permission=PERMISSION.OPERATOR): + raise HTTPException( + status_code=403, + detail={"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}, + ) + return user_id diff --git a/api/machine_api.py b/api/machine_api.py new file mode 100644 index 0000000..9cd46f2 --- /dev/null +++ b/api/machine_api.py @@ -0,0 +1,274 @@ +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError + +from ..schemas.machine import ( + AddMachinePermissionRequest, + AddMachinePermissionResponse, + AddMachineRequest, + AddMachineResponse, + ListMachineBriefRequest, + ListMachineBriefResponse, + ListMachinePermissionsResponse, + MachineDetailResponse, + MachineIdRequest, + RegisterMachineByTrustAnchorRequest, + RegisterMachineWithProfileResponse, + RemoveMachineRequest, + RemoveMachineResponse, + UpdateMachineRequest, + UpdateMachineResponse, +) +from ..services import machine_tasks as machine_service +from ..services.container_module import node_comms +from .deps import require_current_user, require_operator + +router = APIRouter(prefix="/machines", tags=["machines"]) + + +def _model_data(model, *, exclude_none: bool = False) -> dict[str, Any]: + """兼容 Pydantic v1/v2 的模型转 dict。""" + + if hasattr(model, "model_dump"): + return model.model_dump(exclude_none=exclude_none) + return model.dict(exclude_none=exclude_none) + + +def _error(status_code: int, message: str, error_reason: str | None = None) -> JSONResponse: + """返回 Ctrl 现有错误结构。""" + + payload: dict[str, Any] = {"success": 0, "message": message} + if error_reason is not None: + payload["error_reason"] = error_reason + return JSONResponse(status_code=status_code, content=payload) + + +##################### +# 添加机器 + + +@router.post("/add_machine", response_model=AddMachineResponse) +def add_machine_api( + request: Request, + message: AddMachineRequest, + operator_user_id: int = Depends(require_operator), +): + """人工添加机器。 + + 迁移期兼容入口;后续主建档流程会收敛到 register_machine。 + """ + + data = _model_data(message) + try: + with request.app.state.flask_app.app_context(): + success = machine_service.Add_machine( + machine_name=data.get("machine_name", ""), + machine_ip=data.get("machine_ip", ""), + machine_type=data.get("machine_type", ""), + machine_description=data.get("machine_description", ""), + cpu_core_number=data.get("cpu_core_number", 0), + gpu_number=data.get("gpu_number", 0), + gpu_type=data.get("gpu_type", ""), + memory_size=data.get("memory_size", 0), + max_shared_gb=data.get("max_shared_gb", 2), + disk_size=data.get("disk_size", 0), + max_memory_gb=data.get("max_memory_gb", 0), + max_gpu_number=data.get("max_gpu_number", 0), + max_cpu_core_number=data.get("max_cpu_core_number", 0), + operator_user_id=operator_user_id, + ) + except IntegrityError as e: + detail = str(e.orig) if hasattr(e, "orig") else str(e) + return _error(409, f"Duplicate entry: {detail}", "duplicate_entry") + except Exception as e: + err_reason = getattr(e, "error_reason", None) + if err_reason: + return _error(422, str(e), err_reason) + return _error(500, f"Internal error: {e}", "internal_error") + + if success: + return {"success": 1, "message": "Machine created successfully"} + return _error(500, "Failed to create machine", "create_failed") + + +##################### +# 注册机器 + + +@router.post("/register_machine", response_model=RegisterMachineWithProfileResponse) +def register_machine_api( + request: Request, + message: RegisterMachineByTrustAnchorRequest, + _: int = Depends(require_operator), +): + """TOFU 建档一体接入:管理员填最小信任锚(name/ip)→ 首连完成 TLS pin + UID 下发 → + Node 返回硬件 → 建档(默认分配比例)。之后用 update_machine 调整分配限制。""" + + try: + with request.app.state.flask_app.app_context(): + result = node_comms.register_machine(message.machine_name, message.machine_ip) + except Exception as e: + err_reason = getattr(e, "error_reason", None) + if err_reason: + return _error(422, str(e), err_reason) + return _error(500, f"Internal error: {e}", "internal_error") + + return { + "success": 1, + "message": "Machine enrolled successfully", + "uid": result["uid"], + "certificate_fingerprint": result["certificate_fingerprint"], + "machine_id": result["machine_id"], + "hardware": result.get("hardware"), + } + + +##################### +# 删除机器 + + +@router.post("/remove_machine", response_model=RemoveMachineResponse) +def remove_machine_api( + request: Request, + message: RemoveMachineRequest, + operator_user_id: int = Depends(require_operator), +): + """删除一组机器记录。""" + + with request.app.state.flask_app.app_context(): + success = machine_service.Remove_machine( + machine_id=message.machine_ids, + operator_user_id=operator_user_id, + ) + if success: + return {"success": 1, "message": "Machine(s) removed successfully"} + return _error(500, "Failed to remove machine(s)", "remove_failed") + + +##################### +# 更新机器 + + +@router.post("/update_machine", response_model=UpdateMachineResponse) +def update_machine_api( + request: Request, + message: UpdateMachineRequest, + operator_user_id: int = Depends(require_operator), +): + """更新机器管理字段或资源分配限制。""" + + fields = _model_data(message.fields, exclude_none=True) + try: + with request.app.state.flask_app.app_context(): + success = machine_service.Update_machine( + machine_id=message.machine_id, + operator_user_id=operator_user_id, + **fields, + ) + except Exception as e: + err_reason = getattr(e, "error_reason", None) + if err_reason: + return _error(422, str(e), err_reason) + return _error(500, f"Internal error: {e}", "internal_error") + + if success: + return {"success": 1, "message": "Machine updated successfully"} + return _error(500, "Failed to update machine", "update_failed") + + +##################### +# 查询机器详情 + + +@router.post("/get_detail_information", response_model=MachineDetailResponse) +def get_detail_information_api( + request: Request, + message: MachineIdRequest, + _: int = Depends(require_current_user), +): + """查询机器详情。""" + + with request.app.state.flask_app.app_context(): + machine_info = machine_service.Get_detail_information(machine_id=message.machine_id) + if not machine_info: + return _error(404, "Machine not found", "machine_not_found") + return _model_data(machine_info) + + +##################### +# 查询机器概要列表 + + +@router.post("/list_all_machine_bref_information", response_model=ListMachineBriefResponse) +def list_all_machine_bref_information_api( + request: Request, + message: ListMachineBriefRequest, + user_id: int = Depends(require_current_user), +): + """分页查询机器概要。""" + + with request.app.state.flask_app.app_context(): + machines_info, total_pages = machine_service.List_all_machine_bref_information( + page_number=message.page_number, + page_size=message.page_size, + user_id=user_id, + ) + machines = [] + for machine in machines_info: + machine_type = machine.machine_type.value if hasattr(machine.machine_type, "value") else machine.machine_type + machine_status = machine.machine_status.value if hasattr(machine.machine_status, "value") else machine.machine_status + machines.append( + { + "machine_id": getattr(machine, "id", None), + "machine_name": machine.machine_name, + "machine_ip": machine.machine_ip, + "machine_type": machine_type, + "machine_status": machine_status, + } + ) + return {"machines": machines, "total_pages": total_pages} + + +##################### +# 添加机器权限 + + +@router.post("/add_machine_permission", response_model=AddMachinePermissionResponse) +def add_machine_permission_api( + request: Request, + message: AddMachinePermissionRequest, + operator_user_id: int = Depends(require_operator), +): + """给用户添加机器权限。""" + + try: + with request.app.state.flask_app.app_context(): + machine_service.Add_machine_permission( + message.machine_id, + message.user_id, + operator_user_id=operator_user_id, + ) + except ValueError as e: + reason = str(e) + status = 404 if reason in ("machine_not_found", "user_not_found") else 400 + return _error(status, reason, reason) + return {"success": 1, "message": "machine permission added"} + + +##################### +# 查询机器权限 + + +@router.get("/list_machine_permissions", response_model=ListMachinePermissionsResponse) +def list_machine_permissions_api( + request: Request, + machine_id: int = Query(..., ge=1), + _: int = Depends(require_current_user), +): + """查询机器授权用户 id 列表。""" + + with request.app.state.flask_app.app_context(): + user_ids = machine_service.List_machine_permissions(machine_id) + return {"success": 1, "machine_id": machine_id, "user_ids": user_ids} diff --git a/blueprints/operation_log_api.py b/api/operation_log_api.py similarity index 100% rename from blueprints/operation_log_api.py rename to api/operation_log_api.py diff --git a/blueprints/user_api.py b/api/user_api.py similarity index 100% rename from blueprints/user_api.py rename to api/user_api.py diff --git a/blueprints/__init__.py b/blueprints/__init__.py deleted file mode 100644 index 79ae18b..0000000 --- a/blueprints/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import Blueprint - -api_bp = Blueprint("api", __name__, url_prefix="/api") - -# 导入各个 API 模块以注册路由 -from . import user_api -from . import machine_api -from . import container_api -from . import announcement_api -from . import operation_log_api - - -def register_blueprints(app): - app.register_blueprint(api_bp) - diff --git a/blueprints/machine_api.py b/blueprints/machine_api.py deleted file mode 100644 index 60468ff..0000000 --- a/blueprints/machine_api.py +++ /dev/null @@ -1,308 +0,0 @@ -from flask import jsonify, request -from . import api_bp -from ..services import machine_tasks as machine_service -from ..services.container_module import node_comms -from ..repositories import user_repo, authentications_repo -from ..schemas.user_schema import user_schema, users_schema -from ..constant import PERMISSION - - -from sqlalchemy.exc import IntegrityError - - -@api_bp.post("/machines/add_machine") -def add_machine_api(): - ''' - 通信数据格式: - 发送格式: - { - - "machine_name", - "machine_ip", - "machine_type", - "machine_description", - "cpu_core_number", - "gpu_number", - "gpu_type", - "memory_size", - "max_shared_gb", - "max_memory_gb", - "max_gpu_number", - "max_cpu_core_number", - "disk_size" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - if (not user_repo.check_permission(request.cookies.get("auth_token", ""), required_permission=PERMISSION.OPERATOR)): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - data = request.get_json() or {} - machine_name = data.get("machine_name", "") - machine_ip = data.get("machine_ip", "") - machine_type = data.get("machine_type", "") - machine_description = data.get("machine_description", "") - cpu_core_number = data.get("cpu_core_number", 0) - gpu_number = data.get("gpu_number", 0) - gpu_type = data.get("gpu_type", "") - memory_size = data.get("memory_size", 0) - max_shared_gb = data.get("max_shared_gb", 2) # 默认值为2GB - max_memory_gb = data.get("max_memory_gb", 0) - max_gpu_number = data.get("max_gpu_number", 0) - max_cpu_core_number = data.get("max_cpu_core_number", 0) - disk_size = data.get("disk_size", 0) - try: # 仅仅是防御性质的措施 - success = machine_service.Add_machine(machine_name=machine_name, - machine_ip=machine_ip, - machine_type=machine_type, - machine_description=machine_description, - cpu_core_number=cpu_core_number, - gpu_number=gpu_number, - gpu_type=gpu_type, - memory_size=memory_size, - max_shared_gb=max_shared_gb, - disk_size=disk_size, - max_memory_gb=max_memory_gb, - max_gpu_number=max_gpu_number, - max_cpu_core_number=max_cpu_core_number, - operator_user_id=authentications_repo.get_user_id_by_token(request.cookies.get("auth_token", ""))) - except IntegrityError as ie: - # likely duplicate unique constraint (e.g. machine_name) - return jsonify({"success": 0, "message": f"Duplicate entry: {str(ie.orig) if hasattr(ie, 'orig') else str(ie)}", "error_reason": "duplicate_entry"}), 409 - except Exception as e: - err_reason = getattr(e, 'error_reason', None) - if err_reason: - return jsonify({"success": 0, "message": str(e), "error_reason": err_reason}), 422 - return jsonify({"success": 0, "message": f"Internal error: {str(e)}", "error_reason": "internal_error"}), 500 - - if success: - return jsonify({"success": 1, "message": "Machine created successfully"}), 201 - else: - return jsonify({"success": 0, "message": "Failed to create machine", "error_reason": "create_failed"}), 500 - - -@api_bp.post("/machines/register_machine") -def register_machine_api(): - '''TOFU 接入机器:HTTPS 首连 → TLS 层取 Node 证书指纹 → 颁发 UID → 下发 → 落库双凭据。 - - 发送格式:{"machine_id": 1} - 返回格式:{"success": 1, "uid": "xxx", "certificate_fingerprint": "xxx"} - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - if (not user_repo.check_permission(request.cookies.get("auth_token", ""), required_permission=PERMISSION.OPERATOR)): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - - data = request.get_json() or {} - try: - machine_id = int(data.get("machine_id", 0)) - except Exception: - return jsonify({"success": 0, "message": "machine_id must be an integer", "error_reason": "invalid_machine_id"}), 400 - if machine_id <= 0: - return jsonify({"success": 0, "message": "machine_id required", "error_reason": "invalid_machine_id"}), 400 - - try: - result = node_comms.register_machine(machine_id) - except Exception as e: - err_reason = getattr(e, 'error_reason', None) - if err_reason: - return jsonify({"success": 0, "message": str(e), "error_reason": err_reason}), 422 - return jsonify({"success": 0, "message": f"Internal error: {str(e)}", "error_reason": "internal_error"}), 500 - - return jsonify({"success": 1, "message": "Machine enrolled successfully", - "uid": result["uid"], "certificate_fingerprint": result["certificate_fingerprint"]}), 200 -@api_bp.post("/machines/remove_machine") -def remove_machine_api(): - ''' - 发送格式: - { - "machine_ids", - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - if (not user_repo.check_permission(request.cookies.get("auth_token", ""), required_permission=PERMISSION.OPERATOR)): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - data = request.get_json() or {} - data = request.get_json() or {} - machine_ids = data.get("machine_ids", []) - success = machine_service.Remove_machine(machine_id=machine_ids, - operator_user_id=authentications_repo.get_user_id_by_token(request.cookies.get("auth_token", ""))) - if success: - return jsonify({"success": 1, "message": "Machine(s) removed successfully"}), 200 - else: - return jsonify({"success": 0, "message": "Failed to remove machine(s)", "error_reason": "remove_failed"}), 500 - -@api_bp.post("/machines/update_machine") -def update_machine_api(): - ''' - allowed = {"machine_name", "machine_ip", "machine_type", "machine_status", "cpu_core_number", - "memory_size", "gpu_number", "gpu_type", "disk_size", "machine_description", "max_shared_gb", "max_memory_gb", "max_gpu_number", "max_cpu_core_number"} - - 通信数据格式: - 发送格式: - { - "machine_id", - "machine_name", - "machine_ip", - "machine_type", - "machine_status", - "cpu_core_number", - "gpu_number", - "gpu_type", - "memory_size", - "disk_size", - "max_shared_gb", - "max_memory_gb", - "max_gpu_number", - "max_cpu_core_number", - "machine_description" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - if (not user_repo.check_permission(request.cookies.get("auth_token", ""), required_permission=PERMISSION.OPERATOR)): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - data = request.get_json() or {} - machine_id = data.get("machine_id", 0) - fields = data.get("fields", {}) - try: - success = machine_service.Update_machine(machine_id=machine_id, - operator_user_id=authentications_repo.get_user_id_by_token(request.cookies.get("auth_token", "")), - **fields) - except Exception as e: - err_reason = getattr(e, 'error_reason', None) - if err_reason: - return jsonify({"success": 0, "message": str(e), "error_reason": err_reason}), 422 - return jsonify({"success": 0, "message": f"Internal error: {str(e)}", "error_reason": "internal_error"}), 500 - - if success: - return jsonify({"success": 1, "message": "Machine updated successfully"}), 200 - else: - return jsonify({"success": 0, "message": "Failed to update machine", "error_reason": "update_failed"}), 500 - - -@api_bp.post("/machines/get_detail_information") -def get_detail_information_api(): - ''' - 通信数据格式: - 发送格式: - { - "machine_id", - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - machine_id = data.get("machine_id", 0) - machine_info = machine_service.Get_detail_information(machine_id=machine_id) - if machine_info: - return jsonify({ - "machine_name": machine_info.machine_name, - "machine_ip": machine_info.machine_ip, - "machine_type": machine_info.machine_type, - "machine_description": machine_info.machine_description, - "cpu_core_number": machine_info.cpu_core_number, - "gpu_number": machine_info.gpu_number, - "gpu_type": machine_info.gpu_type, - "memory_size_gb": machine_info.memory_size_gb, - "max_shared_gb": machine_info.max_shared_gb, - "max_memory_gb": machine_info.max_memory_gb, - "max_gpu_number": machine_info.max_gpu_number, - "max_cpu_core_number": machine_info.max_cpu_core_number, - "disk_size_gb": machine_info.disk_size_gb, - "containers": machine_info.containers - }), 200 - else: - return jsonify({"success": 0, "message": "Machine not found", "error_reason": "machine_not_found"}), 404 - -@api_bp.post("/machines/list_all_machine_bref_information") -def list_all_machine_bref_information_api(): - ''' - 通信数据格式: - 发送格式: - { - "page_number", - "page_size" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json(silent=True) or {} - page_number = int(data.get("page_number", 0)) - page_size = int(data.get("page_size", 10)) - user_id = authentications_repo.get_user_id_by_token(token) - machines_info, total_pages = machine_service.List_all_machine_bref_information(page_number=page_number, page_size=page_size, user_id=user_id) - machines_list = [] - for machine in machines_info: - machines_list.append({ - "machine_id": getattr(machine, 'id', None), - "machine_name": machine.machine_name, - "machine_ip": machine.machine_ip, - "machine_type": machine.machine_type, - "machine_status": machine.machine_status - }) - return jsonify({"machines": machines_list, "total_pages": total_pages}), 200 - -@api_bp.post("/machines/add_machine_permission") -def add_machine_permission_api(): - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - if (not user_repo.check_permission(token, required_permission=PERMISSION.OPERATOR)): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - data = request.get_json(silent=True) or {} - machine_id = int(data.get("machine_id") or 0) - user_id = int(data.get("user_id") or 0) - if not machine_id or not user_id: - return jsonify({"success": 0, "message": "machine_id and user_id required", "error_reason": "missing_fields"}), 400 - try: - machine_service.Add_machine_permission(machine_id, user_id, - operator_user_id=authentications_repo.get_user_id_by_token(token)) - except ValueError as e: - reason = str(e) - status = 404 if reason in ("machine_not_found", "user_not_found") else 400 - return jsonify({"success": 0, "message": reason, "error_reason": reason}), status - return jsonify({"success": 1, "message": "machine permission added"}), 200 - - -@api_bp.get("/machines/list_machine_permissions") -def list_machine_permissions_api(): - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - machine_id = request.args.get("machine_id", type=int) or 0 - if not machine_id: - return jsonify({"success": 0, "message": "machine_id required", "error_reason": "missing_fields"}), 400 - user_ids = machine_service.List_machine_permissions(machine_id) - return jsonify({"success": 1, "machine_id": machine_id, "user_ids": user_ids}), 200 diff --git a/requirements.txt b/requirements.txt index b2cf666..d7e1e20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ Flask>=2.3.0 Flask-SQLAlchemy>=3.0.0 SQLAlchemy>=2.0.0 marshmallow>=3.19.0 +fastapi +uvicorn pymysql pydantic Flask-Cors>=3.0.10 @@ -10,4 +12,4 @@ dotenv cryptography pytest>=8.0.0 pytest-cov>=5.0.0 -alembic>=1.13.0 \ No newline at end of file +alembic>=1.13.0 diff --git a/run.py b/run.py index 1c81d8d..6387b76 100644 --- a/run.py +++ b/run.py @@ -2,32 +2,35 @@ import sys from importlib import import_module +import uvicorn + pkg_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.dirname(pkg_dir) if parent_dir not in sys.path: - sys.path.insert(0, parent_dir) + sys.path.insert(0, parent_dir) package_name = os.path.basename(pkg_dir) try: - create_app = import_module(package_name).create_app + create_app = import_module(package_name).create_app except Exception: - create_app = import_module('__init__').create_app + create_app = import_module("__init__").create_app app = create_app() if __name__ == "__main__": - # Use SSL when configured (development only). If cert/key files exist, use them; - # otherwise fall back to Flask's adhoc cert for quick local testing. - ssl_enabled = app.config.get("SSL_ENABLED", False) - if ssl_enabled: - cert_path = app.config.get("SSL_CERT_PATH") - key_path = app.config.get("SSL_KEY_PATH") - if cert_path and key_path and os.path.exists(cert_path) and os.path.exists(key_path): - ssl_ctx = (cert_path, key_path) - else: - # fallback to an auto-generated certificate (not for production) - ssl_ctx = 'adhoc' - app.run(host="0.0.0.0", port=5000, debug=True, ssl_context=ssl_ctx, threaded=True) - else: - app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) + flask_app = app.state.flask_app + ssl_enabled = flask_app.config.get("SSL_ENABLED", False) + cert_path = flask_app.config.get("SSL_CERT_PATH") + key_path = flask_app.config.get("SSL_KEY_PATH") + + ssl_kwargs = {} + if ssl_enabled and cert_path and key_path and os.path.exists(cert_path) and os.path.exists(key_path): + ssl_kwargs = {"ssl_certfile": cert_path, "ssl_keyfile": key_path} + uvicorn.run( + app, + host="0.0.0.0", + port=int(os.getenv("CTRL_PORT", "5000")), + reload=False, + **ssl_kwargs, + ) diff --git a/run_wss.py b/run_wss.py new file mode 100644 index 0000000..607db68 --- /dev/null +++ b/run_wss.py @@ -0,0 +1,71 @@ +"""Ctrl WSS 接收服务(旁挂 uvicorn 实例)。 + +Node → Ctrl `/ws/node` 接收端点,独立端口运行,TLS **客户端证书校验(REQUIRED)**: +- 主 API uvicorn(CTRL_PORT,默认 5000)不能开 REQUIRED——浏览器前端不带客户端证书; +- 本实例只服务 /ws/node,Node 必须持有已 pin 的自签证书私钥才能握手(传输层凭据)。 +- 应用层再校验 ?uid= 归位 machine 记录(双凭据)。 + +落库需 Flask app context:旁挂实例桥接进 Ctrl 的 Flask runtime(repositories 零改动)。 +""" +import os +import ssl + +from fastapi import FastAPI + +WSS_PORT = int(os.getenv("CTRL_WSS_PORT", "5001")) + + +def _flask_runtime(overrides: dict | None = None): + """延迟获取 Ctrl Flask runtime(避免模块级循环 import)。 + + *overrides* 透传给 runtime(测试注入 SQLite 等);生产旁挂不传 → 真实配置。 + """ + from FuxiYu_CtrKernel import _create_flask_runtime_app + return _create_flask_runtime_app(None, overrides, register_legacy_routes=False) + + +def _build_ssl_context(): + """WSS ssl context:Ctrl 证书 + REQUIRED + ca_certs=pin chain(Node 自签即信任锚)。""" + from FuxiYu_CtrKernel.utils.cert_utils import ctrl_certificate_paths, ensure_ctrl_certificates + from FuxiYu_CtrKernel.services.container_module.node_comms import rebuild_pinned_chain + + ensure_ctrl_certificates() + paths = ctrl_certificate_paths() + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(str(paths.cert_file), str(paths.key_file)) + chain = rebuild_pinned_chain() + if chain is None: + import logging + logging.getLogger(__name__).warning( + "no pinned Node certs yet — REQUIRED disabled; Node must enroll before secure WSS") + else: + ctx.load_verify_locations(str(chain)) + ctx.verify_mode = ssl.CERT_REQUIRED + return ctx + + +def create_wss_app(overrides: dict | None = None) -> FastAPI: + """创建 WSS 接收应用(挂 /ws/node → node_comms.handle_node_ws)。 + + *overrides* 透传给 Flask runtime(测试注入 SQLite 等)。 + """ + app = FastAPI(title="FuxiYu CtrlKernel WSS Receiver") + flask_app = _flask_runtime(overrides) + + from FuxiYu_CtrKernel.services.container_module.node_comms import handle_node_ws + + @app.websocket("/ws/node") + async def ws_node(websocket): + # apply_* 落库用 db.session —— 长连接持有 Flask app context 桥接 + with flask_app.app_context(): + await handle_node_ws(websocket) + + return app + + +if __name__ == "__main__": + import uvicorn + + app = create_wss_app() + ctx = _build_ssl_context() + uvicorn.run(app, host="0.0.0.0", port=WSS_PORT, ssl=ctx) diff --git a/schemas/__init__.py b/schemas/__init__.py index e69de29..afe20fd 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -0,0 +1,69 @@ +from .common import ( + ApiErrorResponse, + EmptyObject, + FreeFormObject, + IdRequest, + PageRequest, + SuccessMessageResponse, +) +from .machine import ( + AddMachinePermissionRequest, + AddMachinePermissionResponse, + AddMachineRequest, + AddMachineResponse, + ListMachineBriefRequest, + ListMachineBriefResponse, + ListMachinePermissionsResponse, + MachineAllocationLimit, + MachineBriefItem, + MachineDetailResponse, + MachineIdRequest, + MachineRuntimeSnapshot, + MachineStatus, + MachineType, + MachineUpdateFields, + NodeHardwareProfile, + RegisterMachineByTrustAnchorRequest, + RegisterMachineRequest, + RegisterMachineResponse, + RegisterMachineWithProfileResponse, + RemoveMachineRequest, + RemoveMachineResponse, + SysSnapshotMessage, + UpdateMachineRequest, + UpdateMachineResponse, +) + +__all__ = [ + "AddMachinePermissionRequest", + "AddMachinePermissionResponse", + "AddMachineRequest", + "AddMachineResponse", + "ApiErrorResponse", + "EmptyObject", + "FreeFormObject", + "IdRequest", + "ListMachineBriefRequest", + "ListMachineBriefResponse", + "ListMachinePermissionsResponse", + "MachineAllocationLimit", + "MachineBriefItem", + "MachineDetailResponse", + "MachineIdRequest", + "MachineRuntimeSnapshot", + "MachineStatus", + "MachineType", + "MachineUpdateFields", + "NodeHardwareProfile", + "PageRequest", + "RegisterMachineByTrustAnchorRequest", + "RegisterMachineRequest", + "RegisterMachineResponse", + "RegisterMachineWithProfileResponse", + "RemoveMachineRequest", + "RemoveMachineResponse", + "SuccessMessageResponse", + "SysSnapshotMessage", + "UpdateMachineRequest", + "UpdateMachineResponse", +] diff --git a/schemas/common.py b/schemas/common.py new file mode 100644 index 0000000..5be01e4 --- /dev/null +++ b/schemas/common.py @@ -0,0 +1,43 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class ApiErrorResponse(BaseModel): + """统一错误响应,用于 FastAPI Swagger responses。""" + + success: int | bool = 0 + message: str + error_reason: str | None = None + + +class SuccessMessageResponse(BaseModel): + """只表达操作是否成功和提示文案的通用响应。""" + + success: int | bool = 1 + message: str + + +class PageRequest(BaseModel): + """分页请求;当前 Ctrl 约定 page_number 从 0 开始。""" + + page_number: int = Field(default=0, ge=0) + page_size: int = Field(default=10, ge=1) + + +class IdRequest(BaseModel): + """单个数据库对象 id 请求。""" + + id: int = Field(..., ge=1) + + +class EmptyObject(BaseModel): + """占位空对象,避免 Swagger 显示为任意 JSON。""" + + pass + + +class FreeFormObject(BaseModel): + """少数迁移期字段还未稳定时使用的自由对象。""" + + value: dict[str, Any] = Field(default_factory=dict) diff --git a/schemas/machine.py b/schemas/machine.py new file mode 100644 index 0000000..ab363e1 --- /dev/null +++ b/schemas/machine.py @@ -0,0 +1,264 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from .common import PageRequest, SuccessMessageResponse + + +MachineType = Literal["GPU", "CPU"] +MachineStatus = Literal["online", "offline", "maintenance"] + + +##################### +# 机器硬件信息 + + +class NodeHardwareProfile(BaseModel): + """Node 首连或 sys_snapshot 上报的宿主机硬件信息。""" + + cpu_core_number: int = Field(default=0, ge=0) + gpu_number: int = Field(default=0, ge=0) + gpu_type: str | None = None + memory_size_gb: int = Field(default=0, ge=0) + disk_size_gb: int = Field(default=0, ge=0) + + +class MachineAllocationLimit(BaseModel): + """Ctrl 对机器可分配资源的管理上限。""" + + max_shared_gb: int = Field(default=2, ge=0) + max_cpu_core_number: int = Field(default=0, ge=0) + max_gpu_number: int = Field(default=0, ge=0) + max_memory_gb: int = Field(default=0, ge=0) + + +##################### +# 添加机器(迁移期兼容入口) + + +class AddMachineRequest(MachineAllocationLimit): + """人工添加机器。 + + 后续 register_machine 会成为主建档入口;该请求保留给预登记/兼容流程。 + """ + + machine_name: str + machine_ip: str + machine_type: MachineType + machine_description: str = "" + cpu_core_number: int = Field(default=0, ge=0) + gpu_number: int = Field(default=0, ge=0) + gpu_type: str | None = None + memory_size: int = Field(default=0, ge=0) + disk_size: int = Field(default=0, ge=0) + + +class AddMachineResponse(SuccessMessageResponse): + """添加机器响应。""" + + pass + + +##################### +# 注册机器(TOFU 接入) + + +class RegisterMachineRequest(BaseModel): + """当前实现:对已有 machine_id 执行 TLS pin + UID 下发。""" + + machine_id: int = Field(..., ge=1) + + +class RegisterMachineByTrustAnchorRequest(BaseModel): + """后续目标:管理员只填最小信任锚,由注册流程完成建档。""" + + machine_name: str + machine_ip: str + machine_description: str = "" + + +class RegisterMachineResponse(SuccessMessageResponse): + """TOFU 注册成功响应。""" + + uid: str + certificate_fingerprint: str + + +class RegisterMachineWithProfileResponse(RegisterMachineResponse): + """TOFU 建档一体响应:建档后返回 machine_id 与 Node 上报的硬件快照。""" + + machine_id: int + hardware: dict | None = None + + +##################### +# 删除机器 + + +class RemoveMachineRequest(BaseModel): + machine_ids: list[int] = Field(default_factory=list) + + +class RemoveMachineResponse(SuccessMessageResponse): + pass + + +##################### +# 更新机器 + + +class MachineUpdateFields(BaseModel): + """机器可更新字段。 + + 真实硬件字段后续主要由 Node 上报;管理员主要调整资源分配限制和管理字段。 + """ + + machine_name: str | None = None + machine_ip: str | None = None + machine_type: MachineType | None = None + machine_status: MachineStatus | None = None + machine_description: str | None = None + cpu_core_number: int | None = Field(default=None, ge=0) + gpu_number: int | None = Field(default=None, ge=0) + gpu_type: str | None = None + memory_size: int | None = Field(default=None, ge=0) + disk_size: int | None = Field(default=None, ge=0) + max_shared_gb: int | None = Field(default=None, ge=0) + max_memory_gb: int | None = Field(default=None, ge=0) + max_gpu_number: int | None = Field(default=None, ge=0) + max_cpu_core_number: int | None = Field(default=None, ge=0) + + +class UpdateMachineRequest(BaseModel): + machine_id: int = Field(..., ge=1) + fields: MachineUpdateFields = Field(default_factory=MachineUpdateFields) + + +class UpdateMachineResponse(SuccessMessageResponse): + pass + + +##################### +# 查询机器详情 + + +class MachineIdRequest(BaseModel): + machine_id: int = Field(..., ge=1) + + +class MachineDetailResponse(NodeHardwareProfile, MachineAllocationLimit): + machine_name: str + machine_ip: str + machine_type: MachineType + machine_status: MachineStatus + machine_description: str | None = None + containers: list[int] = Field(default_factory=list) + + +##################### +# 查询机器概要列表 + + +class ListMachineBriefRequest(PageRequest): + pass + + +class MachineBriefItem(BaseModel): + machine_id: int + machine_name: str + machine_ip: str + machine_type: MachineType + machine_status: MachineStatus + + +class ListMachineBriefResponse(BaseModel): + machines: list[MachineBriefItem] + total_pages: int + + +##################### +# 机器权限 + + +class AddMachinePermissionRequest(BaseModel): + machine_id: int = Field(..., ge=1) + user_id: int = Field(..., ge=1) + + +class AddMachinePermissionResponse(SuccessMessageResponse): + pass + + +class ListMachinePermissionsResponse(BaseModel): + success: int | bool = 1 + machine_id: int + user_ids: list[int] + + +##################### +# WSS sys_snapshot + + +class MachineRuntimeSnapshot(BaseModel): + """Node WSS sys_snapshot payload 里的动态系统状态。""" + + usage_percent: float | None = Field(default=None, ge=0) + + +class SysSnapshotCpu(BaseModel): + """sys_snapshot.payload.cpu。""" + + cores: int = Field(default=0, ge=0) + physical_cores: int | None = Field(default=None, ge=0) + usage_percent: float | None = Field(default=None, ge=0) + + +class SysSnapshotMemory(BaseModel): + """sys_snapshot.payload.memory。""" + + total_gb: float | None = Field(default=None, ge=0) + used_gb: float | None = Field(default=None, ge=0) + available_gb: float | None = Field(default=None, ge=0) + usage_percent: float | None = Field(default=None, ge=0) + + +class SysSnapshotGpu(BaseModel): + """sys_snapshot.payload.gpu[],vendor-aware 便于后续支持 AMD/Intel。""" + + vendor: str + index: int | None = Field(default=None, ge=0) + name: str | None = None + memory_gb: float | None = Field(default=None, ge=0) + + +class SysSnapshotDisk(BaseModel): + """sys_snapshot.payload.disk。""" + + total_gb: float | None = Field(default=None, ge=0) + memory_used_gb: float | None = Field(default=None, ge=0) + used_gb: float | None = Field(default=None, ge=0) + free_gb: float | None = Field(default=None, ge=0) + percent: float | None = Field(default=None, ge=0) + + +class SysSnapshotPayload(BaseModel): + """Node 推送的 sys_snapshot 业务 payload。""" + + hostname: str | None = None + platform: str | None = None + cpu: SysSnapshotCpu + memory: SysSnapshotMemory = Field(default_factory=SysSnapshotMemory) + gpu: list[SysSnapshotGpu | dict[str, Any]] = Field(default_factory=list) + disk: SysSnapshotDisk | dict[str, Any] = Field(default_factory=dict) + collected_at: str | None = None + + +class SysSnapshotMessage(BaseModel): + """Node -> Ctrl 的 sys_snapshot 帧。 + + 实际外层由 snapshot_batch 携带 node_uid;单帧保持 type/topic/payload 结构。 + """ + + type: Literal["snapshot"] = "snapshot" + topic: Literal["sys_snapshot"] = "sys_snapshot" + payload: SysSnapshotPayload | dict[str, Any] diff --git a/services/container_module/node_comms.py b/services/container_module/node_comms.py index 483fd21..46ba0f6 100644 --- a/services/container_module/node_comms.py +++ b/services/container_module/node_comms.py @@ -197,24 +197,49 @@ def _fetch_peer_cert(machine_ip: str, timeout: float = 5.0) -> tuple[str, bytes] return der_cert_sha256_fingerprint(der), der -def register_machine(machine_id: int, timeout: float = 8.0) -> dict: - """TOFU 接入一台机器:首连 → TLS 层取指纹 → 颁发 UID → 下发 → pin → 落库。 +# 默认资源分配比例(占 Node 上报硬件的比例):管理员随后用 update_machine 调整 +DEFAULT_RESOURCE_RATIO = float(os.getenv("CTRL_DEFAULT_RESOURCE_RATIO", "0.5")) - 返回 {"success": True, "uid": str, "certificate_fingerprint": str}; + +def _default_resource_limits(hardware: dict) -> dict: + """按默认比例策略从 Node 上报硬件生成资源分配限制(建档用)。 + + 真实硬件(cpu/memory/disk/gpu)取 Node 上报值;max_* 分配限制按比例折算, + 管理员通过 update_machine 调整。hardware 为 None/空时返回空 dict。 + """ + hw = hardware or {} + cpu_cores = int((hw.get("cpu") or {}).get("cores") or 0) + mem_gb = int((hw.get("memory") or {}).get("total_gb") or 0) + disk_gb = int((hw.get("disk") or {}).get("total_gb") or 0) + gpus = hw.get("gpu") or [] + ratio = DEFAULT_RESOURCE_RATIO + limits = { + "cpu_core_number": cpu_cores, + "max_cpu_core_number": max(1, int(cpu_cores * ratio)), + "memory_size_gb": mem_gb, + "max_memory_gb": max(1, int(mem_gb * ratio)), + "disk_size_gb": disk_gb, + "gpu_number": len(gpus), + "max_gpu_number": len(gpus), + "gpu_type": (gpus[0].get("name", "") if gpus else ""), + } + return limits + + +def register_machine(machine_name: str, machine_ip: str, timeout: float = 8.0) -> dict: + """TOFU 建档一体接入(机器建档主入口):信任锚(name/ip)→ TLS 首连 → 指纹 → + 硬件上报 → UID 下发 → 建档(默认分配比例)→ 落库双凭据。 + + 入参是管理员手填的最小信任锚;机器记录由本流程创建(add_machine 不再必须)。 + 返回 {"success": True, "uid", "certificate_fingerprint", "machine_id", "hardware"}; 失败抛 NodeServiceError(reason 区分阶段)。 """ + from ...constant import MachineTypes from ...utils.cert_utils import ensure_ctrl_certificates, ctrl_certificate_paths, der_cert_to_pem - machine = None - try: - machine = machine_repo.get_by_id(machine_id) - except Exception: - machine = None - if not machine: - raise NodeServiceError(f"register_machine failed: machine {machine_id} not found", reason="machine_not_found") - machine_ip = getattr(machine, 'machine_ip', None) - if not machine_ip: - raise NodeServiceError(f"register_machine failed: machine {machine_id} has no ip", reason="machine_no_ip") + if not machine_name or not machine_ip: + raise NodeServiceError("register_machine failed: machine_name and machine_ip are required", + reason="invalid_trust_anchor") # Ctrl 证书先就绪(mTLS 客户端证书;Node 侧校验调用者用) try: @@ -232,7 +257,7 @@ def register_machine(machine_id: int, timeout: float = 8.0) -> dict: raise NodeServiceError(f"register_machine failed: cannot reach {machine_ip} over TLS: {e}", reason="machine_unreachable") from e - # 2. 首连登记资料(只读身份状态,不返回指纹) + # 2. 首连登记资料(身份状态 + 静态硬件,不返回指纹) try: profile_url = get_full_url(machine_ip, "/node_identity/enrollment_profile") profile_resp = requests.get(profile_url, timeout=timeout, verify=False, cert=client_cert) @@ -243,6 +268,7 @@ def register_machine(machine_id: int, timeout: float = 8.0) -> dict: if not isinstance(profile, dict): raise NodeServiceError(f"register_machine failed: bad enrollment_profile from {machine_ip}", reason="enrollment_failed") + hardware = profile.get("hardware") if isinstance(profile.get("hardware"), dict) else {} # 3. 生成高熵 UID 并下发 uid = secrets.token_urlsafe(24) @@ -264,21 +290,50 @@ def register_machine(machine_id: int, timeout: float = 8.0) -> dict: except Exception as e: logger.warning("register_machine: failed to persist pin file for %s: %s", machine_ip, e) - # 5. 落库双凭据 + # 5. 建档(硬件 + 默认分配策略) + limits = _default_resource_limits(hardware) + machine_type = MachineTypes.GPU if limits["gpu_number"] > 0 else MachineTypes.CPU + try: + machine = machine_repo.create_machine( + machinename=machine_name, + machine_ip=machine_ip, + machine_type=machine_type, + machine_description=f"enrolled via TOFU register ({machine_ip})", + cpu_core_number=limits["cpu_core_number"], + gpu_number=limits["gpu_number"], + gpu_type=limits["gpu_type"], + memory_size=limits["memory_size_gb"], + max_shared_gb=2, + disk_size=limits["disk_size_gb"], + max_cpu_core_number=limits["max_cpu_core_number"], + max_gpu_number=limits["max_gpu_number"], + max_memory_gb=limits["max_memory_gb"], + ) + except Exception as e: + raise NodeServiceError(f"register_machine failed: create machine record: {e}", + reason="create_machine_failed") from e + + # 6. 落库双凭据 try: machine_repo.update_machine( - machine_id, + machine.id, node_uid=uid, node_cert_fingerprint=fingerprint, cert_pinned_at=datetime.datetime.utcnow(), ) except Exception as e: - raise NodeServiceError(f"register_machine failed: persist credentials for machine {machine_id}: {e}", + raise NodeServiceError(f"register_machine failed: persist credentials for machine {machine.id}: {e}", reason="persist_failed") from e - logger.info("machine %s (%s) enrolled: uid=%s fingerprint=%s", - machine_id, machine_ip, uid, fingerprint) - return {"success": True, "uid": uid, "certificate_fingerprint": fingerprint} + logger.info("machine %s (%s) enrolled: id=%s uid=%s fingerprint=%s hardware=%s", + machine_name, machine_ip, machine.id, uid, fingerprint, hardware) + return { + "success": True, + "uid": uid, + "certificate_fingerprint": fingerprint, + "machine_id": machine.id, + "hardware": hardware, + } #################################################### @@ -408,14 +463,77 @@ def apply_disk_usage_snapshot(data: dict) -> dict: return {"updated": updated, "skipped": skipped} +def apply_sys_snapshot(data: dict, machine_id: int | None = None) -> dict: + """解析 sys_snapshot 帧:静态硬件漂移检测 + 动态指标记录。 + + *machine_id* 由调用方从帧的 node_uid 归位(apply_snapshot_batch 提供)。 + - 静态(cpu.cores/memory.total_gb/disk.total_gb/gpu): + 比对 machines 表建档硬件,不一致 → warning(漂移检测,凭据之外的硬件变动感知) + - 动态(cpu.usage_percent/memory.used_gb/disk.used_gb): + 记录日志(管理面板展示/告警评估的落库待后续) + 返回 {"checked", "drifted"}。 + """ + checked = drifted = 0 + if not isinstance(data, dict) or machine_id is None: + return {"checked": checked, "drifted": drifted} + + machine = None + try: + machine = machine_repo.get_by_id(machine_id) + except Exception: + machine = None + if machine is None: + logger.debug("apply_sys_snapshot: machine %s not found (deleted?)", machine_id) + return {"checked": checked, "drifted": drifted} + + checked += 1 + cpu = (data.get("cpu") or {}).get("cores") + mem = (data.get("memory") or {}).get("total_gb") + disk = (data.get("disk") or {}).get("total_gb") + gpus = data.get("gpu") or [] + + drift = {} + if cpu is not None and int(cpu) != (machine.cpu_core_number or 0): + drift["cpu_core_number"] = f"{machine.cpu_core_number} -> {int(cpu)}" + if mem is not None and int(mem) != (machine.memory_size_gb or 0): + drift["memory_size_gb"] = f"{machine.memory_size_gb} -> {int(mem)}" + if disk is not None and int(disk) != (machine.disk_size_gb or 0): + drift["disk_size_gb"] = f"{machine.disk_size_gb} -> {int(disk)}" + if len(gpus) != (machine.gpu_number or 0): + drift["gpu_number"] = f"{machine.gpu_number} -> {len(gpus)}" + + if drift: + drifted += 1 + logger.warning("apply_sys_snapshot: HARDWARE DRIFT on machine %s (%s): %s", + machine.id, data.get("hostname"), drift) + + logger.info("apply_sys_snapshot: machine %s (%s) cpu=%s%% mem=%s%% disk=%s%%", + machine.id, data.get("hostname"), + (data.get("cpu") or {}).get("usage_percent"), + (data.get("memory") or {}).get("usage_percent"), + (data.get("disk") or {}).get("percent")) + return {"checked": checked, "drifted": drifted} + + def apply_snapshot_batch(batch: dict) -> dict: - """解析 snapshot_batch 帧 → 按 topic 分发到三个 apply_*。返回按 topic 的统计。 + """解析 snapshot_batch 帧 → 按 topic 分发到各 apply_*。返回按 topic 的统计。 HTTP 回退轮询与 WSS 推送共用本函数(传输无关)。 """ result = {} if not isinstance(batch, dict): return result + + # sys_snapshot 归位上下文:帧带 node_uid(WSS 协议);未归位(uid 不在 machine 表)→ None + machine_id = None + node_uid = batch.get("node_uid") + if node_uid: + try: + machine = machine_repo.get_by_uid(node_uid) + machine_id = machine.id if machine else None + except Exception: + machine_id = None + frames = batch.get("payload") or [] for frame in frames: if not isinstance(frame, dict) or frame.get("type") != "snapshot": @@ -428,6 +546,8 @@ def apply_snapshot_batch(batch: dict) -> dict: result[topic] = apply_last_ssh_snapshot(data) elif topic == "disk_usage": result[topic] = apply_disk_usage_snapshot(data) + elif topic == "sys_snapshot": + result[topic] = apply_sys_snapshot(data, machine_id) else: logger.warning("apply_snapshot_batch: unknown topic %r", topic) return result From 67ed6714d73e54a8bfaf1704506ab104e44edd8a Mon Sep 17 00:00:00 2001 From: chester Date: Wed, 19 Aug 2026 16:54:52 +0800 Subject: [PATCH 05/63] =?UTF-8?q?-=20PROGRESS=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9A=82=E5=AD=98|=20#5=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=E5=B7=B2=E5=AE=8C=E6=88=90API=E8=BF=81=E7=A7=BB(machine?= =?UTF-8?q?=20/=20operation=5Flog=20/=20user)=20-=20=E5=B7=B2=E5=AE=8C?= =?UTF-8?q?=E6=88=90check=5Fkeys=20=E9=80=80=E5=BD=B9=20-=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9F=BA=E5=BB=BA=E5=B7=B2=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/__init__.py | 6 +- api/operation_log_api.py | 119 ++- api/user_api.py | 717 +++++++----------- api_doc.py | 249 ------ private_A.pem | 28 - public_A.pem | 9 - schedulers/container_disk_check_task.py | 7 +- schedulers/container_mount_cleanup_task.py | 14 +- schemas/__init__.py | 50 ++ schemas/operation_log.py | 47 ++ schemas/user.py | 100 +++ services/container_module/node_comms.py | 65 +- services/container_tasks.py | 74 +- test/conftest.py | 13 +- test/container/conftest.py | 21 +- test/container/test_container_common.py | 13 +- .../test_container_disk_check_task.py | 20 +- .../test_container_mount_cleanup_task.py | 18 +- .../test_container_tasks_collaborators.py | 8 +- .../test_container_tasks_lifecycle.py | 20 +- test/container/test_container_tasks_ssh.py | 8 +- .../test_ctrl_user_machine_container_flow.py | 1 - test/link/conftest.py | 35 +- test/link/test_link_roundtrip.py | 53 +- test/link/transport.py | 13 +- test/mocks.py | 14 - utils/CheckKeys.py | 171 ----- utils/heartbeat.py | 22 +- 28 files changed, 699 insertions(+), 1216 deletions(-) delete mode 100644 api_doc.py delete mode 100644 private_A.pem delete mode 100644 public_A.pem create mode 100644 schemas/operation_log.py create mode 100644 schemas/user.py delete mode 100644 utils/CheckKeys.py diff --git a/api/__init__.py b/api/__init__.py index c9f8353..1411ee3 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -6,14 +6,16 @@ # 已迁移到 FastAPI 的路由 from . import machine_api +from . import operation_log_api +from . import user_api router.include_router(machine_api.router) +router.include_router(operation_log_api.router) +router.include_router(user_api.router) # 尚未迁移的 Flask API 模块,继续挂在 legacy Blueprint 上。 -from . import user_api from . import container_api from . import announcement_api -from . import operation_log_api def register_api(app): diff --git a/api/operation_log_api.py b/api/operation_log_api.py index fb0b793..24d7339 100644 --- a/api/operation_log_api.py +++ b/api/operation_log_api.py @@ -1,81 +1,78 @@ -from flask import request, jsonify +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse -from . import api_bp -from ..repositories import authentications_repo, user_repo +from ..schemas.operation_log import OperationLogListResponse, OperationLogStatsResponse from ..services import operation_log_tasks -from ..utils.parsers import parse_bool -from ..constant import PERMISSION +from .deps import require_operator +router = APIRouter(prefix="/admin/operation_logs", tags=["operation_logs"]) -def _require_operator(): - """鉴权失败时返回 (response, status),成功返回 None。""" - token = request.cookies.get("auth_token", "") - if not authentications_repo.is_token_valid(token): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - if not user_repo.check_permission(token, required_permission=PERMISSION.OPERATOR): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - return None +def _error(status_code: int, message: str, error_reason: str) -> JSONResponse: + """返回 Ctrl 现有错误结构。""" -def _int_or_none(name): - raw = request.args.get(name) - if raw is None or raw == "": - return None - try: - return int(raw) - except Exception: - return None + return JSONResponse( + status_code=status_code, + content={"success": 0, "message": message, "error_reason": error_reason}, + ) -@api_bp.get("/admin/operation_logs") -def list_operation_logs_api(): - """操作日志查询(operator-only)。 +@router.get("", response_model=OperationLogListResponse) +def list_operation_logs_api( + request: Request, + _: int = Depends(require_operator), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1), + operator_user_id: int | None = None, + operation: str | None = None, + target_type: str | None = None, + success: bool | None = None, + start: str | None = None, + end: str | None = None, + tz_offset_minutes: int | None = None, +): + """操作日志查询。 - query 参数:page / page_size / operation / target_type / - operator_user_id / success(true|false) / start / end / - tz_offset_minutes(分钟;start/end 按前端本地时间原样传, - 由后端按该偏移解析成库内 naive UTC 口径) + start/end 按前端本地时间传入,tz_offset_minutes 用于转换库内 UTC 口径。 """ - denied = _require_operator() - if denied: - return denied try: - result = operation_log_tasks.list_operation_logs( - page=_int_or_none("page") or 1, - page_size=_int_or_none("page_size") or 20, - operator_user_id=_int_or_none("operator_user_id"), - operation=request.args.get("operation") or None, - target_type=request.args.get("target_type") or None, - success=parse_bool(request.args.get("success")), - start=request.args.get("start") or None, - end=request.args.get("end") or None, - tz_offset_minutes=_int_or_none("tz_offset_minutes"), - ) + with request.app.state.flask_app.app_context(): + result = operation_log_tasks.list_operation_logs( + page=page, + page_size=page_size, + operator_user_id=operator_user_id, + operation=operation, + target_type=target_type, + success=success, + start=start, + end=end, + tz_offset_minutes=tz_offset_minutes, + ) except Exception as e: - return jsonify({"success": 0, "message": f"query failed: {e}", "error_reason": "list_failed"}), 500 - - return jsonify({"success": 1, **result}), 200 + return _error(500, f"query failed: {e}", "list_failed") + return {"success": 1, **result} -@api_bp.get("/admin/operation_logs/stats") -def operation_log_stats_api(): - """操作日志统计(operator-only)。query 参数:start / end / tz_offset_minutes。 - tz_offset_minutes 同时影响窗口解析与 by_day 分桶日(本地日), - 使绿墙日期轴与前端 UTC+8 渲染一致。 - """ - denied = _require_operator() - if denied: - return denied +@router.get("/stats", response_model=OperationLogStatsResponse) +def operation_log_stats_api( + request: Request, + _: int = Depends(require_operator), + start: str | None = None, + end: str | None = None, + tz_offset_minutes: int | None = None, +): + """操作日志统计。""" try: - result = operation_log_tasks.operation_log_stats( - start=request.args.get("start") or None, - end=request.args.get("end") or None, - tz_offset_minutes=_int_or_none("tz_offset_minutes"), - ) + with request.app.state.flask_app.app_context(): + result = operation_log_tasks.operation_log_stats( + start=start, + end=end, + tz_offset_minutes=tz_offset_minutes, + ) except Exception as e: - return jsonify({"success": 0, "message": f"stats failed: {e}", "error_reason": "list_failed"}), 500 + return _error(500, f"stats failed: {e}", "list_failed") - return jsonify({"success": 1, **result}), 200 + return {"success": 1, **result} diff --git a/api/user_api.py b/api/user_api.py index 6f1d97b..ff58261 100644 --- a/api/user_api.py +++ b/api/user_api.py @@ -1,429 +1,292 @@ -from flask import jsonify, request, make_response, current_app -from . import api_bp +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import JSONResponse + +from ..repositories import user_repo +from ..schemas.common import SuccessMessageResponse +from ..schemas.user import ( + ChangePasswordRequest, + DeleteUserResponse, + ListUserBriefResponse, + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + RequestRegisterCodeRequest, + ResetPasswordResponse, + UpdateUserRequest, + UpdateUserResponse, + UserDetailResponse, + UserIdRequest, +) from ..services import user_tasks -from ..repositories import user_repo, authentications_repo -from ..schemas.user_schema import user_schema, users_schema - - -@api_bp.post("/register") -def register(): - ''' - 通信数据格式: - 发送格式: - { - "username":"xxxx", - "email":"xxxx", - "password":"xxxx", - "graduation_year":xxxx - } - 返回格式: - { - "success": [0|1], - ["error_reason": "xxxx"], - "message": "xxxx", - "user_id": xxxx, - "username": "xxxx", - "email": "xxxx", - } - ''' - """用户注册 API""" - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 直接读取明文字段 - username = recived_data.get("username") - email = recived_data.get("email") - password = recived_data.get("password") - graduation_year = recived_data.get("graduation_year") - - if not username or not email or not password: - return jsonify({"success": 0, "message": "username, email and password required"}), 400 - - # 调用 service 层注册用户 - try: - success, user_or_reason, _ = user_tasks.Register_with_code(username, email, password, graduation_year, recived_data.get("registration_code")) - except Exception as e: - return jsonify({"success": 0, "message": "registration failed due to server error"}), 500 - if success: - return jsonify({ - "success": 1, - "message": "Registration successful", - "user_id": user_or_reason.id, - "username": user_or_reason.username, - "email": user_or_reason.email - }), 201 - else: - # user_or_reason 是错误原因字符串 - error_reason = user_or_reason - error_messages = { - "username_exists": "Username already exists", - "email_exists": "Email already exists", - "no_none_ascii": "Input contains non-ASCII characters", - "invalid_username": "Username may contain only letters, digits and underscore", - "registration_code_required": "Verification code required", - "registration_code_invalid": "Verification code invalid or expired", - "mail_send_failed": "Failed to send verification email" - } - message = error_messages.get(error_reason, "Registration failed") - - if error_reason in ["username_exists", "email_exists"]: - status_code = 409 # Conflict - else: - status_code = 400 # Bad Request - - return jsonify({ - "success": 0, - "message": message, - "error_reason": error_reason - }), status_code - - - - -@api_bp.post("/request_register_code") -def request_register_code(): - data = request.get_json(silent=True) or {} - email = data.get("email") - if not email: - return jsonify({"success": 0, "message": "email required", "error_reason": "missing_email"}), 400 - success, reason = user_tasks.Request_register_code(email) - if success: - return jsonify({"success": 1, "message": "verification code sent"}), 200 - status = 400 if reason == 'email_domain_not_allowed' else 500 - return jsonify({"success": 0, "message": reason, "error_reason": reason}), status - -@api_bp.post("/login") -def login(): - ''' - 通信数据格式: - 发送格式: - { - "username":"xxxx", - "password":"xxxx" - "remember":"[True|False] - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"], - "user_id": xxxx, - "username": "xxxx", - "email": "xxxx", - "permission": "[user|operator]", - } - ''' - """用户登录 API""" - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 直接读取明文字段 - username = recived_data.get("username") - password = recived_data.get("password") - remember = recived_data.get("remember") - - if not username or not password: - return jsonify({"success": 0, "message": "username and password required"}), 400 - - # 调用 Login 函数,返回结果和错误原因 - success, user_or_reason, token = user_tasks.Login(username, password, remember=remember) - - if success: - max_age = None - if remember: - max_age = 24*3600*30 - # 创建响应 - response = make_response(jsonify({ - "success": 1, - "message": "Login successful", - "user_id": user_or_reason.id, - "username": user_or_reason.username, - "email": user_or_reason.email, - "permission": user_or_reason.permission.value, - }), 200) - - # 设置 cookies - response.set_cookie( - 'auth_token', - token, - max_age=max_age, # 不记住:None,关浏览器就丢 | 记住:24小时*30 - httponly=True, - secure=current_app.config.get("SSL_ENABLED", True), # 跟随 ENABLE_SSL:HTTPS 时阻止 cookie 走明文 - samesite='Lax' - ) - - return response - else: - # user_or_reason 是错误原因字符串 - error_reason = user_or_reason - error_messages = { - "user_not_found": "User does not exist", - "password_incorrect": "Password is incorrect" - } - message = error_messages.get(error_reason, "Login failed") - if error_reason == "user_not_found": - error_code = 404 - elif error_reason == "password_incorrect": - error_code = 400 - - return jsonify({ - "success": 0, - "message": message, - "error_reason": error_reason - }), error_code - -@api_bp.get("/users/get_user_detail_information") -def get_user_detail_information_api(): - ''' - 通信数据格式: - 发送格式: - { - "user_id" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"], - "user_id", - "username", - "email", - "graduation_year", - "permission": "[user|operator]", - "containers", # in IDs - "amount_of_container", - "amount_of_functional_container", - "amount_of_managed_container" +from .deps import require_current_user + +router = APIRouter(tags=["users"]) + + +def _model_data(model, *, exclude_none: bool = False) -> dict[str, Any]: + """兼容 Pydantic v1/v2 的模型转 dict。""" + + if hasattr(model, "model_dump"): + return model.model_dump(exclude_none=exclude_none) + return model.dict(exclude_none=exclude_none) + + +def _error(status_code: int, message: str, error_reason: str | None = None) -> JSONResponse: + """返回 Ctrl 现有错误结构。""" + + payload: dict[str, Any] = {"success": 0, "message": message} + if error_reason is not None: + payload["error_reason"] = error_reason + return JSONResponse(status_code=status_code, content=payload) + + +##################### +# 注册 + + +@router.post("/register", response_model=RegisterResponse, status_code=201) +def register(message: RegisterRequest, request: Request): + """用户注册。""" + + data = _model_data(message) + try: + with request.app.state.flask_app.app_context(): + success, user_or_reason, _ = user_tasks.Register_with_code( + data.get("username"), + data.get("email"), + data.get("password"), + data.get("graduation_year"), + data.get("registration_code"), + ) + except Exception: + return _error(500, "registration failed due to server error") + + if success: + return { + "success": 1, + "message": "Registration successful", + "user_id": user_or_reason.id, + "username": user_or_reason.username, + "email": user_or_reason.email, + } + + error_reason = user_or_reason + error_messages = { + "username_exists": "Username already exists", + "email_exists": "Email already exists", + "no_none_ascii": "Input contains non-ASCII characters", + "invalid_username": "Username may contain only letters, digits and underscore", + "registration_code_required": "Verification code required", + "registration_code_invalid": "Verification code invalid or expired", + "mail_send_failed": "Failed to send verification email", } - ''' - # require valid token - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json(silent=True) or {} - # support both JSON body and querystring - user_id = data.get("user_id") or request.args.get("user_id") - if not user_id: - return jsonify({"success": 0, "message": "user_id required", "error_reason": "missing_user_id"}), 400 - - info = user_tasks.Get_user_detail_information(user_id) - if not info: - return jsonify({"success": 0, "message": "user not found", "error_reason": "user_not_found"}), 404 - - # if pydantic model, convert to dict - try: - payload = info.dict() - except Exception: - payload = info - - return jsonify({"success": 1, "user_info": payload}), 200 - -@api_bp.get("/users/list_all_user_bref_information") -def list_all_user_bref_information_api(): - ''' - 通信数据格式: - 发送格式: - { - "page_number", - "page_size" - } - 返回格式: - {[ - "user_id", - "username", - "email", - "graduation_year", - "containers", - "amount_of_container", - "amount_of_functional_container", - "amount_of_managed_container" - ], - ... - } - ''' - # require valid token - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json(silent=True) or {} - page_number = data.get("page_number") or request.args.get("page_number") or 1 - page_size = data.get("page_size") or request.args.get("page_size") or 10 - - try: - users = user_tasks.List_all_user_bref_information(page_number=int(page_number), page_size=int(page_size)) - except Exception as e: - return jsonify({"success": 0, "message": "failed to list users", "error_reason": "list_failed"}), 500 - - # convert pydantic models to dicts if necessary - out = [] - for u in users: - try: - out.append(u.dict()) - except Exception: - out.append(u) - - return jsonify({"success": 1, "users": out}), 200 - -@api_bp.post("/users/change_password") -def change_password_user(): - ''' - 通讯数据格式: - 发送格式: - { - "user_id", - "old_password", - "new_password" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json(silent=True) or {} - - user_id = data.get("user_id") or request.args.get("user_id") - old_password = data.get("old_password") - new_password = data.get("new_password") - - if not user_id or not old_password or not new_password: - return jsonify({"success": 0, "message": "user_id, old_password and new_password required", "error_reason": "missing_fields"}), 400 - - # fetch user object - user = user_repo.get_by_id(int(user_id)) - if not user: - return jsonify({"success": 0, "message": "user not found", "error_reason": "user_not_found"}), 404 - - try: - ok = user_tasks.Change_password(user, old_password, new_password) - if ok: - return jsonify({"success": 1, "message": "password changed"}), 200 - else: - return jsonify({"success": 0, "message": "old password incorrect", "error_reason": "old_password_incorrect"}), 400 - except ValueError as e: - if str(e) == 'no_none_ascii': - return jsonify({"success": 0, "message": "None ascii not allowed (Chinese not accepted)", "error_reason": "no_none_ascii"}), 400 - raise - -@api_bp.post("/users/delete_user") -def delete_user_api(): - ''' - 通讯数据格式: - 发送格式: - { - "user_id" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"], - "wild_containers": [...] # 可选字段,仅在存在无主容器阻止删除时返回 - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json(silent=True) or {} - user_id = data.get("user_id") or request.args.get("user_id") - if not user_id: - return jsonify({"success": 0, "message": "user_id required", "error_reason": "missing_user_id"}), 400 - - try: - ok = user_tasks.Delete_user(int(user_id)) - except Exception as e: - # 异常时,意味着存在无主容器阻止删除;返回特定错误信息并附加无主容器列表 - payload = {"success": 0, "message": "Wild container NOT allowed. Must remove all affected containers first.", "error_reason": "wild_container"} - wild = getattr(e, 'wild_containers', None) - if wild: - payload['wild_containers'] = wild - return jsonify(payload), 400 - - if ok: - return jsonify({"success": 1, "message": "user deleted"}), 200 - else: - return jsonify({"success": 0, "message": "user not found", "error_reason": "user_not_found"}), 404 - -@api_bp.post("/users/update_user") -def update_user_api(): - ''' - 通讯数据格式: - 发送格式: - { - "user_id", - "fields": { - "username": "newname", - "email": "newemail", - "graduation_year": 2026 - } - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"], - "user": { ... updated user data ... } - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json(silent=True) or {} - - user_id = data.get("user_id") or request.args.get("user_id") - fields = data.get("fields", {}) - - if not user_id or not fields: - return jsonify({"success": 0, "message": "user_id and fields required", "error_reason": "missing_fields"}), 400 - - try: - user = user_tasks.Update_user(int(user_id), **fields) - except ValueError as e: - if str(e) == 'no_none_ascii': - return jsonify({"success": 0, "message": "禁止非ASCII字符(请勿输入中文)", "error_reason": "no_none_ascii"}), 400 - if str(e) == 'invalid_username': - return jsonify({"success": 0, "message": "用户名仅允许字母、数字和下划线", "error_reason": "invalid_username"}), 400 - return jsonify({"success": 0, "message": str(e), "error_reason": "invalid_fields"}), 400 - - if user: - return jsonify({"success": 1, "message": "user updated", "user": user.username}), 200 - else: - return jsonify({"success": 0, "message": "user not found", "error_reason": "user_not_found"}), 404 - -@api_bp.post("/users/reset_password") -def reset_password_api(): - ''' - 通讯数据格式: - 发送格式: - { - "user_id" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"], - "new_password": "xxxx" - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json(silent=True) or {} - - user_id = data.get("user_id") or request.args.get("user_id") - - if not user_id: - return jsonify({"success": 0, "message": "user_id required", "error_reason": "missing_user_id"}), 400 - - new_password = user_tasks.Reset_password(int(user_id)) - if new_password: - return jsonify({"success": 1, "message": "password reset", "new_password": new_password}), 200 - else: - return jsonify({"success": 0, "message": "user not found", "error_reason": "user_not_found"}), 404 \ No newline at end of file + status_code = 409 if error_reason in {"username_exists", "email_exists"} else 400 + return _error(status_code, error_messages.get(error_reason, "Registration failed"), error_reason) + + +@router.post("/request_register_code", response_model=SuccessMessageResponse) +def request_register_code(message: RequestRegisterCodeRequest, request: Request): + """发送注册验证码。""" + + with request.app.state.flask_app.app_context(): + success, reason = user_tasks.Request_register_code(message.email) + if success: + return {"success": 1, "message": "verification code sent"} + status_code = 400 if reason == "email_domain_not_allowed" else 500 + return _error(status_code, reason, reason) + + +##################### +# 登录 + + +@router.post("/login", response_model=LoginResponse) +def login(message: LoginRequest, response: Response, request: Request): + """用户登录并设置 auth_token cookie。""" + + with request.app.state.flask_app.app_context(): + success, user_or_reason, token = user_tasks.Login( + message.username, + message.password, + remember=message.remember, + ) + ssl_enabled = request.app.state.flask_app.config.get("SSL_ENABLED", True) + + if success: + max_age = 24 * 3600 * 30 if message.remember else None + response.set_cookie( + "auth_token", + token, + max_age=max_age, + httponly=True, + secure=ssl_enabled, + samesite="Lax", + ) + return { + "success": 1, + "message": "Login successful", + "user_id": user_or_reason.id, + "username": user_or_reason.username, + "email": user_or_reason.email, + "permission": user_or_reason.permission.value, + } + + error_reason = user_or_reason + error_messages = { + "user_not_found": "User does not exist", + "password_incorrect": "Password is incorrect", + } + status_code = 404 if error_reason == "user_not_found" else 400 + return _error(status_code, error_messages.get(error_reason, "Login failed"), error_reason) + + +##################### +# 用户详情 + + +@router.get("/users/get_user_detail_information", response_model=UserDetailResponse) +def get_user_detail_information_api( + request: Request, + user_id: int = Query(..., ge=1), + _: int = Depends(require_current_user), +): + """查询用户详情。""" + + with request.app.state.flask_app.app_context(): + info = user_tasks.Get_user_detail_information(user_id) + if not info: + return _error(404, "user not found", "user_not_found") + return {"success": 1, "user_info": _model_data(info)} + + +@router.get("/users/list_all_user_bref_information", response_model=ListUserBriefResponse) +def list_all_user_bref_information_api( + request: Request, + page_number: int = Query(default=1, ge=1), + page_size: int = Query(default=10, ge=1), + _: int = Depends(require_current_user), +): + """分页查询用户概要。""" + + try: + with request.app.state.flask_app.app_context(): + users = user_tasks.List_all_user_bref_information( + page_number=int(page_number), + page_size=int(page_size), + ) + except Exception: + return _error(500, "failed to list users", "list_failed") + + return {"success": 1, "users": [_model_data(u) for u in users]} + + +##################### +# 修改密码 + + +@router.post("/users/change_password", response_model=SuccessMessageResponse) +def change_password_user( + message: ChangePasswordRequest, + request: Request, + _: int = Depends(require_current_user), +): + """修改用户密码。""" + + with request.app.state.flask_app.app_context(): + user = user_repo.get_by_id(message.user_id) + if not user: + return _error(404, "user not found", "user_not_found") + try: + ok = user_tasks.Change_password(user, message.old_password, message.new_password) + except ValueError as e: + if str(e) == "no_none_ascii": + return _error(400, "None ascii not allowed (Chinese not accepted)", "no_none_ascii") + raise + + if ok: + return {"success": 1, "message": "password changed"} + return _error(400, "old password incorrect", "old_password_incorrect") + + +##################### +# 删除用户 + + +@router.post("/users/delete_user", response_model=DeleteUserResponse) +def delete_user_api( + message: UserIdRequest, + request: Request, + _: int = Depends(require_current_user), +): + """删除用户。""" + + try: + with request.app.state.flask_app.app_context(): + ok = user_tasks.Delete_user(message.user_id) + except Exception as e: + payload: dict[str, Any] = { + "success": 0, + "message": "Wild container NOT allowed. Must remove all affected containers first.", + "error_reason": "wild_container", + } + wild = getattr(e, "wild_containers", None) + if wild: + payload["wild_containers"] = wild + return JSONResponse(status_code=400, content=payload) + + if ok: + return {"success": 1, "message": "user deleted"} + return _error(404, "user not found", "user_not_found") + + +##################### +# 更新用户 + + +@router.post("/users/update_user", response_model=UpdateUserResponse) +def update_user_api( + message: UpdateUserRequest, + request: Request, + _: int = Depends(require_current_user), +): + """更新用户基础字段。""" + + fields = _model_data(message.fields, exclude_none=True) + if not fields: + return _error(400, "user_id and fields required", "missing_fields") + + try: + with request.app.state.flask_app.app_context(): + user = user_tasks.Update_user(message.user_id, **fields) + except ValueError as e: + if str(e) == "no_none_ascii": + return _error(400, "禁止非ASCII字符(请勿输入中文)", "no_none_ascii") + if str(e) == "invalid_username": + return _error(400, "用户名仅允许字母、数字和下划线", "invalid_username") + return _error(400, str(e), "invalid_fields") + + if user: + return {"success": 1, "message": "user updated", "user": user.username} + return _error(404, "user not found", "user_not_found") + + +##################### +# 重置密码 + + +@router.post("/users/reset_password", response_model=ResetPasswordResponse) +def reset_password_api( + message: UserIdRequest, + request: Request, + _: int = Depends(require_current_user), +): + """重置用户密码。""" + + with request.app.state.flask_app.app_context(): + new_password = user_tasks.Reset_password(message.user_id) + if new_password: + return {"success": 1, "message": "password reset", "new_password": new_password} + return _error(404, "user not found", "user_not_found") diff --git a/api_doc.py b/api_doc.py deleted file mode 100644 index aaf34a8..0000000 --- a/api_doc.py +++ /dev/null @@ -1,249 +0,0 @@ -from flask import Blueprint, jsonify, request -from ..services import user_service -from ..schemas.user_schema import user_schema, users_schema -from ..utils.CheckKeys import * -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey -from ..config import KeyConfig -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.primitives import hashes -import requests -import json -from ..services.container_service import * - - -api_bp = Blueprint("api", __name__, url_prefix="/api") - -''' -通信数据格式: -发送格式: -{ - "message":{ - "type":'create', - "config": - { - "gpu_list":[0,1,2,...], - "cpu_number":20, - "memory":16,#GB - "user_name":'example', - "port":0, - "image":"ubuntu24.04" - } - }, - "signature":"xxxxxx" -} -返回格式: -{ - "container_id": container_id, - "container_name": container_name -} -''' -@api_bp.get("/create_container") -def Create_container(): - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 使用 get_verified_msg 函数解密并验证 - verified_msg = get_verified_msg(recived_data) - - if not verified_msg: - return jsonify({"success": 0, "message": "invalid_signature or decryption failed"}), 401 - - # 提取消息类型和配置 - msg_type = verified_msg.get("type") - config = verified_msg.get("config") - - if msg_type != "create" or not config: - return jsonify({"success": 0, "message": "invalid message type or config"}), 400 - - container_id, container_name = create_container(**config) - - return jsonify({ - "container_id": container_id, - "container_name": container_name - }), 200 - -''' -通信数据格式: -发送格式: -{ - "message":{ - "type":'remove', - "config": - { - "container_id":"xxxx" - } - }, - "signature":"xxxxxx" -} - -返回格式: -{ - "success": [0|1], -} -''' -@api_bp.post("/remove_container") -def Remove_container(): - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 使用 get_verified_msg 函数解密并验证 - verified_msg = get_verified_msg(recived_data) - - if not verified_msg: - return jsonify({"success": 0, "message": "invalid_signature or decryption failed"}), 401 - - # 提取消息类型和配置 - msg_type = verified_msg.get("type") - config = verified_msg.get("config") - - if msg_type != "remove" or not config: - return jsonify({"success": 0, "message": "invalid message type or config"}), 400 - - success = remove_container(**config) - - return jsonify({ - "success": success, - }), 200 - -''' -通信数据格式: -发送格式: -{ - "message":{ - "type":'update', - "config": - { - "container_id":"xxxx", - "user_name":"xxxx", - "role":['admin'|'collaborator'] - } - }, - "signature":"xxxxxx" -} -返回格式: -{ - "success": [0|1], -} -''' -@api_bp.post("/add_collaborator") -def Add_collaborator(): - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 使用 get_verified_msg 函数解密并验证 - verified_msg = get_verified_msg(recived_data) - - if not verified_msg: - return jsonify({"success": 0, "message": "invalid_signature or decryption failed"}), 401 - - # 提取消息类型和配置 - msg_type = verified_msg.get("type") - config = verified_msg.get("config") - - if msg_type != "update" or not config: - return jsonify({"success": 0, "message": "invalid message type or config"}), 400 - - success = add_collaborator(**config) - - return jsonify({ - "success": success, - }), 200 - - -''' -通信数据格式: -发送格式: -{ - "message":{ - "type":'update', - "config": - { - "container_id":"xxxx", - "user_name":"xxxx", - } - }, - "signature":"xxxxxx" -} -返回格式: -{ - "success": [0|1], -} -''' -@api_bp.post("/remove_collaborator") -def Remove_collaborator(): - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 使用 get_verified_msg 函数解密并验证 - verified_msg = get_verified_msg(recived_data) - - if not verified_msg: - return jsonify({"success": 0, "message": "invalid_signature or decryption failed"}), 401 - - # 提取消息类型和配置 - msg_type = verified_msg.get("type") - config = verified_msg.get("config") - - if msg_type != "update" or not config: - return jsonify({"success": 0, "message": "invalid message type or config"}), 400 - - success = remove_collaborator(**config) - - return jsonify({ - "success": success, - }), 200 - - -''' -通信数据格式: -发送格式: -{ - "message":{ - "type":'update', - "config": - { - "container_id":"xxxx", - "user_name":"xxxx", - "updated_role":"xxxx" - } - }, - "signature":"xxxxxx" -} -返回格式: -{ - "success": 0|1, -} -''' -@api_bp.post("/update_role") -def Update_role(): - recived_data = request.get_json(silent=True) - if not recived_data: - return jsonify({"success": 0, "message": "invalid json"}), 400 - - # 使用 get_verified_msg 函数解密并验证 - verified_msg = get_verified_msg(recived_data) - - if not verified_msg: - return jsonify({"success": 0, "message": "invalid_signature or decryption failed"}), 401 - - # 提取消息类型和配置 - msg_type = verified_msg.get("type") - config = verified_msg.get("config") - - if msg_type != "update" or not config: - return jsonify({"success": 0, "message": "invalid message type or config"}), 400 - - success = update_role(**config) - - return jsonify({ - "success": success, - }), 200 - - -def register_blueprints(app): - app.register_blueprint(api_bp) - diff --git a/private_A.pem b/private_A.pem deleted file mode 100644 index 762b20d..0000000 --- a/private_A.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCzeu5VBs++yceD -fyGdqweprNdT0Yorm/3QyzSkS2YURBVVfkZVoWkojwirZhsSdFdKP0n+66B2KvQ7 -QO9JrvuSuwFdwOO+uzye0mb88i0LYVOkW5i/X+JTNHVGsbgBnkDXeVMjW/gHNjj5 -p/nZhH4M19j7RjuLUooUm2Steyw+OkcAvxB4Mr7tAuaYfJP+XAM880HtEiQiPt2t -hwVchhH7qusRvfzGcsXsqUGZ0LlaR+79VdJIU2Lxuum61iPG/kRoZniq+BBHTdfH -HbXbpQRCpUw5VIfVRl+Cc44ezdyMpm292dyR4WlUYIJtTSSu64A2w5g6HZ4D9myi -jWWa+/k3AgMBAAECggEAJrx68NXyA2LoURygpDdUBY5cwOXiFMxriOM6ntT5GJmj -IxsIUhXurMbGH7v/TsnWRxuGvGKddLOLl7hJjNUbzczXnCTz4mflyv9tI92e1Cxy -0W/FdVRb9ApH33Se2paNtxOj2mdQjjnpLRTQa15ZmCsD1SOQ2V58l7r8DSce4uTy -xRhhqq10ziIFWhJzqRCrSGt8ro/ObIqIVhrVfLhNgFctVfnc2NefJftpBzwW53H9 -hLxnnA43h9sDzO6TDUW8nJ65d++gp5rikbLE2yz7QZCbuPS3WIiNJWQ7rQy5HCtU -GOu3Up4hWVtInUvuLjAI9hCBofHLNa+miVceGkPSBQKBgQDzYCzO+eVb3ZpFi/33 -jcPdxYR561iJC/WIe/dT6XXxadhMF12mqAGTFUqP+wCsTd9E0Y67159fbm3EpHW1 -a0Q5gxABgaBPq875p7HlOHPXbSJitZRkTj3tv7FB3MBSAFuWDaZYN+f3hV3t0yLP -xTeD8RRMS4irJopVCoXS0zy/FQKBgQC8ykcKhFFla6TV3Be0nqHJafQGCjRe9xpS -tijtB7Q+flqog208LfHITypepK8EmHy8sO7IBwbcIy1ekgx9F/WNyiycMrBq0svL -PntXxiKNbVPU6rwEDIhKtFF303Ya0PzlDxHuGOsxORE5F7nIt3Tj0+y2MQiGiMaz -R+TVYukKGwKBgHMd37P7AeEFHc6dnAA3PxksLzBYAKW6UWZAdMltGUuabCP5vWNx -/mSq4nzeFgBqSRxNHv18zTafji5AOCka1sd5Vd3QiZqFwr3V81Bf9nNEfMpB5zHW -zHYjgN2NZC6lWqzMQg1iTEeI/tfaUZIDT/IJ2zcHV1rVPQNimdAR+J7NAoGBAJuv -f8g3d9xYWbWW6+GiU68Cdh8o+Sk7Q3TkDXnyuXwzvNVslH9lMBdM2Zb03fO/QFZm -3nMMAGc3hymO7UeXo4MLL6Cb2IovTapM23B1z3arqs9RyDzajOZ2LxzOwH26zGKk -+9dKq2GLOx3G6AmS6I0c7f8NDofLcXjVF+u0xpLzAoGAFWcaInDqGsVxioJs4s8V -uV0o9HZJaEQW7kevluxTHYrRyYaYWAgbtX/To5y/bKvJdKvlolenhuDNXrYEFgM8 -Lj4tmQUtIPhWnqm9oESSs1jy95kQvU85ZMofxgJ7YE3iWsXJjJH7y3e6HOKmMFkD -BKgfSmvpZeZlgOpVwJBmWpA= ------END PRIVATE KEY----- diff --git a/public_A.pem b/public_A.pem deleted file mode 100644 index 776d8d3..0000000 --- a/public_A.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs3ruVQbPvsnHg38hnasH -qazXU9GKK5v90Ms0pEtmFEQVVX5GVaFpKI8Iq2YbEnRXSj9J/uugdir0O0DvSa77 -krsBXcDjvrs8ntJm/PItC2FTpFuYv1/iUzR1RrG4AZ5A13lTI1v4BzY4+af52YR+ -DNfY+0Y7i1KKFJtkrXssPjpHAL8QeDK+7QLmmHyT/lwDPPNB7RIkIj7drYcFXIYR -+6rrEb38xnLF7KlBmdC5Wkfu/VXSSFNi8brputYjxv5EaGZ4qvgQR03Xxx2126UE -QqVMOVSH1UZfgnOOHs3cjKZtvdnckeFpVGCCbU0kruuANsOYOh2eA/Zsoo1lmvv5 -NwIDAQAB ------END PUBLIC KEY----- diff --git a/schedulers/container_disk_check_task.py b/schedulers/container_disk_check_task.py index 0552361..e287958 100644 --- a/schedulers/container_disk_check_task.py +++ b/schedulers/container_disk_check_task.py @@ -1,4 +1,3 @@ -import json import threading import time import logging @@ -434,10 +433,8 @@ def _clean_mount_immediately(container) -> None: from ..repositories.machine_repo import get_machine_ip_by_id machine_ip = get_machine_ip_by_id(container.machine_id) url = container_tasks.get_full_url(machine_ip, "/clean_mount") - payload = json.dumps({"config": {"mount_path": bind_mount}}) - sig = container_tasks.signature(payload) - enc = container_tasks.encryption(payload) - res = container_tasks.send(enc, sig, url, timeout=10.0) + payload = {"config": {"mount_path": bind_mount}} + res = container_tasks.send(url, payload, timeout=10.0) logger.debug("[disk-check] escalation mount cleanup for container %s path=%s: %s", container.id, bind_mount, res) except Exception as e: diff --git a/schedulers/container_mount_cleanup_task.py b/schedulers/container_mount_cleanup_task.py index 1a9da55..2561fbb 100644 --- a/schedulers/container_mount_cleanup_task.py +++ b/schedulers/container_mount_cleanup_task.py @@ -5,7 +5,6 @@ - escalation=True 的记录已在删除时立刻清理,此处跳过 """ -import json import threading import time import logging @@ -13,12 +12,7 @@ from flask import Flask, current_app from ..repositories import container_mount_cleanup_repo, machine_repo -from ..services.container_tasks import ( - encryption, - get_full_url, - send, - signature, -) +from ..services.container_tasks import get_full_url, send logger = logging.getLogger(__name__) @@ -47,10 +41,8 @@ def run_mount_cleanup_once() -> None: continue url = get_full_url(machine_ip, "/clean_mount") - payload = json.dumps({"config": {"mount_path": row.mount_path}}) - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=10.0) + payload = {"config": {"mount_path": row.mount_path}} + res = send(url, payload, timeout=10.0) if isinstance(res, dict) and res.get("success") == 1: container_mount_cleanup_repo.mark_cleaned(row.id) diff --git a/schemas/__init__.py b/schemas/__init__.py index afe20fd..61ad3f5 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -30,9 +30,36 @@ RemoveMachineRequest, RemoveMachineResponse, SysSnapshotMessage, + SysSnapshotPayload, UpdateMachineRequest, UpdateMachineResponse, ) +from .operation_log import ( + OperationLogItem, + OperationLogListQuery, + OperationLogListResponse, + OperationLogStatsResponse, +) +from .user import ( + ChangePasswordRequest, + DeleteUserResponse, + ListUserBriefRequest, + ListUserBriefResponse, + LoginRequest, + LoginResponse, + PermissionValue, + RegisterRequest, + RegisterResponse, + RequestRegisterCodeRequest, + ResetPasswordResponse, + UpdateUserFields, + UpdateUserRequest, + UpdateUserResponse, + UserBriefItem, + UserDetailInfo, + UserDetailResponse, + UserIdRequest, +) __all__ = [ "AddMachinePermissionRequest", @@ -40,12 +67,18 @@ "AddMachineRequest", "AddMachineResponse", "ApiErrorResponse", + "ChangePasswordRequest", + "DeleteUserResponse", "EmptyObject", "FreeFormObject", "IdRequest", "ListMachineBriefRequest", "ListMachineBriefResponse", "ListMachinePermissionsResponse", + "ListUserBriefRequest", + "ListUserBriefResponse", + "LoginRequest", + "LoginResponse", "MachineAllocationLimit", "MachineBriefItem", "MachineDetailResponse", @@ -55,15 +88,32 @@ "MachineType", "MachineUpdateFields", "NodeHardwareProfile", + "OperationLogItem", + "OperationLogListQuery", + "OperationLogListResponse", + "OperationLogStatsResponse", "PageRequest", + "PermissionValue", "RegisterMachineByTrustAnchorRequest", "RegisterMachineRequest", "RegisterMachineResponse", "RegisterMachineWithProfileResponse", + "RegisterRequest", + "RegisterResponse", "RemoveMachineRequest", "RemoveMachineResponse", + "RequestRegisterCodeRequest", + "ResetPasswordResponse", "SuccessMessageResponse", "SysSnapshotMessage", + "SysSnapshotPayload", "UpdateMachineRequest", "UpdateMachineResponse", + "UpdateUserFields", + "UpdateUserRequest", + "UpdateUserResponse", + "UserBriefItem", + "UserDetailInfo", + "UserDetailResponse", + "UserIdRequest", ] diff --git a/schemas/operation_log.py b/schemas/operation_log.py new file mode 100644 index 0000000..382bdf7 --- /dev/null +++ b/schemas/operation_log.py @@ -0,0 +1,47 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class OperationLogListQuery(BaseModel): + """操作日志查询参数。""" + + page: int = Field(default=1, ge=1) + page_size: int = Field(default=20, ge=1) + operator_user_id: int | None = None + operation: str | None = None + target_type: str | None = None + success: bool | None = None + start: str | None = None + end: str | None = None + tz_offset_minutes: int | None = None + + +class OperationLogItem(BaseModel): + """操作日志条目。字段保持宽松,兼容 repo serialize 输出。""" + + id: int | None = None + operator_user_id: int | None = None + operation: str | None = None + target_type: str | None = None + target_id: int | None = None + target_name: str | None = None + root_owner: str | None = None + detail: dict[str, Any] | None = None + success: bool | int | None = None + error_reason: str | None = None + created_at: str | None = None + + +class OperationLogListResponse(BaseModel): + success: int | bool = 1 + logs: list[OperationLogItem | dict[str, Any]] + total_pages: int + + +class OperationLogStatsResponse(BaseModel): + success: int | bool = 1 + total: int | None = None + by_day: list[dict[str, Any]] | dict[str, Any] | None = None + by_operation: list[dict[str, Any]] | dict[str, Any] | None = None + by_error_reason: list[dict[str, Any]] | dict[str, Any] | None = None diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..8a28f63 --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,100 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from .common import PageRequest, SuccessMessageResponse + + +PermissionValue = Literal["user", "operator"] + + +class RegisterRequest(BaseModel): + username: str + email: str + password: str + graduation_year: int | None = None + registration_code: str | None = None + + +class RegisterResponse(SuccessMessageResponse): + user_id: int + username: str + email: str + + +class RequestRegisterCodeRequest(BaseModel): + email: str + + +class LoginRequest(BaseModel): + username: str + password: str + remember: bool = False + + +class LoginResponse(SuccessMessageResponse): + user_id: int + username: str + email: str + permission: PermissionValue | str + + +class UserIdRequest(BaseModel): + user_id: int = Field(..., ge=1) + + +class UserBriefItem(BaseModel): + user_id: int + username: str + email: str + graduation_year: int | None = None + containers: list[int] = Field(default_factory=list) + amount_of_container: int = 0 + amount_of_functional_container: int = 0 + amount_of_managed_container: int = 0 + amount_of_long_term_container: int = 0 + + +class UserDetailInfo(UserBriefItem): + permission: PermissionValue | str | None = None + + +class UserDetailResponse(BaseModel): + success: int | bool = 1 + user_info: UserDetailInfo | dict[str, Any] + + +class ListUserBriefRequest(PageRequest): + page_number: int = Field(default=1, ge=1) + + +class ListUserBriefResponse(BaseModel): + success: int | bool = 1 + users: list[UserBriefItem | dict[str, Any]] + + +class ChangePasswordRequest(UserIdRequest): + old_password: str + new_password: str + + +class DeleteUserResponse(SuccessMessageResponse): + wild_containers: list[dict[str, Any]] | None = None + + +class UpdateUserFields(BaseModel): + username: str | None = None + email: str | None = None + graduation_year: int | None = None + + +class UpdateUserRequest(UserIdRequest): + fields: UpdateUserFields = Field(default_factory=UpdateUserFields) + + +class UpdateUserResponse(SuccessMessageResponse): + user: str | dict[str, Any] | None = None + + +class ResetPasswordResponse(SuccessMessageResponse): + new_password: str diff --git a/services/container_module/node_comms.py b/services/container_module/node_comms.py index 46ba0f6..1eb0502 100644 --- a/services/container_module/node_comms.py +++ b/services/container_module/node_comms.py @@ -15,7 +15,6 @@ from ...repositories import machine_repo, containers_repo from ...repositories.container_ssh_login_repo import upsert_last_ssh_login_time from ..machine_tasks import is_machine_online_remote -from ...utils.CheckKeys import signature, encryption from ...utils.parallel import parallel_node_calls from .exceptions import NodeServiceError from .utils import _parse_last_ssh_time @@ -42,40 +41,39 @@ def _pin_file(machine_ip: str) -> Path: return Path(PINNED_CERTS_DIR) / f"{machine_ip}.pem" -def _resolve_tls(machine_ip: str, cert=None, verify=None): +def _resolve_tls(url: str, cert=None, verify=None): """解析 send 的 TLS 参数。 - cert 默认 Ctrl 客户端证书(cert_utils 已生成时) - - verify 默认对端 pin 文件;未接入(未 pin)时降级 verify=False(TOFU 过渡,警告) + - verify 默认对端 pin 文件(按 URL 的 host 定位);未接入(未 pin)时降级 + verify=False(TOFU 过渡,警告) """ + host = url.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0] if cert is None: from ...utils.cert_utils import ctrl_certificate_paths paths = ctrl_certificate_paths() if paths.cert_file.exists() and paths.key_file.exists(): cert = (str(paths.cert_file), str(paths.key_file)) if verify is None: - pin = _pin_file(machine_ip) + pin = _pin_file(host) if pin.exists(): verify = str(pin) else: - logger.warning("send to %s: no pinned cert (machine not enrolled yet); TLS verify disabled", machine_ip) + logger.warning("send to %s: no pinned cert (machine not enrolled yet); TLS verify disabled", host) verify = False return cert, verify -def send(ciphertext:bytes,signature:bytes,mechine_ip:str, timeout:float=5.0, *, cert=None, verify=None)->dict: +def send(url: str, payload: dict, timeout: float = 5.0, *, cert=None, verify=None) -> dict: """ - 发送 POST 并返回解析后的响应(优先 JSON),出现错误时返回包含 error 字段的 dict。 + HTTPS POST 明文 JSON payload 到 Node,返回解析后的响应(优先 JSON)。 - TLS:https + Ctrl 客户端证书(cert)+ 对端证书 pin(verify), - 显式传入 cert/verify 可覆盖默认(TOFU 首连时 verify=False)。 + TLS 承载身份(check_keys 信封已退役):https + Ctrl 客户端证书(cert)+ + 对端证书 pin(verify);显式传入 cert/verify 可覆盖默认(TOFU 首连时 verify=False)。 """ - cert, verify = _resolve_tls(mechine_ip, cert=cert, verify=verify) + cert, verify = _resolve_tls(url, cert=cert, verify=verify) try: - resp = requests.post(mechine_ip, json={ - "message": base64.b64encode(ciphertext).decode('utf-8'), - "signature": base64.b64encode(signature).decode('utf-8') - }, timeout=timeout, cert=cert, verify=verify) + resp = requests.post(url, json=payload, timeout=timeout, cert=cert, verify=verify) # 尝试解析为 JSON(即使是 4xx/5xx,也优先解析 body 中的 JSON,以保留 Node 返回的 error_reason) try: @@ -138,14 +136,12 @@ def get_container_status(machine_ip: str, container_name: str, timeout: float = 这个方法主要是为了在服务端调用 Node 的 /container_status API 来验证容器状态的。但是这个方法不被heartbeat使用。 """ url = get_full_url(machine_ip, "/container_status") - payload = json.dumps({"config": {"container_name": container_name}}) - sig = signature(payload) - enc = encryption(payload) + payload = {"config": {"container_name": container_name}} last_exc = None for attempt in range(2): try: - res = send(enc, sig, url, timeout=timeout) + res = send(url, payload, timeout=timeout) # send 不抛网络异常(以 {"error": ...} 返回),按原语义对网络级失败重试 if isinstance(res, dict) and res.get('error') and res.get('status_code') != 404: last_exc = res.get('error') @@ -612,6 +608,26 @@ def probe_machine_connectivity(machine_id: int, attempts: int = CONNECTIVITY_PRO # 3. 断线 → 由挂载方调用 probe_machine_connectivity 回退探测(连续两次不达判宿主机离线) # 应用层 session:落库需 Flask app context(apply_* 用 db.session),挂载方包一层 ctx。 +def _handle_container_deleted(container_name: str) -> None: + """Node 推 delete 帧:容器在 Node 侧消失 → 抹 Ctrl DB 记录(绑定 + 容器行)。 + + 关联表(usercontainer/container_ssh_login/freeze/long_term)均 ondelete=CASCADE, + 删容器行即级联清理。外部删除是异常路径,记录 warning。 + """ + try: + from ...repositories.usercontainer_repo import remove_binding + container = containers_repo.get_by_container_name(container_name) + if container is None: + logger.debug("handle_node_ws delete: container %r already gone (skip)", container_name) + return + remove_binding(0, container.id, all=True) + containers_repo.delete_container(container.id) + logger.warning("handle_node_ws delete: container %r (id=%s) removed from DB (vanished on node)", + container_name, container.id) + except Exception as e: + logger.warning("handle_node_ws delete: failed to remove container %r: %s", container_name, e) + + def rebuild_pinned_chain() -> Path | None: """重建 pin chain 文件:pinned_certs/*.pem 拼接为一个 bundle。 @@ -685,8 +701,17 @@ async def handle_node_ws(websocket) -> None: if frame.get("type") == "snapshot_batch": # 落库需 Flask app context——挂载方(ASGI 桥接)负责包 ctx apply_snapshot_batch(frame) - elif frame.get("type") in ("event", "delete"): - logger.info("handle_node_ws: frame type %r not yet handled (uid=%s)", frame.get("type"), uid) + elif frame.get("type") == "delete": + # 容器在 Node 侧消失(外部删除/对账清理)→ 抹 Ctrl DB 记录。 + # 这是 Ctrl 现有 404 语义(删 DB 记录)的唯一替代品(文档 WSS 协议硬项)。 + container_name = frame.get("container_name") + if container_name: + _handle_container_deleted(container_name) + elif frame.get("type") == "event": + # 运行事件(event_log 素材):记录日志;container_events 表随 WSS 推送落地时规划 + logger.info("handle_node_ws: container event uid=%s name=%s type=%s exit_code=%s", + uid, frame.get("container_name"), frame.get("event_type"), + frame.get("exit_code")) else: logger.warning("handle_node_ws: unknown frame type %r (uid=%s)", frame.get("type"), uid) except Exception as e: diff --git a/services/container_tasks.py b/services/container_tasks.py index a2bf25a..301f074 100644 --- a/services/container_tasks.py +++ b/services/container_tasks.py @@ -19,7 +19,6 @@ from ..repositories import container_ssh_login_repo from ..repositories.machine_repo import * from ..repositories.user_repo import * -from ..utils.CheckKeys import * from ..utils.Container import Container_info from ..repositories.containers_repo import * from ..repositories.usercontainer_repo import * @@ -83,11 +82,9 @@ def get_container_last_ssh_login_time(container_id: int, timeout: float = 5.0) - return None container_name = getattr(container, 'name', None) - payload = json.dumps({"config": {"container_name": container_name}}) + payload = {"config": {"container_name": container_name}} try: - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=timeout) + res = send(url, payload, timeout=timeout) except Exception as e: logger.error("Error sending request to %s: %s", url, e) # Node 不可达时,以 DB 已有记录兜底 @@ -196,7 +193,6 @@ def Create_container(owner_name:str,machine_id:int,container:Container_info,publ container_info['config']=container.get_config() if public_key: container_info['public_key']=public_key - container_info=json.dumps(container_info) # 名称/长度/格式等校验已在参数检查阶段由 container_repo.validate_create_params 完成 # check duplicate container name on this machine before sending to Node @@ -212,11 +208,7 @@ def Create_container(owner_name:str,machine_id:int,container:Container_info,publ except Exception as e: # If the check fails unexpectedly, log and continue to avoid blocking creation due to DB issues logger.warning("failed to check existing container name: %s", e) - signatured_message=signature(container_info) - - - encryptioned_message=encryption(container_info) - res=send(encryptioned_message,signatured_message,full_url) + res=send(full_url, container_info) logger.debug("Create_container: NODE response: %s", res) # 检查Node是否返回错误,如果有则抛出异常;如果没有则继续后续流程(写DB记录、建立绑定、启动心跳等) _raise_on_node_error(res, 'create') @@ -299,10 +291,8 @@ def remove_container(container_id:int, operator_user_id:int|None=None)->bool: } } - container_info=json.dumps(data) - signatured_message=signature(container_info) - encryptioned_message=encryption(container_info) - res=send(encryptioned_message,signatured_message,full_url) + container_info=data + res=send(full_url, container_info) logger.debug("remove_container: NODE response: %s", res) # 先看看远程调用层面是否有错误(网络/请求/远程处理错误等),如果有则抛出异常;如果没有则根据 Node 的返回内容来决定是否继续本地删除(Node 返回 NOTFOUND 则本地也删除,Node 返回 FAILED 则不删除并抛出异常) _raise_on_node_error(res, 'remove') @@ -384,11 +374,9 @@ def pause_container(container_id: int, operator_user_id: int | None = None, extr machine_ip = get_machine_ip_by_id(machine_id) url = get_full_url(machine_ip, "/pause_container") - payload = json.dumps({"config": {"container_name": container.name, "action": "pause"}}) + payload = {"config": {"container_name": container.name, "action": "pause"}} try: - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=10.0) + res = send(url, payload, timeout=10.0) except Exception as e: logger.error("pause_container send error: %s", e) write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.PAUSE_CONTAINER, @@ -428,11 +416,9 @@ def unpause_container(container_id: int, operator_user_id: int | None = None) -> machine_ip = get_machine_ip_by_id(machine_id) url = get_full_url(machine_ip, "/pause_container") - payload = json.dumps({"config": {"container_name": container.name, "action": "unpause"}}) + payload = {"config": {"container_name": container.name, "action": "unpause"}} try: - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=10.0) + res = send(url, payload, timeout=10.0) except Exception as e: logger.error("unpause_container send error: %s", e) write_op_log(success=False, operator_user_id=operator_user_id, operation=OperationType.UNPAUSE_CONTAINER, @@ -500,11 +486,9 @@ def get_container_disk_usage(container_id: int, timeout: float = 20.0) -> dict | return None container_name = getattr(container, 'name', None) - payload = json.dumps({"config": {"container_name": container_name}}) + payload = {"config": {"container_name": container_name}} try: - sig = signature(payload) - enc = encryption(payload) - res = send(enc, sig, url, timeout=timeout) + res = send(url, payload, timeout=timeout) except Exception as e: logger.error("Error sending disk check request to %s: %s", url, e) return None @@ -685,10 +669,8 @@ def add_collaborator(container_id:int,user_id:int,role:ROLE, operator_user_id:in } } - container_info=json.dumps(data) - signatured_message=signature(container_info) - encryptioned_message=encryption(container_info) - res=send(encryptioned_message,signatured_message,full_url) + container_info=data + res=send(full_url, container_info) _raise_on_node_error(res, 'add_collaborator') if res.get('success') not in (1, True): @@ -750,10 +732,8 @@ def remove_collaborator(container_id:int,user_id:int,operator_user_id:int|None=N "user_name":user_name } } - container_info=json.dumps(data) - signatured_message=signature(container_info) - encryptioned_message=encryption(container_info) - res=send(encryptioned_message,signatured_message,full_url) + container_info=data + res=send(full_url, container_info) _raise_on_node_error(res, 'remove_collaborator') if res.get('success') not in (1, True): @@ -802,11 +782,9 @@ def update_role(container_id:int,user_id:int,updated_role:ROLE,operator_user_id: "updated_role":updated_role.value } } - container_info=json.dumps(data) - signatured_message=signature(container_info) - encryptioned_message=encryption(container_info) + container_info=data # 使用 machine_ip 发送 - res=send(encryptioned_message,signatured_message,full_url) + res=send(full_url, container_info) _raise_on_node_error(res, 'update_role') if res.get('success') not in (1, True): @@ -849,11 +827,9 @@ def start_container(container_id:int, operator_user_id:int|None=None)->bool: container_name = get_by_id(container_id).name data = {"config": {"container_name": container_name}} - container_info = json.dumps(data) - signatured_message = signature(container_info) - encryptioned_message = encryption(container_info) + container_info = data - res = send(encryptioned_message, signatured_message, full_url) + res = send(full_url, container_info) logger.debug("start_container: NODE response: %s", res) # Check node-level errors @@ -886,11 +862,9 @@ def stop_container(container_id:int, operator_user_id:int|None=None)->bool: container_name = get_by_id(container_id).name data = {"config": {"container_name": container_name}} - container_info = json.dumps(data) - signatured_message = signature(container_info) - encryptioned_message = encryption(container_info) + container_info = data - res = send(encryptioned_message, signatured_message, full_url) + res = send(full_url, container_info) logger.debug("stop_container: NODE response: %s", res) _raise_on_node_error(res, 'stop') @@ -920,11 +894,9 @@ def restart_container(container_id:int, operator_user_id:int|None=None)->bool: container_name = get_by_id(container_id).name data = {"config": {"container_name": container_name}} - container_info = json.dumps(data) - signatured_message = signature(container_info) - encryptioned_message = encryption(container_info) + container_info = data - res = send(encryptioned_message, signatured_message, full_url) + res = send(full_url, container_info) logger.debug("restart_container: NODE response: %s", res) _raise_on_node_error(res, 'restart') diff --git a/test/conftest.py b/test/conftest.py index dda2486..6e90b9e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -52,13 +52,18 @@ def _safe_test_environment(): @pytest.fixture(scope="session") def app(): - app = create_app(overrides=TEST_CONFIG_OVERRIDES) - _assert_sqlite_database_uri(app) - with app.app_context(): + """FastAPI 迁移后:返回 Flask runtime(legacy 端点 + db context 宿主)。 + + 已迁移到 FastAPI 的端点(如 machine_api)用 TestClient 测;legacy 端点用 flask client。 + """ + fastapi_app = create_app(overrides=TEST_CONFIG_OVERRIDES) + flask_app = fastapi_app.state.flask_app + _assert_sqlite_database_uri(flask_app) + with flask_app.app_context(): from .. import models # noqa: F401 db.create_all() - yield app + yield flask_app db.session.remove() db.drop_all() diff --git a/test/container/conftest.py b/test/container/conftest.py index 3b38b66..487fe3a 100644 --- a/test/container/conftest.py +++ b/test/container/conftest.py @@ -67,11 +67,10 @@ def mock_node_send(monkeypatch): def _install(response): calls.clear() - def _send(ciphertext, signature, url, timeout=5.0): + def _send(url, payload, timeout=5.0): calls.append({ - "ciphertext": ciphertext, - "signature": signature, "url": url, + "payload": payload, "timeout": timeout, }) return dict(response) @@ -82,22 +81,6 @@ def _send(ciphertext, signature, url, timeout=5.0): return _install -@pytest.fixture() -def mock_crypto(monkeypatch): - payloads = [] - - def _signature(payload): - payloads.append(json.loads(payload)) - return b"signature" - - def _encryption(payload): - return payload.encode("utf-8") - - monkeypatch.setattr("FuxiYu_CtrKernel.services.container_tasks.signature", _signature) - monkeypatch.setattr("FuxiYu_CtrKernel.services.container_tasks.encryption", _encryption) - return payloads - - @pytest.fixture() def heartbeat_calls(monkeypatch): calls = {"start": [], "stop": [], "restart": []} diff --git a/test/container/test_container_common.py b/test/container/test_container_common.py index 65bea3c..9b4b7a0 100644 --- a/test/container/test_container_common.py +++ b/test/container/test_container_common.py @@ -14,21 +14,16 @@ def test_container_fixture_creates_root_binding(container_graph): assert getattr(bindings[0]["role"], "value", bindings[0]["role"]) == ROLE.ROOT.value -def test_node_send_mock_records_url_and_payload(mock_node_send, mock_crypto): +def test_node_send_mock_records_url_and_payload(mock_node_send): calls = mock_node_send({"success": 1}) - payload = '{"config": {"container_name": "c1"}}' + payload = {"config": {"container_name": "c1"}} - res = container_tasks.send( - container_tasks.encryption(payload), - container_tasks.signature(payload), - "http://127.0.0.1:5789/api/demo", - timeout=3, - ) + res = container_tasks.send("http://127.0.0.1:5789/api/demo", payload, timeout=3) assert res == {"success": 1} assert calls[0]["url"].endswith("/demo") assert calls[0]["timeout"] == 3 - assert mock_crypto[0]["config"]["container_name"] == "c1" + assert calls[0]["payload"]["config"]["container_name"] == "c1" def test_default_container_tests_do_not_call_requests_post(): diff --git a/test/container/test_container_disk_check_task.py b/test/container/test_container_disk_check_task.py index 2852193..efd89cd 100644 --- a/test/container/test_container_disk_check_task.py +++ b/test/container/test_container_disk_check_task.py @@ -1212,15 +1212,7 @@ def test_clean_mount_immediately_inserts_record(self, app, db_session, monkeypat # mock send to avoid real HTTP monkeypatch.setattr( container_disk_check_task.container_tasks, "send", - lambda enc, sig, url, timeout: {"success": 1} - ) - monkeypatch.setattr( - container_disk_check_task.container_tasks, "signature", - lambda p: b"sig" - ) - monkeypatch.setattr( - container_disk_check_task.container_tasks, "encryption", - lambda p: b"enc" + lambda url, payload, timeout: {"success": 1} ) monkeypatch.setitem(app.config, "CONTAINER_DISK_CHECK_ENABLED", True) @@ -1257,15 +1249,7 @@ def test_clean_mount_immediately_sends_to_node(self, app, db_session, monkeypatc sent_calls = [] monkeypatch.setattr( container_disk_check_task.container_tasks, "send", - lambda enc, sig, url, timeout: sent_calls.append(url) or {"success": 1} - ) - monkeypatch.setattr( - container_disk_check_task.container_tasks, "signature", - lambda p: b"sig" - ) - monkeypatch.setattr( - container_disk_check_task.container_tasks, "encryption", - lambda p: b"enc" + lambda url, payload, timeout: sent_calls.append(url) or {"success": 1} ) monkeypatch.setitem(app.config, "CONTAINER_DISK_CHECK_ENABLED", True) diff --git a/test/container/test_container_mount_cleanup_task.py b/test/container/test_container_mount_cleanup_task.py index fff8f0e..679d5f7 100644 --- a/test/container/test_container_mount_cleanup_task.py +++ b/test/container/test_container_mount_cleanup_task.py @@ -40,15 +40,7 @@ def test_cleans_old_pending_mount(self, app, db_session, monkeypatch): sent_payloads = [] monkeypatch.setattr( container_mount_cleanup_task, "send", - lambda enc, sig, url, timeout: sent_payloads.append(url) or {"success": 1} - ) - monkeypatch.setattr( - container_mount_cleanup_task, "signature", - lambda p: b"sig" - ) - monkeypatch.setattr( - container_mount_cleanup_task, "encryption", - lambda p: b"enc" + lambda url, payload, timeout: sent_payloads.append(url) or {"success": 1} ) # mock machine_repo to return a valid IP monkeypatch.setattr( @@ -113,7 +105,7 @@ def test_continues_on_single_failure(self, app, db_session, monkeypatch): call_count = [0] - def _fail_first(enc, sig, url, timeout): + def _fail_first(url, payload, timeout): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("node unreachable") @@ -122,12 +114,6 @@ def _fail_first(enc, sig, url, timeout): monkeypatch.setattr( container_mount_cleanup_task, "send", _fail_first ) - monkeypatch.setattr( - container_mount_cleanup_task, "signature", lambda p: b"sig" - ) - monkeypatch.setattr( - container_mount_cleanup_task, "encryption", lambda p: b"enc" - ) monkeypatch.setattr( container_mount_cleanup_task.machine_repo, "get_machine_ip_by_id", diff --git a/test/container/test_container_tasks_collaborators.py b/test/container/test_container_tasks_collaborators.py index e910f9b..bedc1e0 100644 --- a/test/container/test_container_tasks_collaborators.py +++ b/test/container/test_container_tasks_collaborators.py @@ -11,7 +11,7 @@ def test_add_collaborator_success_adds_binding_after_node_success( db_session, container_graph, mock_node_send, - mock_crypto, + ): root, _machine, container = container_graph collaborator = create_user(username="collab_user") @@ -85,7 +85,7 @@ def test_remove_collaborator_success_removes_binding_after_node_success( db_session, container_graph_with_collaborator, mock_node_send, - mock_crypto, + ): root, collaborator, _machine, container = container_graph_with_collaborator mock_node_send(NODE_SUCCESS_TRUE) @@ -116,7 +116,7 @@ def test_update_role_success_updates_binding( db_session, container_graph_with_collaborator, mock_node_send, - mock_crypto, + ): root, collaborator, _machine, container = container_graph_with_collaborator mock_node_send(NODE_SUCCESS_TRUE) @@ -137,7 +137,7 @@ def test_update_role_to_root_sets_container_username_root( db_session, container_graph_with_collaborator, mock_node_send, - mock_crypto, + ): root, collaborator, _machine, container = container_graph_with_collaborator mock_node_send(NODE_SUCCESS_TRUE) diff --git a/test/container/test_container_tasks_lifecycle.py b/test/container/test_container_tasks_lifecycle.py index e3c7e11..fa39ceb 100644 --- a/test/container/test_container_tasks_lifecycle.py +++ b/test/container/test_container_tasks_lifecycle.py @@ -13,7 +13,7 @@ def test_create_container_success_sends_node_then_creates_db_record_and_root_bin db_session, container_info, mock_node_send, - mock_crypto, + heartbeat_calls, ): owner = create_user(username="owner_lifecycle") @@ -37,7 +37,7 @@ def test_create_container_success_sends_node_then_creates_db_record_and_root_bin assert bindings[0]["username"] == "root" assert getattr(bindings[0]["role"], "value", bindings[0]["role"]) == ROLE.ROOT.value assert calls[0]["url"].endswith("/create_container") - assert mock_crypto[0]["owner_name"] == owner.username + assert calls[0]["payload"]["owner_name"] == owner.username assert heartbeat_calls["start"] @@ -96,7 +96,7 @@ def test_create_container_rejects_duplicate_name_before_node_write( db_session, container_info, mock_node_send, - mock_crypto, + ): owner = create_user() machine = create_machine() @@ -113,7 +113,7 @@ def test_create_container_node_failure_does_not_create_local_record( db_session, container_info, mock_node_send, - mock_crypto, + ): owner = create_user() machine = create_machine() @@ -131,7 +131,7 @@ def test_create_container_heartbeat_failure_keeps_creation_success( db_session, container_info, mock_node_send, - mock_crypto, + ): owner = create_user() machine = create_machine() @@ -153,7 +153,7 @@ def test_remove_container_success_deletes_bindings_and_container( db_session, container_graph, mock_node_send, - mock_crypto, + node_response, ): root, _machine, container = container_graph @@ -169,7 +169,7 @@ def test_remove_container_node_failed_raises_and_keeps_local_record( db_session, container_graph, mock_node_send, - mock_crypto, + ): root, _machine, container = container_graph mock_node_send(NODE_REMOVE_FAILED) @@ -185,7 +185,7 @@ def test_start_container_success_starts_heartbeat( db_session, container_graph, mock_node_send, - mock_crypto, + heartbeat_calls, ): root, _machine, container = container_graph @@ -200,7 +200,7 @@ def test_stop_container_success_starts_heartbeat( db_session, container_graph, mock_node_send, - mock_crypto, + heartbeat_calls, ): root, _machine, container = container_graph @@ -215,7 +215,7 @@ def test_restart_container_success_marks_offline_and_starts_heartbeat( db_session, container_graph, mock_node_send, - mock_crypto, + heartbeat_calls, ): root, _machine, container = container_graph diff --git a/test/container/test_container_tasks_ssh.py b/test/container/test_container_tasks_ssh.py index 5b87fbc..0dbcac0 100644 --- a/test/container/test_container_tasks_ssh.py +++ b/test/container/test_container_tasks_ssh.py @@ -93,7 +93,7 @@ def test_get_last_ssh_time_normalizes_raw_output_to_iso( db_session, container_graph, mock_node_send, - mock_crypto, + ): """Node 返回 raw last 文本 → Ctrl 归一化为 ISO UTC 存入 DB。""" _root, machine, container = container_graph @@ -112,7 +112,7 @@ def test_get_last_ssh_time_passes_through_iso( db_session, container_graph, mock_node_send, - mock_crypto, + ): """Node 返回已是 ISO 格式 → 直接存储,不重复转换。""" _root, machine, container = container_graph @@ -129,7 +129,7 @@ def test_get_last_ssh_time_not_found_does_not_overwrite( db_session, container_graph, mock_node_send, - mock_crypto, + ): """Node 返回 not_found → 不覆写已有值。""" _root, machine, container = container_graph @@ -158,7 +158,7 @@ def test_get_last_ssh_time_endpoint_404_raises_node_endpoint_not_found( db_session, container_graph, mock_node_send, - mock_crypto, + ): _root, _machine, container = container_graph mock_node_send(NODE_ENDPOINT_404_HTML) diff --git a/test/e2e/test_ctrl_user_machine_container_flow.py b/test/e2e/test_ctrl_user_machine_container_flow.py index 1f88f92..fc33703 100644 --- a/test/e2e/test_ctrl_user_machine_container_flow.py +++ b/test/e2e/test_ctrl_user_machine_container_flow.py @@ -24,7 +24,6 @@ def test_ctrl_e2e_user_login_machine_permission_container_create_and_list( login_resp = client.post("/api/login", json={"username": "e2e_user", "password": "Password_123"}) mocks.mock_node_response(monkeypatch, container_tasks, {"success": 1}) - mocks.mock_container_crypto(monkeypatch, container_tasks) monkeypatch.setattr(node_comms, "is_machine_online_remote", lambda machine_id: True) heartbeat_calls = [] monkeypatch.setattr( diff --git a/test/link/conftest.py b/test/link/conftest.py index dad31e5..4eb587b 100644 --- a/test/link/conftest.py +++ b/test/link/conftest.py @@ -9,6 +9,7 @@ """ import sys import threading +import time from pathlib import Path import pytest @@ -22,7 +23,6 @@ from FuxiYu_NodeKernel import create_app as node_create_app # noqa: E402 from FuxiYu_NodeKernel import extensions as node_ext # noqa: E402 -from FuxiYu_NodeKernel.utils.CheckKeys import KeyConfig # noqa: E402 class _Patcher: @@ -48,13 +48,13 @@ def restore(self): @pytest.fixture(scope="module") def node_server(): - """进程内 Node 服务(真实 Flask 栈 + 真实密钥 + 假 docker)。""" - from werkzeug.serving import make_server + """进程内 Node 服务(真实 FastAPI + uvicorn + 假 docker)。 + + check_keys 已退役:Node 端点直接收明文 JSON(TLS 承担身份,链路测试内用 http)。 + """ + import uvicorn patcher = _Patcher() - patcher.setattr(KeyConfig, "PRIVATE_KEY_PATH", str(NODE_ROOT / "private_A.pem")) - patcher.setattr(KeyConfig, "PUBLIC_KEY_PATH", str(NODE_ROOT / "public_A.pem")) - patcher.setattr(KeyConfig, "PUBLIC_KEY_CONTROL", str(NODE_ROOT / "public_A.pem")) # 假 docker client:Node 端点会直接访问 extensions.docker_client import docker as docker_pkg @@ -75,15 +75,26 @@ def __init__(self): patcher.setattr(node_ext, "docker_client", _FakeDocker()) - app = node_create_app() - app.config.update(TESTING=True) + class _Server(uvicorn.Server): + def install_signal_handlers(self): + pass # 线程内不允许信号处理 - server = make_server("127.0.0.1", 0, app) - port = server.server_port - thread = threading.Thread(target=server.serve_forever, daemon=True) + port = 5788 + server = _Server(uvicorn.Config(node_create_app(), host="127.0.0.1", port=port, log_level="warning")) + thread = threading.Thread(target=server.run, daemon=True) thread.start() + # 等待端口就绪 + import socket + + for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.05) yield f"http://127.0.0.1:{port}" - server.shutdown() + server.should_exit = True + thread.join(timeout=5) patcher.restore() diff --git a/test/link/test_link_roundtrip.py b/test/link/test_link_roundtrip.py index 8258501..4d7bd4b 100644 --- a/test/link/test_link_roundtrip.py +++ b/test/link/test_link_roundtrip.py @@ -1,7 +1,7 @@ """Ctrl ↔ Node 真实链路 roundtrip 测试(integration)。 -链路:Ctrl CheckKeys 加密+签名 → 真实 HTTP(进程内 Node Flask 服务) - → Node 验签+解密 → 服务层(stub)→ 响应 → Ctrl 解析。 +链路:Ctrl HTTPS 明文 JSON(check_keys 已退役,TLS 承担身份)→ 真实 HTTP +(进程内 Node FastAPI 服务)→ 服务层(stub)→ 响应 → Ctrl 解析。 WSS 迁移约定:本文件的断言只依赖 transport 抽象(test/link/transport.py), 迁移时新增 WssNodeLinkTransport 实现,本文件不用改。 @@ -9,25 +9,14 @@ 运行:WSL 下 `pytest test/link -m integration`;默认集排除(需要跨仓库)。 """ import time -from pathlib import Path import pytest -from FuxiYu_CtrKernel.utils.CheckKeys import KeyConfig as CtrlKeyConfig - from .conftest import NODE_ROOT, node_pkg # noqa: F401 (导入 conftest 以完成跨仓库准备) pytestmark = pytest.mark.integration -CTRL_ROOT = Path(__file__).resolve().parents[2] - -node_blueprints = node_pkg.blueprints - - -@pytest.fixture() -def ctrl_key_paths(monkeypatch): - monkeypatch.setattr(CtrlKeyConfig, "PRIVATE_KEY_PATH", str(CTRL_ROOT / "private_A.pem")) - monkeypatch.setattr(CtrlKeyConfig, "PUBLIC_KEY_PATH", str(CTRL_ROOT / "public_A.pem")) +node_service = node_pkg.services.container_service VALID_CFG = { @@ -41,15 +30,15 @@ def ctrl_key_paths(monkeypatch): } -def test_create_container_roundtrip(ctrl_key_paths, node_transport, monkeypatch): - """真实方向:Ctrl 构造创建指令 → Node 验签解密 → stub 服务层 → 响应。""" +def test_create_container_roundtrip(node_transport, monkeypatch): + """真实方向:Ctrl 构造创建指令 → Node 服务层(stub)→ 响应。""" calls = [] def _stub(owner_name, cfg, public_key=None): calls.append((owner_name, cfg.name, public_key)) return node_pkg.services.container_service.CreateContainerReturn("cid123", cfg.name) - monkeypatch.setattr(node_blueprints, "create_container", _stub) + monkeypatch.setattr(node_service, "create_container", _stub) payload = {"owner_name": "admin", "config": VALID_CFG} res = node_transport.post("/api/create_container", payload) @@ -62,29 +51,9 @@ def _stub(owner_name, cfg, public_key=None): assert calls[0] == ("admin", "link_c", None) -def test_invalid_signature_rejected_by_node(ctrl_key_paths, node_transport, monkeypatch): - """链路级安全断言:错误签名在 Node 端被拒(401),不触达服务层。""" - calls = [] - - monkeypatch.setattr(node_blueprints, "create_container", lambda *a, **k: calls.append(a) or True) - - import base64 - - from FuxiYu_CtrKernel.services import container_tasks - - # 用 Ctrl 的 send 发一个签名伪造的请求(真实 wire 格式 + 假签名) - res = container_tasks.send( - b"not-encrypted", - base64.b64decode(base64.b64encode(b"x" * 256)), - _node_base_url(node_transport) + "/api/create_container", - ) - assert res.get("error_reason") == "invalid_signature" - assert calls == [] - - -def test_add_collaborator_roundtrip_echo(ctrl_key_paths, node_transport, monkeypatch): +def test_add_collaborator_roundtrip_echo(node_transport, monkeypatch): """消息层闭环锚点:Node 回显 decrypted_message == Ctrl 发送的原始 dict。""" - monkeypatch.setattr(node_blueprints, "add_collaborator", lambda *a: True) + monkeypatch.setattr(node_service, "add_collaborator", lambda *a: True) payload = {"config": {"container_name": "link_c", "user_name": "u1", "role": "admin"}} res = node_transport.post("/api/add_collaborator", payload) @@ -93,7 +62,7 @@ def test_add_collaborator_roundtrip_echo(ctrl_key_paths, node_transport, monkeyp assert res.get("decrypted_message") == payload -def test_container_status_roundtrip(ctrl_key_paths, node_transport, monkeypatch): +def test_container_status_roundtrip(node_transport, monkeypatch): """真实链路下 container_status 的 online 判定(fake 容器 + exec 成功 → online)。""" from FuxiYu_NodeKernel import extensions as node_ext @@ -129,7 +98,3 @@ def __init__(self): assert res.get("success") == 1 assert res.get("container_status") == "online" - - -def _node_base_url(transport) -> str: - return transport.base_url diff --git a/test/link/transport.py b/test/link/transport.py index cc609b2..dbb2a2f 100644 --- a/test/link/transport.py +++ b/test/link/transport.py @@ -1,8 +1,8 @@ """Ctrl ↔ Node 传输层抽象。 设计目标:WSS 迁移时的"可换壳"。 -- 消息层协议(加密 + 签名 + 请求/响应语义)不变,测试断言不变 -- 今天 `HttpNodeLinkTransport` 走 HTTP POST;WSS 落地后新增 +- 消息层协议(HTTPS + TLS 承载身份,check_keys 信封已退役)——测试断言不变 +- 今天 `HttpNodeLinkTransport` 走 HTTPS POST;WSS 落地后新增 `WssNodeLinkTransport` 实现同一接口,链路测试文件不用改 接口约定: @@ -12,21 +12,16 @@ import json from FuxiYu_CtrKernel.services import container_tasks -from FuxiYu_CtrKernel.utils.CheckKeys import encryption, signature class HttpNodeLinkTransport: - """基于当前 HTTP + RSA/AES 消息层协议的实现。""" + """基于当前 HTTPS + 明文 JSON 的实现(TLS 承载身份)。""" def __init__(self, base_url: str): self.base_url = base_url.rstrip("/") def post(self, endpoint: str, payload: dict, timeout: float = 5.0) -> dict: - raw = json.dumps(payload) - enc = encryption(raw) - sig = signature(raw) - # container_tasks.send 的内部 wire 格式就是 {"message": b64, "signature": b64} - return container_tasks.send(enc, sig, f"{self.base_url}{endpoint}", timeout=timeout) + return container_tasks.send(f"{self.base_url}{endpoint}", payload, timeout=timeout) # WSS 迁移占位:届时实现 diff --git a/test/mocks.py b/test/mocks.py index cf2a73d..275361c 100644 --- a/test/mocks.py +++ b/test/mocks.py @@ -30,20 +30,6 @@ def _send(*args, **kwargs): return calls -def mock_container_crypto(monkeypatch, module): - payloads = [] - - def _signature(payload): - payloads.append(json.loads(payload)) - return b"signature" - - def _encryption(payload): - return payload.encode("utf-8") - - monkeypatch.setattr(module, "signature", _signature) - monkeypatch.setattr(module, "encryption", _encryption) - return payloads - def mock_mail_success(monkeypatch, module): calls = [] diff --git a/utils/CheckKeys.py b/utils/CheckKeys.py deleted file mode 100644 index c578326..0000000 --- a/utils/CheckKeys.py +++ /dev/null @@ -1,171 +0,0 @@ -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey -from cryptography.hazmat.primitives.asymmetric import rsa -from ..config import KeyConfig -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -import json -import base64 -import os -# 加载公钥和私钥,返回公钥和私钥对象 -def load_keys(private_key_path:str,pub_key_path:str,pub_key_node_path)->tuple[RSAPrivateKey,RSAPublicKey,RSAPublicKey]: - with open(private_key_path, "rb") as f: - private_key_A = serialization.load_pem_private_key( - f.read(), - password=None, # 如果加密过,就填密码 - ) - - # 加载公钥 - with open(pub_key_path, "rb") as f: - public_key_A = serialization.load_pem_public_key(f.read()) - with open(pub_key_node_path,"rb") as f: - public_node = serialization.load_pem_public_key(f.read()) - - return (private_key_A,public_key_A,public_node) - -def generate_keys()->tuple[RSAPrivateKey,RSAPublicKey]: - private_key_A = rsa.generate_private_key(public_exponent=65537, key_size=2048) - public_key_A = private_key_A.public_key() - return (private_key_A,public_key_A) - -def write_keys(path:str,key): - # 保存私钥到文件 - if type(key)==RSAPrivateKey: - with open(path, "wb") as f: - f.write( - key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, # 通用私钥格式 - encryption_algorithm=serialization.NoEncryption() - ) - ) - elif type(key)==RSAPublicKey: - with open(path, "wb") as f: - f.write( - key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo # 标准公钥格式 - ) - ) - -#加密信息 -def encryption(message:str)->bytes: - # Hybrid encryption: AES-GCM for message, RSA-OAEP to encrypt AES key - _,_,PUBLIC_KEY_B = load_keys(KeyConfig.PRIVATE_KEY_PATH, KeyConfig.PUBLIC_KEY_PATH, KeyConfig.PUBLIC_KEY_PATH) - if isinstance(message, str): - message = message.encode('utf-8') - # generate AES key - aes_key = AESGCM.generate_key(bit_length=128) - aesgcm = AESGCM(aes_key) - nonce = os.urandom(12) - ciphertext = aesgcm.encrypt(nonce, message, None) - # encrypt aes_key with RSA - enc_key = PUBLIC_KEY_B.encrypt( - aes_key, - padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), - label=None) - ) - payload = { - "enc_key": base64.b64encode(enc_key).decode('utf-8'), - "nonce": base64.b64encode(nonce).decode('utf-8'), - "ciphertext": base64.b64encode(ciphertext).decode('utf-8') - } - return json.dumps(payload).encode('utf-8') - -#签名信息 -def signature(message:str)->bytes: - PRIVATE_KEY_A,_,_=load_keys(KeyConfig.PRIVATE_KEY_PATH,KeyConfig.PUBLIC_KEY_PATH,KeyConfig.PUBLIC_KEY_PATH) - # 将字符串编码为 bytes - message_bytes = message.encode('utf-8') if isinstance(message, str) else message - signature = PRIVATE_KEY_A.sign( - message_bytes, - padding.PSS(mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH), - hashes.SHA256() - ) - return signature - -#解密信息 -def decryption(ciphertext:bytes)->bytes: - PRIVATE_KEY_A,_,_=load_keys(KeyConfig.PRIVATE_KEY_PATH,KeyConfig.PUBLIC_KEY_PATH,KeyConfig.PUBLIC_KEY_PATH) - # Try hybrid format (JSON with enc_key/nonce/ciphertext) - try: - raw = ciphertext.decode('utf-8') - payload = json.loads(raw) - enc_key = base64.b64decode(payload.get('enc_key')) - nonce = base64.b64decode(payload.get('nonce')) - ct = base64.b64decode(payload.get('ciphertext')) - # decrypt AES key - aes_key = PRIVATE_KEY_A.decrypt( - enc_key, - padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), - label=None) - ) - aesgcm = AESGCM(aes_key) - plaintext = aesgcm.decrypt(nonce, ct, None) - return plaintext - except Exception: - # fallback: try legacy RSA decrypt (for backward compatibility) - try: - plaintext = PRIVATE_KEY_A.decrypt( - ciphertext, - padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), - label=None) - ) - return plaintext - except Exception as e: - raise - -#验证签名 -def verify_signature(message:bytes, signature:bytes)->bool: - _,_,PUBLIC_KEY_B=load_keys(KeyConfig.PRIVATE_KEY_PATH,KeyConfig.PUBLIC_KEY_PATH,KeyConfig.PUBLIC_KEY_PATH) - try: - PUBLIC_KEY_B.verify( - signature, - message, - padding.PSS(mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH), - hashes.SHA256() - ) - return True - except Exception: - return False - -def get_verified_msg(recived_message:dict)->dict: - """ - 解密并验证签名的消息 - :param recived_message: 包含加密消息和签名的字典 {"message": bytes/str, "signature": bytes/str} - :return: 验证成功返回解密后的字典,失败返回空字典 - """ - try: - # 提取加密消息和签名 - encrypted_msg = recived_message.get("message") - signature_data = recived_message.get("signature") - - if not encrypted_msg or not signature_data: - return {} - - # 与 Node 端保持一致:wire 格式是 base64 字符串,先解码再处理 - if isinstance(encrypted_msg, str): - encrypted_msg = base64.b64decode(encrypted_msg) - if isinstance(signature_data, str): - signature_data = base64.b64decode(signature_data) - - # 解密消息 - decrypted_msg = decryption(encrypted_msg) - - # 验证签名 - if not verify_signature(decrypted_msg, signature_data): - return {} - - # 将解密后的消息转换为字典 - message_dict = json.loads(decrypted_msg.decode('utf-8')) - - return message_dict - except Exception as e: - # 任何异常都返回空字典 - return {} \ No newline at end of file diff --git a/utils/heartbeat.py b/utils/heartbeat.py index be3355b..d048ddb 100644 --- a/utils/heartbeat.py +++ b/utils/heartbeat.py @@ -1,11 +1,6 @@ import threading import time -import json -import base64 -import requests -from ..config import CommsConfig -from ..utils.CheckKeys import signature, encryption from ..repositories.containers_repo import update_container, list_containers as repo_list_containers from ..repositories.machine_repo import get_by_id as get_machine_by_id, update_machine from ..constant import ContainerStatus, MachineStatus, OperationType @@ -32,20 +27,11 @@ def _log_machine_status_transition(mid: int, new_status: MachineStatus) -> None: def send(machine_ip: str, endpoint: str, payload: dict, timeout: float = 5.0): - url = f"http://{machine_ip}{CommsConfig.NODE_URL_MIDDLE}{endpoint}" - body = json.dumps(payload) - sig = signature(body) - enc = encryption(body) + """HTTPS 明文 POST(check_keys 已退役,TLS 承载身份)。""" + from ..services.container_module.node_comms import get_full_url, send as node_comms_send + url = get_full_url(machine_ip, endpoint) try: - resp = requests.post(url, json={ - "message": base64.b64encode(enc).decode('utf-8'), - "signature": base64.b64encode(sig).decode('utf-8') - }, timeout=timeout) - resp.raise_for_status() - try: - return resp.json() - except ValueError: - return {"text": resp.text, "status_code": resp.status_code} + return node_comms_send(url, payload, timeout=timeout) except Exception as e: return {"error": str(e)} From 0565ec50b3156ce505b782828aafca99c5abe15e Mon Sep 17 00:00:00 2001 From: chester Date: Fri, 21 Aug 2026 01:23:41 +0800 Subject: [PATCH 06/63] =?UTF-8?q?-=20REFACTOR=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E4=BA=86FastAPI=E5=92=8CWSS=E7=9A=84=E8=BF=81=E7=A7=BB=20-=20I?= =?UTF-8?q?NTEGRATION=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E4=BA=86wss=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=9A=84"?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E6=B3=A8=E5=86=8C"=E3=80=81"=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E4=BC=A0=E8=BE=93"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 52 +- api/__init__.py | 19 +- api/announcement_api.py | 837 +++++----- api/container_api.py | 1363 ++++++++--------- api/deps.py | 53 + api/machine_api.py | 29 +- api/user_api.py | 9 +- models/__init__.py | 5 + models/auth_entity.py | 15 + models/auth_group.py | 31 + models/user_group.py | 12 + models/userimage.py | 22 + repositories/auth_repo.py | 91 ++ repositories/containers_repo.py | 17 +- repositories/user_repo.py | 4 + run_wss.py | 59 +- schedulers/container_disk_check_task.py | 61 +- schedulers/container_ssh_refresh_task.py | 112 -- schemas/__init__.py | 59 +- schemas/container.py | 237 +++ schemas/machine.py | 15 +- schemas/operation_log.py | 3 + services/container_module/node_comms.py | 87 +- services/container_tasks.py | 351 +---- services/rbac_service.py | 150 ++ services/user_tasks.py | 1 - test/announcement/test_api.py | 60 +- test/assertions.py | 11 +- test/conftest.py | 55 +- test/container/conftest.py | 17 +- .../test_container_api_collaborators.py | 6 +- .../test_container_api_information.py | 6 +- .../container/test_container_api_lifecycle.py | 6 +- .../container/test_container_api_long_term.py | 6 +- test/container/test_container_common.py | 12 - .../test_container_ssh_refresh_task.py | 46 - .../test_container_tasks_information.py | 76 +- .../test_container_tasks_lifecycle.py | 33 +- test/container/test_container_tasks_ssh.py | 75 +- .../test_ctrl_user_machine_container_flow.py | 9 +- .../test_machine_enrollment_wss.py | 324 ++++ test/machine/test_machine_api_crud.py | 6 +- test/machine/test_machine_api_listing.py | 16 +- test/machine/test_machine_api_permission.py | 12 +- test/mocks.py | 9 +- test/operation_log/test_operation_log_api.py | 6 +- test/test_api_web_containers.py | 36 +- test/test_api_web_machine.py | 22 +- test/test_safe_pytest_app.py | 11 +- test/test_testing_platform.py | 4 +- test/test_testing_safety_contract.py | 2 +- test/user/test_user_api_auth.py | 2 +- test/user/test_user_api_profile.py | 12 +- utils/heartbeat.py | 150 -- 54 files changed, 2559 insertions(+), 2165 deletions(-) create mode 100644 models/auth_entity.py create mode 100644 models/auth_group.py create mode 100644 models/user_group.py create mode 100644 models/userimage.py create mode 100644 repositories/auth_repo.py delete mode 100644 schedulers/container_ssh_refresh_task.py create mode 100644 schemas/container.py create mode 100644 services/rbac_service.py delete mode 100644 test/container/test_container_ssh_refresh_task.py create mode 100644 test/integration/test_machine_enrollment_wss.py diff --git a/__init__.py b/__init__.py index f3bef4c..9533d34 100644 --- a/__init__.py +++ b/__init__.py @@ -7,8 +7,10 @@ _DOTENV_PATH = Path(__file__).resolve().parent / ".env" load_dotenv(_DOTENV_PATH, override=True) -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from flask import Flask from flask_cors import CORS @@ -20,7 +22,6 @@ from .extensions import db from .schedulers.container_cleanup_task import start_container_cleanup_scheduler from .schedulers.container_mount_cleanup_task import start_mount_cleanup_scheduler -from .schedulers.container_ssh_refresh_task import start_container_ssh_refresh_scheduler from .utils.logging_config import configure_daily_logging @@ -54,6 +55,14 @@ def _create_flask_runtime_app( db.create_all() + # RBAC 预设组/权限点 seed(幂等);TESTING 下也执行,保证新库可用 + try: + from .services.rbac_service import seed_rbac_defaults + seed_rbac_defaults() + except Exception as e: # 建表/seed 异常不应阻断启动(老库无新表时兜底) + import logging + logging.getLogger(__name__).warning("rbac seed skipped: %s", e) + if register_legacy_routes: register_legacy_api(flask_app) @@ -72,7 +81,6 @@ def _start_background_tasks(flask_app: Flask) -> None: 任务内部仍按 Flask app context 编写;FastAPI lifespan 只负责启动位置迁移。 """ - start_container_ssh_refresh_scheduler(flask_app, interval_seconds=300) start_container_cleanup_scheduler(flask_app, interval_seconds=1200) start_mount_cleanup_scheduler(flask_app) @@ -104,6 +112,44 @@ async def lifespan(_: FastAPI): allow_headers=["*"], ) + @app.exception_handler(RequestValidationError) + async def _validation_error_handler(request: Request, exc: RequestValidationError): + errors = exc.errors() + reason = "invalid_payload" + fields = { + str(part) + for error in errors + for part in error.get("loc", ()) + if part not in ("body", "query", "path") + } + if any(error.get("type") == "json_invalid" for error in errors): + reason = "invalid_json" + elif request.url.path.endswith("/users/get_user_detail_information") and "user_id" in fields: + reason = "missing_user_id" + elif request.url.path.endswith("/request_register_code") and "email" in fields: + reason = "missing_email" + elif request.url.path.endswith("/machines/add_machine_permission") and {"machine_id", "user_id"} & fields: + reason = "missing_fields" + return JSONResponse( + status_code=400, + content={ + "success": 0, + "message": "invalid request payload", + "error_reason": reason, + "detail": errors, + }, + ) + + @app.exception_handler(HTTPException) + async def _http_exception_handler(_: Request, exc: HTTPException): + if isinstance(exc.detail, dict) and "success" in exc.detail: + return JSONResponse(status_code=exc.status_code, content=exc.detail, headers=exc.headers) + return JSONResponse( + status_code=exc.status_code, + content={"success": 0, "message": str(exc.detail), "error_reason": None}, + headers=exc.headers, + ) + register_api(app) # 未迁移 API 兜底。必须最后挂载,让 FastAPI 已迁移路由优先匹配。 diff --git a/api/__init__.py b/api/__init__.py index 1411ee3..09c0cac 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -5,30 +5,29 @@ api_bp = Blueprint("api", __name__, url_prefix="/api") # 已迁移到 FastAPI 的路由 +from . import announcement_api +from . import container_api from . import machine_api from . import operation_log_api from . import user_api +router.include_router(announcement_api.router) +router.include_router(container_api.router) router.include_router(machine_api.router) router.include_router(operation_log_api.router) router.include_router(user_api.router) -# 尚未迁移的 Flask API 模块,继续挂在 legacy Blueprint 上。 -from . import container_api -from . import announcement_api - - def register_api(app): - """注册 Ctrl FastAPI 路由。""" + """注册 Ctrl FastAPI 路由。""" - app.include_router(router) + app.include_router(router) def register_legacy_api(app): - """注册尚未迁移的 Flask Blueprint 路由。""" + """注册尚未迁移的 Flask Blueprint 路由。""" - app.register_blueprint(api_bp) + app.register_blueprint(api_bp) -# 兼容旧调用名,后续整体切完 FastAPI 时再清。 +# 兼容旧调用名 register_blueprints = register_legacy_api diff --git a/api/announcement_api.py b/api/announcement_api.py index 46ed731..d01a22c 100644 --- a/api/announcement_api.py +++ b/api/announcement_api.py @@ -1,485 +1,454 @@ """公告系统 API 路由。 -全部端点要求 Operator 权限。认证模式沿用项目现有风格: -- token 来自请求头 -- authentications_repo.is_token_valid + user_repo.check_permission +全部端点要求 Operator 权限;认证由 FastAPI dependency 完成。 """ -from flask import jsonify, request - -from ..constant import AnnouncementStatus, AnnouncementTemplateCategory, PERMISSION -from ..repositories import announcement_repo, authentications_repo, user_repo -from ..services import announcement_tasks - -from . import api_bp - - -# ── 工具函数 ────────────────────────────────────────────────────────── - - -def _get_token() -> str: - """从请求中提取 token(cookie)。""" - return request.cookies.get("auth_token", "") - - -def _require_operator(): - """校验 Operator 权限;通过返回 None,失败返回 (response, status_code)。""" - token = _get_token() - if not authentications_repo.is_token_valid(token): - return jsonify({"success": 0, "message": "invalid token", "error_reason": "invalid_token"}), 401 - if not user_repo.check_permission(token, required_permission=PERMISSION.OPERATOR): - return jsonify({"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}), 403 - return None +from typing import Any +from fastapi import APIRouter, Body, Depends, Query, Request +from fastapi.responses import JSONResponse -def _current_user_id() -> int: - """返回当前 token 对应的 user_id。调用前必须已通过 _require_operator 校验。""" - return authentications_repo.get_user_id_by_token(_get_token()) - - -# ── 模板 CRUD ───────────────────────────────────────────────────────── +from ..constant import AnnouncementStatus, AnnouncementTemplateCategory +from ..models.announcement import Announcement as AnnouncementModel +from ..repositories import announcement_repo +from ..services import announcement_tasks +from .deps import require_operator +router = APIRouter(prefix="/announcements", tags=["announcements"]) -@api_bp.get("/announcements/templates") -def list_templates_api(): - err = _require_operator() - if err: - return err - category = request.args.get("category") - limit = request.args.get("limit", 100, type=int) - offset = request.args.get("offset", 0, type=int) - rows, total = announcement_repo.list_templates(category=category, limit=limit, offset=offset) - return jsonify( - { - "success": 1, - "templates": [ - { - "id": t.id, - "name": t.name, - "category": t.category.value if hasattr(t.category, "value") else t.category, - "description": t.description, - "subject_template": t.subject_template, - "body_template": t.body_template, - "source_announcement_id": t.source_announcement_id, - "created_by": t.created_by, - "created_at": t.created_at.isoformat() if t.created_at else None, - "updated_at": t.updated_at.isoformat() if t.updated_at else None, - } - for t in rows - ], - "total": total, - } - ), 200 +def _error(status_code: int, message: str, error_reason: str) -> JSONResponse: + """返回 Ctrl 现有错误结构。""" + return JSONResponse( + status_code=status_code, + content={"success": 0, "message": message, "error_reason": error_reason}, + ) -@api_bp.post("/announcements/templates") -def create_template_api(): - err = _require_operator() - if err: - return err - data = request.get_json(silent=True) or {} - name = data.get("name") - subject_template = data.get("subject_template") - body_template = data.get("body_template") +def _model_data(model, *, exclude_none: bool = False) -> dict[str, Any]: + """兼容 Pydantic v1/v2 的 model -> dict。""" + + if hasattr(model, "model_dump"): + return model.model_dump(exclude_none=exclude_none) + return model.dict(exclude_none=exclude_none) + + +def _template_view(template) -> dict[str, Any]: + return { + "id": template.id, + "name": template.name, + "category": template.category.value if hasattr(template.category, "value") else template.category, + "description": template.description, + "subject_template": template.subject_template, + "body_template": template.body_template, + "source_announcement_id": template.source_announcement_id, + "created_by": template.created_by, + "created_at": template.created_at.isoformat() if template.created_at else None, + "updated_at": template.updated_at.isoformat() if template.updated_at else None, + } + + +def _announcement_view(announcement) -> dict[str, Any]: + return { + "id": announcement.id, + "title": announcement.title, + "content": announcement.content, + "raw_content": announcement.raw_content, + "created_by": announcement.created_by, + "status": announcement.status.value if hasattr(announcement.status, "value") else announcement.status, + "targets": announcement.targets, + "target_snapshot": announcement.target_snapshot, + "recipient_count": announcement.recipient_count, + "success_count": announcement.success_count, + "fail_count": announcement.fail_count, + "created_at": announcement.created_at.isoformat() if announcement.created_at else None, + "sent_at": announcement.sent_at.isoformat() if announcement.sent_at else None, + "source_draft_id": announcement.source_draft_id, + "template_id": announcement.template_id, + } + + +def _draft_view(draft) -> dict[str, Any]: + return { + "id": draft.id, + "title": draft.title, + "content": draft.content, + "raw_content": draft.raw_content, + "created_by": draft.created_by, + "targets": draft.targets, + "template_id": draft.template_id, + "created_at": draft.created_at.isoformat() if draft.created_at else None, + "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, + } + + +@router.get("/templates") +def list_templates_api( + request: Request, + category: str | None = Query(default=None), + limit: int = Query(default=100, ge=1), + offset: int = Query(default=0, ge=0), + _: int = Depends(require_operator), +): + """列出公告模板。""" + + with request.app.state.flask_app.app_context(): + rows, total = announcement_repo.list_templates(category=category, limit=limit, offset=offset) + return {"success": 1, "templates": [_template_view(t) for t in rows], "total": total} + + +@router.post("/templates") +async def create_template_api( + request: Request, + payload: dict[str, Any] = Body(default_factory=dict), + operator_user_id: int = Depends(require_operator), +): + """创建公告模板。""" + + name = payload.get("name") + subject_template = payload.get("subject_template") + body_template = payload.get("body_template") if not name: - return jsonify({"success": 0, "message": "name is required", "error_reason": "missing_field"}), 400 + return _error(400, "name is required", "missing_field") if not subject_template or not body_template: - return jsonify({"success": 0, "message": "subject_template and body_template are required", "error_reason": "missing_field"}), 400 + return _error(400, "subject_template and body_template are required", "missing_field") try: - template = announcement_repo.create_template( - name=name, - subject_template=subject_template, - body_template=body_template, - created_by=_current_user_id(), - description=data.get("description"), - category=data.get("category", "custom"), - ) + with request.app.state.flask_app.app_context(): + template = announcement_repo.create_template( + name=name, + subject_template=subject_template, + body_template=body_template, + created_by=operator_user_id, + description=payload.get("description"), + category=payload.get("category", "custom"), + ) + template_data = _template_view(template) except Exception: - return jsonify({"success": 0, "message": "template name may already exist", "error_reason": "duplicate_entry"}), 409 - - return jsonify( - { - "success": 1, - "template": { - "id": template.id, - "name": template.name, - "category": template.category.value if hasattr(template.category, "value") else template.category, - "description": template.description, - "subject_template": template.subject_template, - "body_template": template.body_template, - }, - } - ), 200 + return _error(409, "template name may already exist", "duplicate_entry") + return {"success": 1, "template": template_data} -@api_bp.get("/announcements/templates/") -def get_template_api(template_id: int): - err = _require_operator() - if err: - return err - template = announcement_repo.get_template_by_id(template_id) - if template is None: - return jsonify({"success": 0, "message": "template not found", "error_reason": "not_found"}), 404 - return jsonify( - { - "success": 1, - "template": { - "id": template.id, - "name": template.name, - "category": template.category.value if hasattr(template.category, "value") else template.category, - "description": template.description, - "subject_template": template.subject_template, - "body_template": template.body_template, - "source_announcement_id": template.source_announcement_id, - "created_by": template.created_by, - "created_at": template.created_at.isoformat() if template.created_at else None, - "updated_at": template.updated_at.isoformat() if template.updated_at else None, - }, - } - ), 200 - - -@api_bp.put("/announcements/templates/") -def update_template_api(template_id: int): - err = _require_operator() - if err: - return err - data = request.get_json(silent=True) or {} - template = announcement_repo.update_template( - template_id, - **{k: v for k, v in data.items() if v is not None}, - ) - if template is None: - return jsonify({"success": 0, "message": "template not found", "error_reason": "not_found"}), 404 - return jsonify( - { - "success": 1, - "template": { - "id": template.id, - "name": template.name, - "category": template.category.value if hasattr(template.category, "value") else template.category, - }, - } - ), 200 +@router.get("/templates/{template_id}") +def get_template_api( + request: Request, + template_id: int, + _: int = Depends(require_operator), +): + """查看单个模板。""" -@api_bp.delete("/announcements/templates/") -def delete_template_api(template_id: int): - err = _require_operator() - if err: - return err - template = announcement_repo.get_template_by_id(template_id) + with request.app.state.flask_app.app_context(): + template = announcement_repo.get_template_by_id(template_id) + if template is not None: + template_data = _template_view(template) if template is None: - return jsonify({"success": 0, "message": "template not found", "error_reason": "not_found"}), 404 - if template.category == AnnouncementTemplateCategory.SYSTEM: - return jsonify({"success": 0, "message": "cannot delete system template", "error_reason": "cannot_delete_system_template"}), 400 - announcement_repo.delete_template(template_id) - return jsonify({"success": 1, "message": "template deleted"}), 200 - - -# ── 目标解析 ────────────────────────────────────────────────────────── - - -@api_bp.post("/announcements/resolve-targets") -def resolve_targets_api(): - err = _require_operator() - if err: - return err - data = request.get_json(silent=True) or {} - raw_targets = data.get("targets") or [] + return _error(404, "template not found", "not_found") + return {"success": 1, "template": template_data} + + +@router.put("/templates/{template_id}") +async def update_template_api( + request: Request, + template_id: int, + payload: dict[str, Any] = Body(default_factory=dict), + _: int = Depends(require_operator), +): + """更新模板。""" + + with request.app.state.flask_app.app_context(): + template = announcement_repo.update_template( + template_id, + **{k: v for k, v in payload.items() if v is not None}, + ) + if template is not None: + template_data = _template_view(template) + if template is None: + return _error(404, "template not found", "not_found") + return {"success": 1, "template": template_data} + + +@router.delete("/templates/{template_id}") +def delete_template_api( + request: Request, + template_id: int, + _: int = Depends(require_operator), +): + """删除模板。""" + + with request.app.state.flask_app.app_context(): + template = announcement_repo.get_template_by_id(template_id) + if template is None: + return _error(404, "template not found", "not_found") + if template.category == AnnouncementTemplateCategory.SYSTEM: + return _error(400, "cannot delete system template", "cannot_delete_system_template") + announcement_repo.delete_template(template_id) + return {"success": 1, "message": "template deleted"} + + +@router.post("/resolve-targets") +async def resolve_targets_api( + request: Request, + payload: dict[str, Any] = Body(default_factory=dict), + _: int = Depends(require_operator), +): + """解析公告目标。""" + + raw_targets = payload.get("targets") or [] if not raw_targets: - return jsonify({"success": 0, "message": "targets must not be empty", "error_reason": "empty_targets"}), 400 + return _error(400, "targets must not be empty", "empty_targets") - targets = [announcement_tasks.TargetEntry(**t) for t in raw_targets] try: - result = announcement_tasks.resolve_recipients(targets) + with request.app.state.flask_app.app_context(): + targets = [announcement_tasks.TargetEntry(**target) for target in raw_targets] + result = announcement_tasks.resolve_recipients(targets) except ValueError as e: - return jsonify({"success": 0, "message": str(e), "error_reason": str(e)}), 400 - - return jsonify( - { - "success": 1, - "recipient_count": result.total_count, - "summary": [s.model_dump() for s in result.summary], - "preview_emails": [r.email for r in result.recipients[:10]], - } - ), 200 - - -# ── 公告(已发送)查询与操作 ────────────────────────────────────────── - - -@api_bp.get("/announcements/list") -def list_announcements_api(): - err = _require_operator() - if err: - return err - status = request.args.getlist("status") or None - limit = request.args.get("limit", 50, type=int) - offset = request.args.get("offset", 0, type=int) - - rows, total = announcement_repo.list_announcements(status=status, limit=limit, offset=offset) - - # 分别统计各类状态数量 - from ..models.announcement import Announcement as _Ann - sent_count = _Ann.query.filter_by(status=AnnouncementStatus.SENT).count() - partial_count = _Ann.query.filter_by(status=AnnouncementStatus.PARTIAL).count() - failed_count = _Ann.query.filter_by(status=AnnouncementStatus.FAILED).count() - - return jsonify( - { - "success": 1, - "announcements": [ - { - "id": a.id, - "title": a.title, - "content": a.content, - "raw_content": a.raw_content, - "created_by": a.created_by, - "status": a.status.value if hasattr(a.status, "value") else a.status, - "targets": a.targets, - "target_snapshot": a.target_snapshot, - "recipient_count": a.recipient_count, - "success_count": a.success_count, - "fail_count": a.fail_count, - "created_at": a.created_at.isoformat() if a.created_at else None, - "sent_at": a.sent_at.isoformat() if a.sent_at else None, - "source_draft_id": a.source_draft_id, - "template_id": a.template_id, - } - for a in rows - ], - "total": total, - "sent_count": sent_count, - "partial_count": partial_count, - "failed_count": failed_count, - } - ), 200 - - -@api_bp.get("/announcements/") -def get_announcement_api(announcement_id: int): - err = _require_operator() - if err: - return err - ann = announcement_repo.get_announcement_by_id(announcement_id) + return _error(400, str(e), str(e)) + + return { + "success": 1, + "recipient_count": result.total_count, + "summary": [_model_data(item) for item in result.summary], + "preview_emails": [recipient.email for recipient in result.recipients[:10]], + } + + +@router.get("/list") +def list_announcements_api( + request: Request, + status: list[str] | None = Query(default=None), + limit: int = Query(default=50, ge=1), + offset: int = Query(default=0, ge=0), + _: int = Depends(require_operator), +): + """分页查询公告。""" + + with request.app.state.flask_app.app_context(): + rows, total = announcement_repo.list_announcements(status=status, limit=limit, offset=offset) + announcements = [_announcement_view(row) for row in rows] + sent_count = AnnouncementModel.query.filter_by(status=AnnouncementStatus.SENT).count() + partial_count = AnnouncementModel.query.filter_by(status=AnnouncementStatus.PARTIAL).count() + failed_count = AnnouncementModel.query.filter_by(status=AnnouncementStatus.FAILED).count() + + return { + "success": 1, + "announcements": announcements, + "total": total, + "sent_count": sent_count, + "partial_count": partial_count, + "failed_count": failed_count, + } + + +@router.get("/{announcement_id:int}") +def get_announcement_api( + request: Request, + announcement_id: int, + _: int = Depends(require_operator), +): + """查看单个公告。""" + + with request.app.state.flask_app.app_context(): + ann = announcement_repo.get_announcement_by_id(announcement_id) + if ann is not None: + announcement_data = _announcement_view(ann) if ann is None: - return jsonify({"success": 0, "message": "announcement not found", "error_reason": "not_found"}), 404 - return jsonify( - { - "success": 1, - "announcement": { - "id": ann.id, - "title": ann.title, - "content": ann.content, - "raw_content": ann.raw_content, - "created_by": ann.created_by, - "status": ann.status.value if hasattr(ann.status, "value") else ann.status, - "targets": ann.targets, - "target_snapshot": ann.target_snapshot, - "recipient_count": ann.recipient_count, - "success_count": ann.success_count, - "fail_count": ann.fail_count, - "created_at": ann.created_at.isoformat() if ann.created_at else None, - "sent_at": ann.sent_at.isoformat() if ann.sent_at else None, - "source_draft_id": ann.source_draft_id, - "template_id": ann.template_id, - }, - } - ), 200 + return _error(404, "announcement not found", "not_found") + return {"success": 1, "announcement": announcement_data} + +@router.post("/{announcement_id:int}/resend") +def resend_announcement_api( + request: Request, + announcement_id: int, + _: int = Depends(require_operator), +): + """重新发送公告。""" -@api_bp.post("/announcements//resend") -def resend_announcement_api(announcement_id: int): - err = _require_operator() - if err: - return err try: - result = announcement_tasks.resend_announcement_service(announcement_id) + with request.app.state.flask_app.app_context(): + result = announcement_tasks.resend_announcement_service(announcement_id) except ValueError as e: reason = str(e) if reason == "announcement_still_sending": - return jsonify({"success": 0, "message": reason, "error_reason": reason}), 409 - return jsonify({"success": 0, "message": reason, "error_reason": reason}), 404 - return jsonify({"success": 1, **result.model_dump()}), 200 - - -@api_bp.post("/announcements//copy-as-draft") -def copy_announcement_as_draft_api(announcement_id: int): - err = _require_operator() - if err: - return err - try: - draft = announcement_tasks.copy_announcement_as_draft_service( - announcement_id, created_by=_current_user_id() - ) - except ValueError as e: - return jsonify({"success": 0, "message": str(e), "error_reason": str(e)}), 404 - return jsonify({"success": 1, "draft_id": draft.id}), 200 - - -@api_bp.post("/announcements//convert-to-template") -def convert_announcement_to_template_api(announcement_id: int): - err = _require_operator() - if err: - return err - try: - template = announcement_tasks.convert_announcement_to_template_service( - announcement_id, created_by=_current_user_id() - ) - except ValueError as e: - return jsonify({"success": 0, "message": str(e), "error_reason": str(e)}), 404 - return jsonify( - { - "success": 1, - "template_id": template.id, - "name": template.name, - "body_template": template.body_template, - } - ), 200 - - -@api_bp.delete("/announcements/") -def delete_announcement_api(announcement_id: int): - err = _require_operator() - if err: - return err - ok = announcement_tasks.delete_announcement_service(announcement_id) + return _error(409, reason, reason) + return _error(404, reason, reason) + return {"success": 1, **_model_data(result)} + + +@router.post("/{announcement_id:int}/copy-as-draft") +def copy_announcement_as_draft_api( + request: Request, + announcement_id: int, + operator_user_id: int = Depends(require_operator), +): + """复制公告为草稿。""" + + with request.app.state.flask_app.app_context(): + try: + draft = announcement_tasks.copy_announcement_as_draft_service( + announcement_id, + created_by=operator_user_id, + ) + draft_id = draft.id + except ValueError as e: + return _error(404, str(e), str(e)) + return {"success": 1, "draft_id": draft_id} + + +@router.post("/{announcement_id:int}/convert-to-template") +def convert_announcement_to_template_api( + request: Request, + announcement_id: int, + operator_user_id: int = Depends(require_operator), +): + """将公告转成模板。""" + + with request.app.state.flask_app.app_context(): + try: + template = announcement_tasks.convert_announcement_to_template_service( + announcement_id, + created_by=operator_user_id, + ) + template_data = { + "template_id": template.id, + "name": template.name, + "body_template": template.body_template, + } + except ValueError as e: + return _error(404, str(e), str(e)) + return {"success": 1, **template_data} + + +@router.delete("/{announcement_id:int}") +def delete_announcement_api( + request: Request, + announcement_id: int, + _: int = Depends(require_operator), +): + """删除公告。""" + + with request.app.state.flask_app.app_context(): + ok = announcement_tasks.delete_announcement_service(announcement_id) if not ok: - return jsonify({"success": 0, "message": "announcement not found", "error_reason": "not_found"}), 404 - return jsonify({"success": 1, "message": "announcement deleted"}), 200 + return _error(404, "announcement not found", "not_found") + return {"success": 1, "message": "announcement deleted"} + +@router.post("/batch-delete") +async def batch_delete_announcements_api( + request: Request, + payload: dict[str, Any] = Body(default_factory=dict), + _: int = Depends(require_operator), +): + """批量删除公告。""" -@api_bp.post("/announcements/batch-delete") -def batch_delete_announcements_api(): - err = _require_operator() - if err: - return err - data = request.get_json(silent=True) or {} - announcement_ids = data.get("announcement_ids") or [] + announcement_ids = payload.get("announcement_ids") or [] if not announcement_ids: - return jsonify({"success": 0, "message": "announcement_ids required", "error_reason": "missing_field"}), 400 - result = announcement_tasks.batch_delete_announcements_service(announcement_ids) - return jsonify({"success": 1, **result}), 200 - - -# ── 草稿 CRUD ───────────────────────────────────────────────────────── - - -@api_bp.get("/announcements/drafts") -def list_drafts_api(): - err = _require_operator() - if err: - return err - limit = request.args.get("limit", 50, type=int) - offset = request.args.get("offset", 0, type=int) - - rows, total = announcement_repo.list_drafts(created_by=_current_user_id(), limit=limit, offset=offset) - return jsonify( - { - "success": 1, - "drafts": [ - { - "id": d.id, - "title": d.title, - "content": d.content, - "raw_content": d.raw_content, - "created_by": d.created_by, - "targets": d.targets, - "template_id": d.template_id, - "created_at": d.created_at.isoformat() if d.created_at else None, - "updated_at": d.updated_at.isoformat() if d.updated_at else None, - } - for d in rows - ], - "total": total, - } - ), 200 + return _error(400, "announcement_ids required", "missing_field") + + with request.app.state.flask_app.app_context(): + result = announcement_tasks.batch_delete_announcements_service(announcement_ids) + return {"success": 1, **result} + + +@router.get("/drafts") +def list_drafts_api( + request: Request, + limit: int = Query(default=50, ge=1), + offset: int = Query(default=0, ge=0), + operator_user_id: int = Depends(require_operator), +): + """列出当前 Operator 的草稿。""" + + with request.app.state.flask_app.app_context(): + rows, total = announcement_repo.list_drafts( + created_by=operator_user_id, + limit=limit, + offset=offset, + ) + drafts = [_draft_view(row) for row in rows] + return {"success": 1, "drafts": drafts, "total": total} -@api_bp.post("/announcements/drafts/save") -def save_draft_api(): - err = _require_operator() - if err: - return err - data = request.get_json(silent=True) or {} - title = data.get("title") - content = data.get("content") +@router.post("/drafts/save") +async def save_draft_api( + request: Request, + payload: dict[str, Any] = Body(default_factory=dict), + operator_user_id: int = Depends(require_operator), +): + """保存或更新草稿。""" + title = payload.get("title") + content = payload.get("content") if not title or not content: - return jsonify({"success": 0, "message": "title and content are required", "error_reason": "missing_field"}), 400 - - try: - draft = announcement_repo.save_draft( - title=title, - content=content, - created_by=_current_user_id(), - draft_id=data.get("draft_id"), - raw_content=data.get("raw_content"), - targets=data.get("targets"), - template_id=data.get("template_id"), - ) - except ValueError as e: - return jsonify({"success": 0, "message": str(e), "error_reason": str(e)}), 404 - return jsonify({"success": 1, "draft_id": draft.id}), 200 - - -@api_bp.get("/announcements/drafts/") -def get_draft_api(draft_id: int): - err = _require_operator() - if err: - return err - draft = announcement_repo.get_draft_by_id(draft_id) + return _error(400, "title and content are required", "missing_field") + + with request.app.state.flask_app.app_context(): + try: + draft = announcement_repo.save_draft( + title=title, + content=content, + created_by=operator_user_id, + draft_id=payload.get("draft_id"), + raw_content=payload.get("raw_content"), + targets=payload.get("targets"), + template_id=payload.get("template_id"), + ) + draft_id = draft.id + except ValueError as e: + return _error(404, str(e), str(e)) + return {"success": 1, "draft_id": draft_id} + + +@router.get("/drafts/{draft_id}") +def get_draft_api( + request: Request, + draft_id: int, + _: int = Depends(require_operator), +): + """查看草稿。""" + + with request.app.state.flask_app.app_context(): + draft = announcement_repo.get_draft_by_id(draft_id) + if draft is not None: + draft_data = _draft_view(draft) if draft is None: - return jsonify({"success": 0, "message": "draft not found", "error_reason": "not_found"}), 404 - return jsonify( - { - "success": 1, - "draft": { - "id": draft.id, - "title": draft.title, - "content": draft.content, - "raw_content": draft.raw_content, - "created_by": draft.created_by, - "targets": draft.targets, - "template_id": draft.template_id, - "created_at": draft.created_at.isoformat() if draft.created_at else None, - "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, - }, - } - ), 200 - + return _error(404, "draft not found", "not_found") + return {"success": 1, "draft": draft_data} -@api_bp.delete("/announcements/drafts/") -def delete_draft_api(draft_id: int): - err = _require_operator() - if err: - return err - ok = announcement_repo.delete_draft(draft_id) - if not ok: - return jsonify({"success": 0, "message": "draft not found", "error_reason": "not_found"}), 404 - return jsonify({"success": 1, "message": "draft deleted"}), 200 +@router.delete("/drafts/{draft_id}") +def delete_draft_api( + request: Request, + draft_id: int, + _: int = Depends(require_operator), +): + """删除草稿。""" -# ── 批量发送(唯一的发送入口)────────────────────────────────────────── + with request.app.state.flask_app.app_context(): + ok = announcement_repo.delete_draft(draft_id) + if not ok: + return _error(404, "draft not found", "not_found") + return {"success": 1, "message": "draft deleted"} -@api_bp.post("/announcements/drafts/batch-send") -def batch_send_drafts_api(): - err = _require_operator() - if err: - return err +@router.post("/drafts/batch-send") +async def batch_send_drafts_api( + request: Request, + payload: dict[str, Any] = Body(default_factory=dict), + _: int = Depends(require_operator), +): + """批量发送草稿。""" - data = request.get_json(silent=True) or {} - draft_ids = data.get("draft_ids") or [] - raw_targets = data.get("targets") or [] - targets = [announcement_tasks.TargetEntry(**t) for t in raw_targets] + draft_ids = payload.get("draft_ids") or [] + raw_targets = payload.get("targets") or [] + targets = [announcement_tasks.TargetEntry(**target) for target in raw_targets] try: - result = announcement_tasks.batch_send_drafts_service(draft_ids, targets) + with request.app.state.flask_app.app_context(): + result = announcement_tasks.batch_send_drafts_service(draft_ids, targets) except ValueError as e: reason = str(e) status_map = { @@ -487,6 +456,6 @@ def batch_send_drafts_api(): "too_many_recipients": 400, "batch_too_large": 400, } - return jsonify({"success": 0, "message": reason, "error_reason": reason}), status_map.get(reason, 400) + return _error(status_map.get(reason, 400), reason, reason) - return jsonify({"success": 1, **result.model_dump()}), 200 + return {"success": 1, **_model_data(result)} diff --git a/api/container_api.py b/api/container_api.py index cbf7054..b6cbb98 100644 --- a/api/container_api.py +++ b/api/container_api.py @@ -1,87 +1,171 @@ +"""容器系统 API 路由。""" + +from __future__ import annotations + +import threading +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Body, Depends, Request +from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError -from flask import jsonify, request -from flask import current_app -from . import api_bp + +from ..constant import OperationType, ROLE +from ..repositories import containers_repo from ..services import container_tasks as container_service -from ..utils.Container import Container_info -from ..constant import ROLE, OperationType from ..services.operation_log_tasks import write_operation_log as write_op_log +from ..utils.Container import Container_info from ..utils.parsers import parse_bool -from ..repositories import containers_repo, authentications_repo, user_repo -from ..schemas.user_schema import user_schema, users_schema +from ..schemas.container import ( + CollaboratorRequest, + ContainerDetailResponse, + ContainerIdRequest, + ContainerOperationResponse, + ContainerStatusRequest, + ContainerStatusResponse, + CreateContainerRequest, + CreateContainerResponse, + DeleteContainerRequest, + DeleteContainerResponse, + ListAllContainerBrefInformationRequest, + ListAllContainerBrefInformationResponse, + RefreshLastSshLoginTimeRequest, + RefreshLastSshLoginTimeResponse, + SetLongTermContainerRequest, + SetLongTermContainerResponse, + UpdateRoleRequest, +) +from .deps import require_current_user + +router = APIRouter(prefix="/containers", tags=["containers"]) -# map known error_reason strings to HTTP status codes so we can surface them to clients REASON_STATUS_MAP = { - 'container_exists': 409, - 'invalid_payload': 400, - 'invalid_signature': 401, - 'invalid_json': 400, - 'invalid_config': 400, - 'docker_init_failed': 502, - 'docker_check_failed': 502, - 'unexpected_response': 502, - 'not_found': 404, - 'duplicate_entry': 409, - 'create_failed': 500, - 'delete_failed': 500, - 'start_failed': 500, - 'stop_failed': 500, - 'restart_failed': 500, - 'container_offline': 400, - 'node_endpoint_not_found': 502, - 'container_not_found': 404, - 'machine_permission_denied': 403, - 'container_permission_denied': 403, - 'long_term_limit_reached': 409, + "container_exists": 409, + "invalid_payload": 400, + "invalid_signature": 401, + "invalid_json": 400, + "invalid_config": 400, + "docker_init_failed": 502, + "docker_check_failed": 502, + "unexpected_response": 502, + "not_found": 404, + "duplicate_entry": 409, + "create_failed": 500, + "delete_failed": 500, + "start_failed": 500, + "stop_failed": 500, + "restart_failed": 500, + "container_offline": 400, + "node_endpoint_not_found": 502, + "container_not_found": 404, + "machine_permission_denied": 403, + "container_permission_denied": 403, + "long_term_limit_reached": 409, } +def _error(status_code: int, message: str, error_reason: str | None = None) -> JSONResponse: + payload: dict[str, Any] = {"success": 0, "message": message} + if error_reason is not None: + payload["error_reason"] = error_reason + return JSONResponse(status_code=status_code, content=payload) + + +def _dump_model(value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump() + if hasattr(value, "dict"): + return value.dict() + return value + + +def _payload_data(payload: Any) -> dict[str, Any]: + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_none=True) + if hasattr(payload, "dict"): + return payload.dict(exclude_none=True) + return dict(payload) + + def _log_failure(*, operation, target_type, target_id, operator_user_id, error_reason, detail=None): - """蓝图层失败补记:task 层直接上抛/返回 False 的失败在这里统一记一条。 - - .log 记录由 write_operation_log 内部统一完成(success=False → error 级), - 此处只负责补写 op-log 表。 - """ - write_op_log(success=False, operator_user_id=operator_user_id, operation=operation, - target_type=target_type, target_id=target_id, - detail=detail or {}, error_reason=error_reason) - - -@api_bp.post("/containers/create_container") -def create_container_api(): - ''' - 通信数据格式: - 发送格式: - { - "user_name", - "machine_id", - "container":{ - "GPU_LIST":list[int], - "CPU_NUMBER":int, - "MEMORY":int, - "NAME":str, - "image":str - }, - "public_key" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} + write_op_log( + success=False, + operator_user_id=operator_user_id, + operation=operation, + target_type=target_type, + target_id=target_id, + detail=detail or {}, + error_reason=error_reason, + ) + + +def _machine_id_or_none(value: Any) -> int | None: + if value in ("", None): + return None + try: + return int(value) + except Exception: + return None + + +def _user_id_or_none(value: Any) -> int | None: + if value in ("", None): + return None + try: + return int(value) + except Exception: + return None + + +def _refresh_disk_async(app, container_id: int) -> None: + """异步刷新单个容器磁盘用量。""" + + try: + with app.app_context(): + du = container_service.get_container_disk_usage(container_id, timeout=20.0) + if isinstance(du, dict) and du.get("container"): + from ..repositories import container_mount_cleanup_repo, machine_repo, containers_repo as repo + + c = repo.get_by_id(container_id) + if not c: + return + cd = du["container"] + overlay = int(cd.get("overlay_rw_bytes") or 0) + bind = int(cd.get("bind_mount_bytes") or 0) + total = int(cd.get("total_bytes") or 0) + limit = 0 + try: + m = machine_repo.get_by_id(c.machine_id) + dg = getattr(m, "disk_size_gb", 0) or 0 + limit = int(dg * 1024**3) + except Exception: + pass + repo.update_container( + c.id, + commit=True, + disk_overlay_rw_bytes=overlay, + disk_bind_mount_bytes=bind, + disk_total_bytes=total, + disk_limit_bytes=limit, + disk_checked_at=datetime.utcnow(), + ) + except Exception as e: + print(f"[ssh-refresh] async disk refresh failed for container {container_id}: {e}") + + +@router.post("/create_container", response_model=CreateContainerResponse) +def create_container_api( + request: Request, + payload: CreateContainerRequest = Body(default_factory=CreateContainerRequest), + operator_user_id: int = Depends(require_current_user), +): + """创建容器。""" + + data = _payload_data(payload) owner_name = data.get("user_name", "") - machine_id = data.get("machine_id", 0) - operator_user_id = authentications_repo.get_user_id_by_token(token) + machine_id = int(data.get("machine_id", 0) or 0) - # 似乎是一些结构问题 container_raw = data.get("container") or {} - # fallback to top-level keys for backward compatibility if not container_raw: container_raw = { "GPU_LIST": data.get("GPU_LIST", []), @@ -91,529 +175,516 @@ def create_container_api(): "image": data.get("image", ""), } - public_key = data.get("public_key", None) - if public_key == '': # treat empty string as None - public_key = None - # 这里纯粹只是为了增加报错信息的友好性 + public_key = data.get("public_key") or None try: gpu_list = container_raw.get("GPU_LIST") or container_raw.get("gpu_list") or [] cpu_number = int(container_raw.get("CPU_NUMBER") or container_raw.get("cpu_number") or 0) memory = int(container_raw.get("MEMORY") or container_raw.get("memory") or 0) - # support shared memory in GB: accept only SHARED_MEM/shared_memory/SHARED_MEMORY - shared_memory = int(container_raw.get("SHARED_MEM") or container_raw.get("shared_memory") or container_raw.get("SHARED_MEMORY") or 0) + shared_memory = int( + container_raw.get("SHARED_MEM") + or container_raw.get("shared_memory") + or container_raw.get("SHARED_MEMORY") + or 0 + ) name = container_raw.get("NAME") or container_raw.get("name") or "" image = container_raw.get("image") or container_raw.get("IMAGE") or "" - - # construct Container_info instance expected by service layer - container_obj = Container_info(gpu_list=gpu_list, cpu_number=cpu_number, memory=memory, name=name, image=image, shared_memory=shared_memory) - - except Exception as e: - return jsonify({"success": 0, "message": f"Invalid container payload: {str(e)}", "error_reason": "invalid_payload"}), 400 - try: - if not container_service.Create_container(owner_name=owner_name, - machine_id=machine_id, - container=container_obj, - public_key=public_key, - operator_user_id=operator_user_id): - _log_failure(operation=OperationType.CREATE_CONTAINER, target_type="container", target_id=0, - operator_user_id=operator_user_id, error_reason="create_failed", - detail={"machine_id": machine_id, "name": name}) - return jsonify({"success": 0, "message": "Failed to create container", "error_reason": "create_failed"}), 500 - except IntegrityError as e: - _log_failure(operation=OperationType.CREATE_CONTAINER, target_type="container", target_id=0, - operator_user_id=operator_user_id, error_reason="duplicate_entry", - detail={"machine_id": machine_id, "name": name}) - return jsonify({"success": 0, "message": f"Duplicate entry: {str(e.orig) if hasattr(e, 'orig') else str(e)}", "error_reason": "duplicate_entry"}), 409 - except container_service.NodeServiceError as e: - _log_failure(operation=OperationType.CREATE_CONTAINER, target_type="container", target_id=0, - operator_user_id=operator_user_id, error_reason=getattr(e, 'reason', None), - detail={"machine_id": machine_id, "name": name}) - status = REASON_STATUS_MAP.get(getattr(e, 'reason', None), 500) - return jsonify({"success": 0, "message": str(e), "error_reason": getattr(e, 'reason', None)}), status - except Exception as e: - # try to preserve any error_reason set on lower-level exceptions - reason = getattr(e, 'reason', None) or getattr(e, 'error_reason', None) - status = REASON_STATUS_MAP.get(reason, 500) - payload = {"success": 0, "message": f"Internal error: {str(e)}"} - if reason: - payload['error_reason'] = reason - _log_failure(operation=OperationType.CREATE_CONTAINER, target_type="container", target_id=0, - operator_user_id=operator_user_id, error_reason=reason or "internal_error", - detail={"machine_id": machine_id, "name": name}) - return jsonify(payload), status - return jsonify({"success": 1, "message": "Create container request sent"}), 200 - - -@api_bp.post("/containers/delete_container") -def delete_container_api(): - ''' - 通信数据格式: - 发送格式: - { - "container_id" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - container_id = data.get("container_id", 0) - request_user_id = authentications_repo.get_user_id_by_token(token) - try: - if not container_service.remove_container(container_id=container_id, operator_user_id=request_user_id): - _log_failure(operation=OperationType.DELETE_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason="delete_failed", - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": "Failed to delete container", "error_reason": "delete_failed"}), 500 - except container_service.NodeServiceError as e: - # prefer remote's reason when available - _log_failure(operation=OperationType.DELETE_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=getattr(e, 'reason', None), - detail={"container_id": container_id}) - status = 404 if getattr(e, 'reason', None) == 'not_found' else 500 - return jsonify({"success": 0, "message": str(e), "error_reason": getattr(e, 'reason', None)}), status + container_obj = Container_info( + gpu_list=gpu_list, + cpu_number=cpu_number, + memory=memory, + name=name, + image=image, + shared_memory=shared_memory, + ) except Exception as e: - reason = getattr(e, 'reason', None) or getattr(e, 'error_reason', None) - status = REASON_STATUS_MAP.get(reason, 500) - payload = {"success": 0, "message": f"Internal error: {str(e)}"} - if reason: - payload['error_reason'] = reason - _log_failure(operation=OperationType.DELETE_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=reason or "internal_error", - detail={"container_id": container_id}) - return jsonify(payload), status - return jsonify({"success": 1, "message": "Container deleted successfully"}), 200 - - -@api_bp.post("/containers/set_long_term_container") -def set_long_term_container_api(): - token = request.cookies.get("auth_token", "") - if not authentications_repo.is_token_valid(token): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - - data = request.get_json() or {} - if "container_id" not in data or "is_long_term" not in data: - return jsonify({"success": 0, "message": "missing container_id or is_long_term", "error_reason": "invalid_payload"}), 400 - try: - container_id = int(data.get("container_id")) - except Exception: - return jsonify({"success": 0, "message": "invalid container_id", "error_reason": "invalid_payload"}), 400 - is_long_term = parse_bool(data.get("is_long_term")) - if is_long_term is None: - return jsonify({"success": 0, "message": "is_long_term must be boolean", "error_reason": "invalid_payload"}), 400 + return _error(400, f"Invalid container payload: {e}", "invalid_payload") - request_user_id = authentications_repo.get_user_id_by_token(token) try: - result = container_service.set_long_term_container( - container_id=container_id, - is_long_term=is_long_term, - operator_user_id=request_user_id, + with request.app.state.flask_app.app_context(): + if not container_service.Create_container( + owner_name=owner_name, + machine_id=machine_id, + container=container_obj, + public_key=public_key, + operator_user_id=operator_user_id, + ): + _log_failure( + operation=OperationType.CREATE_CONTAINER, + target_type="container", + target_id=0, + operator_user_id=operator_user_id, + error_reason="create_failed", + detail={"machine_id": machine_id, "name": name}, + ) + return _error(500, "Failed to create container", "create_failed") + except IntegrityError as e: + detail = str(e.orig) if hasattr(e, "orig") else str(e) + _log_failure( + operation=OperationType.CREATE_CONTAINER, + target_type="container", + target_id=0, + operator_user_id=operator_user_id, + error_reason="duplicate_entry", + detail={"machine_id": machine_id, "name": name}, ) + return _error(409, f"Duplicate entry: {detail}", "duplicate_entry") except container_service.NodeServiceError as e: reason = getattr(e, "reason", None) - status = REASON_STATUS_MAP.get(reason, 500) - _log_failure(operation=OperationType.SET_LONG_TERM, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=reason, - detail={"container_id": container_id, "is_long_term": is_long_term}) - return jsonify({"success": 0, "message": str(e), "error_reason": reason}), status + _log_failure( + operation=OperationType.CREATE_CONTAINER, + target_type="container", + target_id=0, + operator_user_id=operator_user_id, + error_reason=reason, + detail={"machine_id": machine_id, "name": name}, + ) + return _error(REASON_STATUS_MAP.get(reason, 500), str(e), reason) except Exception as e: reason = getattr(e, "reason", None) or getattr(e, "error_reason", None) - status = REASON_STATUS_MAP.get(reason, 500) - payload = {"success": 0, "message": f"Internal error: {str(e)}"} - if reason: - payload["error_reason"] = reason - _log_failure(operation=OperationType.SET_LONG_TERM, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=reason or "internal_error", - detail={"container_id": container_id, "is_long_term": is_long_term}) - return jsonify(payload), status - - return jsonify({"success": 1, **result}), 200 - - -@api_bp.post("/containers/start_container") -def start_container_api(): - ''' - 请求格式: - {"container_id" } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - container_id = data.get("container_id", 0) - request_user_id = authentications_repo.get_user_id_by_token(token) - try: - if not container_service.start_container(container_id=container_id, operator_user_id=request_user_id): - _log_failure(operation=OperationType.START_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason="start_failed", - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": "Failed to start container", "error_reason": "start_failed"}), 500 - except container_service.NodeServiceError as e: - # propagate known node errors - _log_failure(operation=OperationType.START_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=getattr(e, 'reason', None), - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": str(e), "error_reason": getattr(e, 'reason', None)}), 500 - except Exception as e: - reason = getattr(e, 'reason', None) or getattr(e, 'error_reason', None) - status = REASON_STATUS_MAP.get(reason, 500) - payload = {"success": 0, "message": f"Internal error: {str(e)}"} - if reason: - payload['error_reason'] = reason - _log_failure(operation=OperationType.START_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=reason or "internal_error", - detail={"container_id": container_id}) - return jsonify(payload), status - return jsonify({"success": 1, "message": "Container start request sent"}), 200 - - -@api_bp.post("/containers/stop_container") -def stop_container_api(): - ''' - 请求格式: - { "container_id" } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - container_id = data.get("container_id", 0) - request_user_id = authentications_repo.get_user_id_by_token(token) - try: - if not container_service.stop_container(container_id=container_id, operator_user_id=request_user_id): - _log_failure(operation=OperationType.STOP_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason="stop_failed", - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": "Failed to stop container", "error_reason": "stop_failed"}), 500 - except container_service.NodeServiceError as e: - _log_failure(operation=OperationType.STOP_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=getattr(e, 'reason', None), - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": str(e), "error_reason": getattr(e, 'reason', None)}), 500 - except Exception as e: - reason = getattr(e, 'reason', None) or getattr(e, 'error_reason', None) - status = REASON_STATUS_MAP.get(reason, 500) - payload = {"success": 0, "message": f"Internal error: {str(e)}"} - if reason: - payload['error_reason'] = reason - _log_failure(operation=OperationType.STOP_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=reason or "internal_error", - detail={"container_id": container_id}) - return jsonify(payload), status - return jsonify({"success": 1, "message": "Container stop request sent"}), 200 - - -@api_bp.post("/containers/restart_container") -def restart_container_api(): - ''' - 请求格式: - { "container_id" } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - container_id = data.get("container_id", 0) - request_user_id = authentications_repo.get_user_id_by_token(token) - try: - if not container_service.restart_container(container_id=container_id, operator_user_id=request_user_id): - _log_failure(operation=OperationType.RESTART_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason="restart_failed", - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": "Failed to restart container", "error_reason": "restart_failed"}), 500 - except container_service.NodeServiceError as e: - _log_failure(operation=OperationType.RESTART_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=getattr(e, 'reason', None), - detail={"container_id": container_id}) - return jsonify({"success": 0, "message": str(e), "error_reason": getattr(e, 'reason', None)}), 500 - except Exception as e: - reason = getattr(e, 'reason', None) or getattr(e, 'error_reason', None) - status = REASON_STATUS_MAP.get(reason, 500) - payload = {"success": 0, "message": f"Internal error: {str(e)}"} - if reason: - payload['error_reason'] = reason - _log_failure(operation=OperationType.RESTART_CONTAINER, target_type="container", target_id=container_id, - operator_user_id=request_user_id, error_reason=reason or "internal_error", - detail={"container_id": container_id}) - return jsonify(payload), status - return jsonify({"success": 1, "message": "Container restart request sent"}), 200 - -@api_bp.post("/containers/add_collaborator") -def add_collaborator_api(): - ''' - 通信数据格式: - 发送格式: - { - "user_id", - "container_id", - "role" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success":0,"message":"invalid or missing token", "error_reason": "invalid_token"}),401 - data=request.get_json() or {} - user_id=data.get("user_id","") - container_id=data.get("container_id",0) - operator_user_id = authentications_repo.get_user_id_by_token(token) - role=data.get("role","COLLABORATOR") - - - try: - if not container_service.add_collaborator(container_id=container_id, - user_id=user_id, - role=ROLE(role), - operator_user_id=operator_user_id): - return jsonify({"success":0,"message":"Failed to add collaborator", "error_reason": "add_collaborator_failed"}),500 - except container_service.NodeServiceError as e: - if getattr(e, 'reason', None) == 'container_offline': - return jsonify({"success":0,"message": str(e), "error_reason": getattr(e, 'reason', None)}), 400 - return jsonify({"success":0,"message": str(e), "error_reason": getattr(e, 'reason', None)}), 500 - except Exception as e: - return jsonify({"success": 0, "message": f"Internal error: {str(e)}"}), 500 - return jsonify({"success":1,"message":"Collaborator added successfully"}),201 - -@api_bp.post("/containers/remove_collaborator") -def remove_collaborator_api(): - ''' - 通信数据格式: - 发送格式: - { - "container_id", - "user_id" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success":0,"message":"invalid or missing token", "error_reason": "invalid_token"}),401 - data=request.get_json() or {} - container_id=data.get("container_id",0) - user_id=data.get("user_id","") - request_user_id = authentications_repo.get_user_id_by_token(token) - - try: - if not container_service.remove_collaborator(container_id=container_id, - user_id=user_id, - operator_user_id=request_user_id): - return jsonify({"success":0,"message":"Failed to remove collaborator", "error_reason": "remove_collaborator_failed"}),500 - except container_service.NodeServiceError as e: - if getattr(e, 'reason', None) == 'container_offline': - return jsonify({"success":0,"message": str(e), "error_reason": getattr(e, 'reason', None)}), 400 - return jsonify({"success":0,"message": str(e), "error_reason": getattr(e, 'reason', None)}), 500 - except Exception as e: - return jsonify({"success": 0, "message": f"Internal error: {str(e)}"}), 500 - return jsonify({"success":1,"message":"Collaborator removed successfully"}),200 - -@api_bp.post("/containers/update_role") -def update_role_api(): - ''' - 通信数据格式: - 发送格式: - { - "container_id", - "user_id", - "updated_role" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"] - } - ''' - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success":0,"message":"invalid or missing token", "error_reason": "invalid_token"}),401 - data=request.get_json() or {} - container_id=data.get("container_id",0) - user_id=data.get("user_id","") - updated_role=data.get("updated_role","COLLABORATOR") - request_user_id = authentications_repo.get_user_id_by_token(token) - try: - if not container_service.update_role(container_id=container_id, - user_id=user_id, - updated_role=ROLE(updated_role), - operator_user_id=request_user_id): - return jsonify({"success":0,"message":"Failed to update role", "error_reason": "update_role_failed"}),500 - except container_service.NodeServiceError as e: - if getattr(e, 'reason', None) == 'container_offline': - return jsonify({"success":0,"message": str(e), "error_reason": getattr(e, 'reason', None)}), 400 - return jsonify({"success":0,"message": str(e), "error_reason": getattr(e, 'reason', None)}), 500 - except Exception as e: - return jsonify({"success": 0, "message": f"Internal error: {str(e)}"}), 500 - return jsonify({"success":1,"message":"Role updated successfully"}),200 - -@api_bp.post("/containers/unpause_container") -def unpause_container_api(): - token = request.cookies.get("auth_token", "") - if (not authentications_repo.is_token_valid(token)): - return jsonify({"success": 0, "message": "invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - container_id = data.get("container_id", 0) - operator_user_id = authentications_repo.get_user_id_by_token(token) - try: - if container_service.unpause_container(container_id=container_id, operator_user_id=operator_user_id): - return jsonify({"success": 1, "message": "Container unpaused"}), 200 - else: - return jsonify({"success": 0, "message": "Failed to unpause container", "error_reason": "unpause_failed"}), 500 - except container_service.NodeServiceError as e: - status = REASON_STATUS_MAP.get(getattr(e, 'reason', None), 500) - return jsonify({"success": 0, "message": str(e), "error_reason": getattr(e, 'reason', None)}), status - except Exception as e: - return jsonify({"success": 0, "message": f"Internal error: {str(e)}"}), 500 + _log_failure( + operation=OperationType.CREATE_CONTAINER, + target_type="container", + target_id=0, + operator_user_id=operator_user_id, + error_reason=reason or "internal_error", + detail={"machine_id": machine_id, "name": name}, + ) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return {"success": 1, "message": "Create container request sent"} -@api_bp.post("/containers/get_container_detail_information") -def get_container_detail_information_api(): - ''' - 通信数据格式: - 发送格式: - { - "container_id" - } - 返回格式: - { - "success": [0|1], - "message": "xxxx", - ["error_reason": "xxxx"], - "container_info": { - "container_id", - "container_name", - "container_image", - "machine_id", - "machine_ip", - "container_status", - "memory_gb", - "shared_gb", - "gpu_number", - "cpu_number", - "port", - "owners":['user_id'], - "accounts":[(binding['user_id'],binding['username'],ROLE(binding['role']))], - } - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success":0,"message":"invalid or missing token", "error_reason": "invalid_token"}),401 - data=request.get_json() or {} - container_id=data.get("container_id",0) - try: - container_info=container_service.get_container_detail_information(container_id=container_id) - except ValueError as e: - return jsonify({"success":0,"message":"Container not found", "error_reason": "container_not_found"}),404 - return jsonify({"success":1,"container_info":container_info}),200 - - -@api_bp.post("/containers/container_status") -def container_status_api(): - ''' - 通信数据格式: - 发送格式: - { - "machine_id": , - "container_name": "name" - } - 返回格式: - { - "container_status": "CREATING"|"ONLINE"|... - } - ''' - if (not authentications_repo.is_token_valid(request.cookies.get("auth_token", ""))): - return jsonify({"success":0, "message":"invalid or missing token", "error_reason": "invalid_token"}), 401 - data = request.get_json() or {} - container_name = data.get('container_name', '') - machine_id = data.get('machine_id', None) - if not container_name or machine_id is None or machine_id == '': - return jsonify({"container_status": None}), 200 +@router.post("/delete_container", response_model=DeleteContainerResponse) +def delete_container_api( + request: Request, + payload: DeleteContainerRequest = Body(default_factory=DeleteContainerRequest), + operator_user_id: int = Depends(require_current_user), +): + """删除容器。""" - try: + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + with request.app.state.flask_app.app_context(): try: - machine_id = int(machine_id) - except Exception: - return jsonify({"container_status": None}), 200 - - cid = containers_repo.get_id_by_name_machine(container_name=container_name, machine_id=machine_id) - if not cid: - return jsonify({"container_status": None}), 200 - container = containers_repo.get_by_id(cid) - if not container: - return jsonify({"container_status": None}), 200 - return jsonify({"container_status": container.container_status.value}), 200 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@api_bp.post("/containers/refresh_last_ssh_login_time") -def refresh_last_ssh_login_time_api(): - ''' - 前端触发刷新容器上次 SSH 登录时间。 - 请求格式: - { - "container_id": - } - 返回格式: - { - "success": 0|1, - "container_id": , - "container_name": "", - "last_ssh_login_time": "