diff --git a/.env.example b/.env.example index 67063ec..acd9385 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,16 @@ NODE_PORT=5789 WEB_IP=127.0.0.1 WEB_PORT=5173 +# ── Node 链路旁挂 ── +# Ctrl 主动拨各 Node 的 /ws/ctrl 取快照(machines 表即链路清单)。 +# 关掉则没有任何快照落库、机器状态不再更新,仅调试时使用。 +CTRL_WSS_ENABLED=true +# 链路调优(缺省即下方值,一般无需配置) +# CTRL_LINK_SYNC_INTERVAL=5 # machines 表 → 运行中链路的集合对齐周期(秒) +# CTRL_LINK_BACKOFF_INITIAL=1 # 单机重连退避起始(秒) +# CTRL_LINK_BACKOFF_MAX=30 # 单机重连退避上限(秒),即离线机器的检查周期 +# CTRL_WSS_READ_TIMEOUT=30 # 半开连接防护:多久收不到帧判链路失效(秒) + # Database DATABASE_URL=sqlite:///dev_database.db # or MySQL: @@ -27,46 +37,14 @@ MAIL_TIMEOUT=15 # Test recipient MAIL_TEST_TO=22301131@bjtu.edu.cn -# Settings -LONG_TERM_CONTAINER_LIMIT=1 -CONTAINER_CLEANUP_REMINDER_HOURS=72,24,12 -CONTAINER_DISK_CHECK_ENABLED=false - -# 已删除容器的挂载备份清理:开关 + 扫描间隔 + 保留天数(默认关闭;开启后每天扫描清理一次) -CONTAINER_MOUNT_CLEANUP_ENABLED=false -CONTAINER_MOUNT_CLEANUP_INTERVAL_SECONDS=86400 -CONTAINER_MOUNT_CLEANUP_AFTER_DAYS=14 - -# 容器自动清理:SSH 未登录 N 天后清理(提醒档位见下) -CONTAINER_CLEANUP_AFTER_DAYS=7 - -# 磁盘检测:开关 + 扫描间隔 + 软/硬限位 + 响应处置 -CONTAINER_DISK_CHECK_INTERVAL_SECONDS=900 -CONTAINER_DISK_SOFT_LIMIT_PERCENT=80 -CONTAINER_DISK_HARD_LIMIT_PERCENT=100 -CONTAINER_DISK_RESPONSE_ENABLED=false -# 磁盘超限冻结升级:冻结 N 天后升级处置;宽限期天数;解冻回退阈值 -CONTAINER_DISK_FREEZE_ESCALATION_DAYS=7 -CONTAINER_DISK_FREEZE_GRACE_DAYS=3 -CONTAINER_DISK_FREEZE_RESET_PERCENT=95 - -# Node 通信并行与连接池 -NODE_PARALLEL_ENABLED_MACHINES=true -NODE_PARALLEL_ENABLED_CONTAINERS=true -NODE_PARALLEL_ENABLED_SSH_REFRESH=true -NODE_REQUEST_POOL_SIZE=8 - -# 公告系统 -ANNOUNCEMENT_MAX_RECIPIENTS=200 -ANNOUNCEMENT_SEND_COOLDOWN_SECONDS=60 -ANNOUNCEMENT_BATCH_SEND_MAX=20 -# 密钥路径(默认仓库根目录的 public_A.pem / private_A.pem,一般无需修改) #PRIVATE_KEY_PATH=private_A.pem #PUBLIC_KEY_PATH=public_A.pem -SSL_CERT_PATH=certs/localhost.pem -SSL_KEY_PATH=certs/localhost-key.pem +SSL_CERT_PATH=certs/ctrl.pem +SSL_KEY_PATH=certs/ctrl-key.pem +CTRL_CA_CERT_FILE=certs/ctrl_ca.pem +CTRL_CA_KEY_FILE=certs/ctrl_ca-key.pem -# Flask +# 应用 SECRET_KEY=dev ENABLE_SSL=true diff --git a/.gitignore b/.gitignore index a3c610b..aace078 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,31 @@ **/.pytest_cache/ **/__pycache__/ +*/.pytest_cache/ +*/__pycache__/ venv/ - -# ignore locally-generated TLS certs (mkcert) -certs/*.pem -certs/*-key.pem -certs/*.key -*.log .env +.env* +!.env.example .mysql-fix-*.sql +# 运行时备份文件(.env.bak-*、*.bak-*.sql、dev_database.db.bak-* 等),一律不入库 +*.bak-* + /logs/* !/logs/.gitkeep +/certs/* +!/certs/.gitkeep + +# 信任锚(各机器的 Node 证书)是运行时状态,只留目录占位;与 certs/ 同一写法 +/pinned_certs/* +!/pinned_certs/.gitkeep +/runtime_cache/ + docs/audit/* -*.db \ No newline at end of file +*.db +*.sqlite +*.sqlite3 +*.log diff --git a/.init_next_env.py b/.init_next_env.py new file mode 100644 index 0000000..fc5dbf3 --- /dev/null +++ b/.init_next_env.py @@ -0,0 +1,100 @@ +"""初始化 ~/FuxiYu_Next 三套 .env:删死键 + 补注释。 + +只按**键名**增删,不读取也不改写任何值(SQLPASSWORD / SECRET_KEY 等原样保留), +其余行逐字节不动。执行前先备份。 + +"死键" = 全仓已无读取处。判定依据是本地三仓的 grep 结果;这些是 WSS 方向反转前 +的残留——那时 Node 要主动拨 Ctrl,所以需要知道 Ctrl 的地址。 +""" + +import datetime +import pathlib +import shutil +import sys + +ROOT = pathlib.Path.home() / "FuxiYu_Next" + +# 每个仓库:要删的键 +DEAD_KEYS = { + "FuxiYu_CtrKernel": ["CTRL_WSS_PORT"], + "FuxiYu_NodeKernel": ["CTRL_IP", "CTRL_PORT", "NODE_WSS_ENABLED", "NODE_CTRL_WSS_URL"], + "FuxiYu_Web": [], +} + +# 要在某键之前插入的注释(键名 -> 注释文本) +ANNOTATIONS = { + "CTRL_WSS_ENABLED": ( + "# 链路旁挂进程开关(名字是 WSS 时代的遗留,语义已是「Ctrl→Node 拨号管理器」)。\n" + "# 置 0 会导致一台机器都不被拨、全线离线——不要因为名字里带 WSS 就删它。" + ), +} + +# 追加到文件末尾的说明(仓库 -> 文本) +FOOTERS = { + "FuxiYu_NodeKernel": ( + "\n# 方向已反转:Ctrl 主动拨入 Node,Node 是被动端点。\n" + "# 因此 Node 不需要知道 Ctrl 的地址——CTRL_IP / CTRL_PORT / NODE_CTRL_WSS_URL\n" + "# 等键已无读取处,已于本次初始化中移除,别再按旧文档加回来。\n" + ), +} + + +def rewrite(repo: str) -> tuple[list[str], list[str]]: + path = ROOT / repo / ".env" + original = path.read_text(encoding="utf-8") + dead = set(DEAD_KEYS[repo]) + removed, kept = [], [] + + out_lines: list[str] = [] + for line in original.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key = stripped.split("=", 1)[0].strip() + if key in dead: + removed.append(key) + continue + if key in ANNOTATIONS: + out_lines.append(ANNOTATIONS[key]) + out_lines.append(line) + + text = "\n".join(out_lines) + if not text.endswith("\n"): + text += "\n" + if FOOTERS.get(repo): + text += FOOTERS[repo] + + stamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S") + shutil.copy2(path, path.with_name(f".env.bak-{stamp}")) + path.write_text(text, encoding="utf-8") + + live = [ + line.strip().split("=", 1)[0].strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("#") and "=" in line + ] + return removed, live + + +def main() -> None: + dry = "--apply" not in sys.argv + for repo in DEAD_KEYS: + path = ROOT / repo / ".env" + if not path.exists(): + print(f"!! {repo}/.env 不存在,跳过") + continue + if dry: + before = [ + line.strip().split("=", 1)[0].strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.strip().startswith("#") and "=" in line + ] + print(f"{repo}: 现有 {len(before)} 键;将删除 {DEAD_KEYS[repo] or '(无)'}") + continue + removed, live = rewrite(repo) + print(f"{repo}: 删除 {removed or '(无)'} → 剩余 {len(live)} 键 {live}") + + if dry: + print("\ndry run:未写入。加 --apply 落地(会先备份为 .env.bak-<时间戳>)。") + + +main() diff --git a/README.md b/README.md index 7e08b62..0856c26 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,86 @@ -# 伏羲-御 +# 伏羲 · Fuxi-Yu -## 伏羲・御・Fuxi-Yu +Fuxi-Yu 是面向高校与实验室算力管理场景的容器化平台。CtrlKernel 是伏羲平台的控制端服务,负责用户、机器、容器、公告、审计日志、权限与 Node 通信编排。 -面向算力平台的 Docker 化集群管理与自助使用系统。将物理服务器纳入统一 “控制面”,用户以申请到的 Docker 实例直控实体机,配套可视化的基础环境配置(网络、用户等),免去繁琐安装与踩坑,友好地面向 Linux 新手与多用户场景。 +当前版本使用 FastAPI 提供 HTTP API 与 Swagger 文档,使用 SQLAlchemy 管理数据库访问。 -``` -config.py # 配置 -extensions.py # 第三方扩展初始化 -__init__.py # create_app 工厂 -run.py # 开发直接运行入口 -wsgi.py # 生产/WSGI 入口 -models/ # 数据模型层 -repositories/ # 数据访问仓储层 -services/ # 业务服务层 -schemas/ # 序列化/反序列化层 (Marshmallow) -blueprints/ # 路由蓝图 (接口层) -``` +## 快速上手 -## 功能点 -- App 工厂模式 (`create_app`) -- 配置分环境 (development / production / testing) -- SQLAlchemy + Migrate 数据迁移能力 -- Caching / LoginManager 预置 -- 用户模型 + 仓储 + 服务 + Schema + API 示例 -- Marshmallow 进行序列化 +### 1. 安装依赖 -## 快速开始 +建议在项目约定的 Python 环境中安装: -### 1. 克隆与安装依赖 ```bash pip install -r requirements.txt ``` -### 2. 初始化数据库 +### 2. 配置环境 + +可参考 `.env.example` 创建 `.env`。常用配置: + ```bash -# 生成迁移仓库 -flask --app compute_cluster_manage_web:create_app db init -# 生成迁移脚本 -flask --app compute_cluster_manage_web:create_app db migrate -m "init" -# 应用迁移 -flask --app compute_cluster_manage_web:create_app db upgrade +DATABASE_URL=sqlite:///app.db +CTRL_PORT=5000 +SECRET_KEY=change-me ``` -> 若你直接使用 sqlite,默认文件为 `app.db`。 -### 3. 运行开发服务器 +未设置 `DATABASE_URL` 时,默认使用当前目录下的 `app.db`。 + +### 3. 初始化基础数据 + ```bash -python -m compute_cluster_manage_web.run -# 或 -flask --app compute_cluster_manage_web:create_app run --debug +python seed.py ``` -访问: http://127.0.0.1:5000/api/users -### 4. 示例 API +数据库表会在应用启动时按当前模型创建;手写迁移脚本位于 `migrations/`。 + +### 4. 启动服务 -#### 创建用户 ```bash -curl -X POST http://127.0.0.1:5000/api/users \ - -H 'Content-Type: application/json' \ - -d '{"username":"alice","email":"alice@example.com","password":"123456"}' +python run.py +``` + +默认访问地址: + +```text +http://127.0.0.1:5000 ``` -响应: -```json -{ - "id": 1, - "username": "alice", - "email": "alice@example.com", - "created_at": "2025-01-01T00:00:00" -} + +Swagger 文档: + +```text +http://127.0.0.1:5000/docs ``` -#### 列表用户 +### 5. 运行测试 + +默认测试不会访问真实 Node、Docker、SMTP 或生产数据库: + ```bash -curl http://127.0.0.1:5000/api/users +pytest ``` -### 5. 目录/分层说明 -| 层 | 说明 | 关注点 | -|----|------|--------| -| model | `models/` | ORM 定义,仅包含字段和关系 | -| repository | `repositories/` | 封装数据库 CRUD | -| service | `services/` | 业务逻辑、组合多个仓储 | -| schema | `schemas/` | 输入校验与输出序列化 | -| blueprint(API) | `blueprints/` | HTTP 路由 / 参数获取 / 返回 | - -## 配置 -环境通过环境变量 `FLASK_ENV` / 自定义传入 `create_app("production")` 选择。 -可用变量: -- `DATABASE_URL` (默认 sqlite:///app.db) -- `SECRET_KEY` -- `CACHE_TYPE` (默认 SimpleCache) - -## 部署 (Gunicorn 示例) +需要验证真实外部链路时,再单独运行集成测试: + ```bash -gunicorn 'compute_cluster_manage_web.wsgi:app' -b 0.0.0.0:8000 --workers 4 +pytest -m integration ``` -## 后续可扩展建议 -- 使用 `python-dotenv` 加载 `.env` -- 引入 Alembic 版本号命名策略 / 预置 seed 脚本 -- 集成单元测试 (pytest + factory-boy) -- 使用真实密码哈希: `from werkzeug.security import generate_password_hash` -- 增加 JWT 或 Session 认证流程 +## 目录结构 + +```text +api/ # FastAPI 路由与依赖 +schemas/ # API 请求与响应结构 +models/ # 数据模型 +repositories/ # 数据访问 +services/ # 业务逻辑 +schedulers/ # 后台任务 +utils/ # 通用工具 +migrations/ # 数据库迁移脚本 +run.py # 本地启动入口 +asgi.py # ASGI 应用入口 +``` +## 开发约定 +数据库事务边界放在 service/tasks 层,repository 只接收显式传入的 session 并执行数据读写。这个约定主要是为了让 API、后台任务和 WSS 使用同一套数据库访问方式,降低排错成本。 diff --git a/__init__.py b/__init__.py index ccfe857..a50e768 100644 --- a/__init__.py +++ b/__init__.py @@ -1,55 +1,1022 @@ -# yourapp/__init__.py +from contextlib import asynccontextmanager from pathlib import Path + from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse _DOTENV_PATH = Path(__file__).resolve().parent / ".env" load_dotenv(_DOTENV_PATH, override=True) -import os -from flask import Flask -from flask_cors import CORS -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 .api import register_api +from .config import AppConfig, build_allowed_origins +from . import extensions +from .extensions import configure_database, db from .utils.logging_config import configure_daily_logging -def create_app(config: str | None = None, overrides: dict | None = None): +def _apply_overrides(overrides: dict | None) -> None: + """Apply test or local configuration overrides.""" + if not overrides: - load_dotenv(_DOTENV_PATH, override=True) - app = Flask(__name__) - 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. - origins = build_allowed_origins() - CORS(app, supports_credentials=True, resources={r"/api/*": {"origins": origins}}) - - db.init_app(app) - with app.app_context(): - from . import models - db.create_all() - - - register_blueprints(app) - - # 启动“每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") + return + for key, value in overrides.items(): + setattr(AppConfig, key, value) + + +def _init_database() -> None: + """Import models, create tables, and seed minimal RBAC defaults.""" + + from . import models # noqa: F401 + + db.create_all() + # 必须排在其余自愈之前:这一步含 containers.image → runtime_image 的改名, + # 改名未完成时任何 select(Container) 都会枚举到不存在的列而报 no such column。 + _ensure_container_image_schema() + _strip_legacy_snapshot_image_key() + _ensure_container_lifecycle_schema() + _ensure_deleted_container_schema() + _ensure_image_template_schema() + _ensure_container_failure_schema() + _ensure_gpu_columns() + _ensure_cleanup_deferral_schema() + _ensure_machine_endpoint_schema() + _ensure_freeze_state_schema() + _ensure_machine_image_schema() + try: + from .services.rbac_service import seed_rbac_defaults + + seed_rbac_defaults() + except Exception as e: + import logging + + logging.getLogger(__name__).warning("rbac seed skipped: %s", e) + try: + from .services.image_tasks import seed_image_defaults + + seed_image_defaults() + except Exception as e: + import logging + + logging.getLogger(__name__).warning("image seed skipped: %s", e) + try: + from .services.settings_tasks import seed_system_settings_defaults + + seed_system_settings_defaults() + except Exception as e: + import logging + + logging.getLogger(__name__).warning("system settings seed skipped: %s", e) + + +def _ensure_image_template_schema() -> None: + """补齐开发期旧 images 表缺失的镜像模板列。 + + create_all 只创建新表,不会修改旧表;镜像模板在开发期经历过字段拆分, + 旧 SQLite 库会缺 base_image/dockerfile_body 等列,导致列表接口 500。 + """ + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("images"): + return + + existing = {column["name"] for column in inspector.get_columns("images")} + required_sqlite = { + "base_image": "ALTER TABLE images ADD COLUMN base_image VARCHAR(255) NOT NULL DEFAULT 'ubuntu:24.04'", + "dockerfile_body": "ALTER TABLE images ADD COLUMN dockerfile_body TEXT NOT NULL DEFAULT ''", + "status": "ALTER TABLE images ADD COLUMN status VARCHAR(8) NOT NULL DEFAULT 'draft'", + "created_by_user_id": "ALTER TABLE images ADD COLUMN created_by_user_id INTEGER NULL", + "created_at": "ALTER TABLE images ADD COLUMN created_at DATETIME NULL", + "updated_at": "ALTER TABLE images ADD COLUMN updated_at DATETIME NULL", + "entrypoint": "ALTER TABLE images ADD COLUMN entrypoint VARCHAR(255) NULL", + } + required_mysql = { + "base_image": "ALTER TABLE images ADD COLUMN base_image VARCHAR(255) NOT NULL DEFAULT 'ubuntu:24.04'", + "dockerfile_body": "ALTER TABLE images ADD COLUMN dockerfile_body TEXT NOT NULL", + "status": "ALTER TABLE images ADD COLUMN status ENUM('draft', 'ready', 'disabled') NOT NULL DEFAULT 'draft'", + "created_by_user_id": "ALTER TABLE images ADD COLUMN created_by_user_id INT NULL", + "created_at": "ALTER TABLE images ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "ALTER TABLE images ADD COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "entrypoint": "ALTER TABLE images ADD COLUMN entrypoint VARCHAR(255) NULL", + } + required = required_sqlite if current_engine.dialect.name == "sqlite" else required_mysql + + missing = [name for name in required if name not in existing] + + # 模板名唯一性移交应用层(2026-09 决策):模板的移除是停用而非删除,停用行会继续 + # 占用名字,唯一约束会让该名字永久不可复用。模型已去掉 unique,但旧库上的唯一性 + # 是**独立索引**(create_all 对 unique=True + index=True 生成 ix_images_name), + # 因此可以直接删掉重建为非唯一索引,无需重建表。幂等:只在它仍唯一时才动。 + index_rows = {idx["name"]: idx for idx in inspector.get_indexes("images") if idx.get("name")} + name_index = index_rows.get("ix_images_name") + if name_index is not None and name_index.get("unique"): + # DROP INDEX 语法两方言不同:SQLite 不带表名,MySQL 必须写 `DROP INDEX 名 ON 表`。 + # 曾误用 SQLite 单方言形式,MySQL 上 1064 启动即崩(2026-09-15 实测)。 + drop_ddl = ( + "DROP INDEX ix_images_name" + if current_engine.dialect.name == "sqlite" + else "DROP INDEX ix_images_name ON images" + ) + with current_engine.begin() as conn: + conn.execute(text(drop_ddl)) + conn.execute(text("CREATE INDEX ix_images_name ON images(name)")) + logging.getLogger(__name__).warning( + "images.name unique index dropped: uniqueness now enforced in application layer" + ) + + if not missing: + return + + with current_engine.begin() as conn: + for name in missing: + conn.execute(text(required[name])) + logging.getLogger(__name__).warning("image schema upgraded: added columns %s", ", ".join(missing)) + + +def _ensure_container_failure_schema() -> None: + """补齐开发期旧 containers 表缺失的失败诊断列。""" + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("containers"): + return + + existing = {column["name"] for column in inspector.get_columns("containers")} + required = { + "failed_reason": "ALTER TABLE containers ADD COLUMN failed_reason VARCHAR(255) NULL", + "failed_detail": "ALTER TABLE containers ADD COLUMN failed_detail TEXT NULL", + } + missing = [name for name in required if name not in existing] + if not missing: + return + + with current_engine.begin() as conn: + for name in missing: + conn.execute(text(required[name])) + logging.getLogger(__name__).warning("container schema upgraded: added columns %s", ", ".join(missing)) + + +def _ensure_container_lifecycle_schema() -> None: + """Backfill soft-delete lifecycle columns for old containers tables.""" + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("containers"): + return + + logger = logging.getLogger(__name__) + existing = {column["name"] for column in inspector.get_columns("containers")} + required_sqlite = { + "is_valid": "ALTER TABLE containers ADD COLUMN is_valid BOOLEAN NOT NULL DEFAULT 1", + "deleted_at": "ALTER TABLE containers ADD COLUMN deleted_at DATETIME NULL", + "deleted_trigger": "ALTER TABLE containers ADD COLUMN deleted_trigger VARCHAR(64) NULL", + "deleted_reason": "ALTER TABLE containers ADD COLUMN deleted_reason VARCHAR(255) NULL", + "deleted_by_user_id": "ALTER TABLE containers ADD COLUMN deleted_by_user_id INTEGER NULL", + } + required_mysql = { + "is_valid": "ALTER TABLE containers ADD COLUMN is_valid BOOLEAN NOT NULL DEFAULT TRUE", + "deleted_at": "ALTER TABLE containers ADD COLUMN deleted_at DATETIME NULL", + "deleted_trigger": "ALTER TABLE containers ADD COLUMN deleted_trigger VARCHAR(64) NULL", + "deleted_reason": "ALTER TABLE containers ADD COLUMN deleted_reason VARCHAR(255) NULL", + "deleted_by_user_id": "ALTER TABLE containers ADD COLUMN deleted_by_user_id INTEGER NULL", + } + required = required_sqlite if current_engine.dialect.name == "sqlite" else required_mysql + missing = [name for name in required if name not in existing] + + index_names = {index["name"] for index in inspector.get_indexes("containers") if index.get("name")} + constraint_names = { + constraint["name"] + for constraint in inspector.get_unique_constraints("containers") + if constraint.get("name") + } + schema_names = index_names | constraint_names + indexes_to_create = { + "idx_containers_is_valid": "CREATE INDEX idx_containers_is_valid ON containers(is_valid)", + } + + with current_engine.begin() as conn: + for name in missing: + conn.execute(text(required[name])) + conn.execute(text("UPDATE containers SET is_valid = 1 WHERE is_valid IS NULL")) + # active_name 已废弃:新代码不再写它。旧库上它可能还留着值, + # 而残留的 (active_name, machine_id) 唯一索引会把「已删容器占着名字」变成硬阻塞。 + # 清空即让残留索引失效——新行不再写它(NULL),多个 NULL 在唯一索引下互不冲突。 + # 删列由 migrations/2026-09_drop_active_name.sql 处理(DDL 必须走在人工迁移里)。 + if "active_name" in existing: + cleared = conn.execute(text("UPDATE containers SET active_name = NULL WHERE active_name IS NOT NULL")) + if cleared.rowcount: + logger.warning("active_name deprecated: cleared %s stale value(s)", cleared.rowcount) + for name, ddl in indexes_to_create.items(): + if name in schema_names: + continue + try: + conn.execute(text(ddl)) + except Exception as e: + logger.warning("container lifecycle schema index %s create failed: %s", name, e) + if missing: + logger.warning("container lifecycle schema upgraded: added columns %s", ", ".join(missing)) + + +def _ensure_machine_endpoint_schema() -> None: + """补齐 machines.port(每台宿主机上 Node 的监听端口)。 + + 可空、无回填:旧行为 NULL 即「回落全局默认」,与改动前行为一致。 + """ + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("machines"): + return + + existing = {column["name"] for column in inspector.get_columns("machines")} + if "port" in existing: + return + + with current_engine.begin() as conn: + conn.execute(text("ALTER TABLE machines ADD COLUMN port INTEGER NULL")) + logging.getLogger(__name__).warning("machine schema upgraded: added columns port") + + +def _ensure_freeze_state_schema() -> None: + """补齐 container_disk_freeze_state.deferral_seconds(冻结期内的不可用顺延)。 + + 与 ssh 到期清理、挂载保留期共用同一把尺子:期限按"业务正常时间"计,宕机/维护期 + 不算数。旧行保持 NULL,读取侧 coalesce 兜底为 0。 + """ + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("container_disk_freeze_state"): + return + + existing = {column["name"] for column in inspector.get_columns("container_disk_freeze_state")} + if "deferral_seconds" in existing: + return + + with current_engine.begin() as conn: + conn.execute(text( + "ALTER TABLE container_disk_freeze_state ADD COLUMN deferral_seconds INTEGER NULL" + )) + logging.getLogger(__name__).warning("freeze state schema upgraded: added columns deferral_seconds") + + +def _ensure_deleted_container_schema() -> None: + """Add deleted-owned mount state and cleanup linkage to existing databases.""" + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("deleted_container_restore_snapshot"): + return + + logger = logging.getLogger(__name__) + deleted_columns = { + column["name"] + for column in inspector.get_columns("deleted_container_restore_snapshot") + } + cleanup_columns = ( + { + column["name"] + for column in inspector.get_columns("container_mount_cleanup") + } + if inspector.has_table("container_mount_cleanup") + else set() + ) + deleted_missing = "mount_cleaned" not in deleted_columns + deferral_missing = "deferral_seconds" not in deleted_columns + cleanup_missing = "deleted_id" not in cleanup_columns + + with current_engine.begin() as conn: + if deleted_missing: + conn.execute(text( + "ALTER TABLE deleted_container_restore_snapshot " + "ADD COLUMN mount_cleaned BOOLEAN NOT NULL DEFAULT 0" + )) + if deferral_missing: + # 挂载保留期按"业务正常时间"计:宕机/维护期用户无法恢复,那段不算数。 + # 旧行保持 NULL,读取侧 coalesce 兜底为 0,不重写存量行。 + conn.execute(text( + "ALTER TABLE deleted_container_restore_snapshot " + "ADD COLUMN deferral_seconds INTEGER NULL" + )) + if cleanup_missing and inspector.has_table("container_mount_cleanup"): + conn.execute(text( + "ALTER TABLE container_mount_cleanup ADD COLUMN deleted_id INTEGER NULL" + )) + + if deleted_missing: + if "mount_path" in deleted_columns: + conn.execute(text( + "UPDATE deleted_container_restore_snapshot " + "SET mount_cleaned = 1 WHERE mount_path IS NULL" + )) + if inspector.has_table("container_mount_cleanup"): + conn.execute(text( + "UPDATE deleted_container_restore_snapshot " + "SET mount_cleaned = 1 " + "WHERE mount_cleanup_id IN (" + " SELECT id FROM container_mount_cleanup " + " WHERE cleaned_at IS NOT NULL" + ")" + )) + if cleanup_missing and inspector.has_table("container_mount_cleanup"): + conn.execute(text( + "UPDATE container_mount_cleanup " + "SET deleted_id = (" + " SELECT id FROM deleted_container_restore_snapshot " + " WHERE mount_cleanup_id = container_mount_cleanup.id" + ") " + "WHERE deleted_id IS NULL" + )) + + if deleted_missing: + logger.warning("deleted container schema upgraded: added mount_cleaned") + if cleanup_missing and inspector.has_table("container_mount_cleanup"): + logger.warning("container mount cleanup schema upgraded: added deleted_id") + + +def _ensure_cleanup_deferral_schema() -> None: + """补齐清理顺延(不可用窗口)相关的增量列。 + + 同一特性落在两张表上:machines 记窗口起点与采集心跳,ssh 记录表累计顺延秒数。 + create_all 只创建新表、不会修改旧表,因此这些列是当初上线时漏补的—— + 旧 SQLite 库缺 machines.unavailable_since 会让机器列表与链路读面直接 + 500(每轮 `no such column`),缺 deferral_seconds 会让清理倒计时读面 500。 + """ + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + for table, required in ( + ("machines", { + "unavailable_since": "ALTER TABLE machines ADD COLUMN unavailable_since DATETIME NULL", + # 采集心跳:窗口起点的下界,供启动扫描兜底(旧库行保持 NULL,扫描会跳过) + "last_seen_at": "ALTER TABLE machines ADD COLUMN last_seen_at DATETIME NULL", + }), + ("container_ssh_login_records", { + # 旧行 NULL 由读取侧 coalesce 兜底为 0,这里不强制 DEFAULT,避免重写存量行 + "deferral_seconds": "ALTER TABLE container_ssh_login_records ADD COLUMN deferral_seconds INTEGER NULL", + }), + ): + if not inspector.has_table(table): + continue + existing = {column["name"] for column in inspector.get_columns(table)} + missing = [name for name in required if name not in existing] + if not missing: + continue + with current_engine.begin() as conn: + for name in missing: + conn.execute(text(required[name])) + logging.getLogger(__name__).warning( + "cleanup deferral schema upgraded: %s added columns %s", table, ", ".join(missing) + ) + + +def _ensure_gpu_columns() -> None: + """补齐 GPU 三集合建模列(machines: gpu_list/gpu_allow_list;containers: gpu_chosen_list)。 + + 旧库补 JSON 列(SQLite 存 TEXT,MySQL 存 JSON);新库由 create_all 直接建。 + """ + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + for table, required in ( + ("machines", { + "gpu_list": "ALTER TABLE machines ADD COLUMN gpu_list JSON NULL", + "gpu_allow_list": "ALTER TABLE machines ADD COLUMN gpu_allow_list JSON NULL", + "max_disk_size_gb": "ALTER TABLE machines ADD COLUMN max_disk_size_gb INTEGER NULL", + }), + ("containers", { + "gpu_chosen_list": "ALTER TABLE containers ADD COLUMN gpu_chosen_list JSON NULL", + "port_mappings": "ALTER TABLE containers ADD COLUMN port_mappings JSON NULL", + # 容器创建时间(2026-09):容器 id 在 SQLite 删除后可复用,created_at 提供 + # 新旧区分锚(op log 审计对照用);老库 NULL 由下次创建/回填补齐。 + "created_at": "ALTER TABLE containers ADD COLUMN created_at DATETIME NULL", + }), ): - start_container_ssh_refresh_scheduler(app, interval_seconds=300) - # 启动容器定时清理任务(每20分钟扫描一次到期容器并释放) - start_container_cleanup_scheduler(app, interval_seconds=1200) - # 启动已删除容器 mount 清理任务(每天一次) - start_mount_cleanup_scheduler(app) + if not inspector.has_table(table): + continue + existing = {column["name"] for column in inspector.get_columns(table)} + missing = [name for name in required if name not in existing] + if not missing: + continue + with current_engine.begin() as conn: + for name in missing: + conn.execute(text(required[name])) + logging.getLogger(__name__).warning("gpu schema upgraded: %s added columns %s", table, ", ".join(missing)) + + # 磁盘上限语义收敛回填:max_disk_size_gb 新列 NULL → 沿用原 disk_size_gb + # (上限行为延续,管理员之后可调;幂等:只补 NULL)。 + if inspector.has_table("machines"): + try: + with current_engine.begin() as conn: + conn.execute(text( + "UPDATE machines SET max_disk_size_gb = disk_size_gb " + "WHERE max_disk_size_gb IS NULL AND disk_size_gb IS NOT NULL" + )) + except Exception as e: # pragma: no cover + logging.getLogger(__name__).warning("max_disk_size_gb backfill failed: %s", e) + + _backfill_container_created_at(current_engine) + + +def _backfill_container_created_at(current_engine) -> None: + """容器 created_at 存量回填(2026-09):id 复用区分锚。 + + 反查来源 = op log 的 create_container 成功记录,取 MAX:id 复用 N 次有 N 条 + create 日志,现存容器 = 最近一次成功创建 → MAX 才是当前实体创建时刻。 + 幂等:只补 NULL;无 create 日志的容器维持 NULL(getter 不过滤,兜底)。 + """ + + import logging + + from sqlalchemy import inspect, text + + inspector = inspect(current_engine) + if not (inspector.has_table("containers") and inspector.has_table("operation_logs")): + return + try: + with current_engine.begin() as conn: + conn.execute(text( + "UPDATE containers SET created_at = (" + " SELECT MAX(created_at) FROM operation_logs" + " WHERE target_type = 'container' AND target_id = containers.id" + " AND operation = 'create_container' AND success = 1" + ") WHERE created_at IS NULL" + )) + except Exception as e: # pragma: no cover + logging.getLogger(__name__).warning("container created_at backfill failed: %s", e) + + +def _ensure_container_image_schema() -> None: + """容器镜像归属与构建留痕自愈(2026-09)。 + + create_all 只建新表、不改旧表;旧库仍是单列 image。本函数幂等地补齐: + 改名 → 补列(image_id / last_build_at / 配方两项)→ 建索引 → 清悬挂 → 回填归属 + → 回填配方 → 退役 image_dockerfile → 退役 runtime_image。每一步都可在任意库上重复执行。 + + 必须在任何针对 containers 的 ORM 查询之前跑完:改名未完成时 select(Container) + 会枚举到 containers.runtime_image 而报 no such column。 + """ + + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("containers"): + return + + logger = logging.getLogger(__name__) + existing = {column["name"] for column in inspector.get_columns("containers")} + renamed = "image" in existing and "runtime_image" not in existing + index_names = {index["name"] for index in inspector.get_indexes("containers") if index.get("name")} + # 补列清单:INTEGER / DATETIME / VARCHAR / TEXT 两方言同形,故无需方言分支。 + required_columns = { + "image_id": "ALTER TABLE containers ADD COLUMN image_id INTEGER NULL", + "last_build_at": "ALTER TABLE containers ADD COLUMN last_build_at DATETIME NULL", + "base_image": "ALTER TABLE containers ADD COLUMN base_image VARCHAR(255) NULL", + "dockerfile_body": "ALTER TABLE containers ADD COLUMN dockerfile_body TEXT NULL", + # 启动命令留痕(2026-09 决策)。可空且空即默认,因此存量行无需回填、 + # 行为一字不变——这也是本次引入它最干净的地方。 + "entrypoint": "ALTER TABLE containers ADD COLUMN entrypoint VARCHAR(255) NULL", + } + missing = [name for name in required_columns if name not in existing] + + with current_engine.begin() as conn: + # ① 旧单列改名:数据原地不动(需 SQLite >= 3.25 / MySQL 8.0)。 + # 这一步现在只为一个目的存在——让下面的回填有一个**确定的列名**可读(旧库叫 + # `image`,后来叫 `runtime_image`)。该列在本次自愈的最后会被退役(⑧), + # 因此不会留下任何过渡期状态;它的 NOT NULL 也随之消失。 + if renamed: + conn.execute(text("ALTER TABLE containers RENAME COLUMN image TO runtime_image")) + # ② 补列 + for name in missing: + conn.execute(text(required_columns[name])) + # ③ 索引:DDL 吞异常只 warning,与 _ensure_container_lifecycle_schema 同口径。 + if "ix_containers_image_id" not in index_names: + try: + conn.execute(text("CREATE INDEX ix_containers_image_id ON containers(image_id)")) + except Exception as e: + logger.warning("container image schema index create failed: %s", e) + # ④ 清悬挂:外键只在新库里由 create_all 建出,旧库(SQLite 无法 ALTER 加外键) + # 没有它。模板现在只停用不删除,因此新的悬挂值产生不了;这一步只清理 + # 历史遗留(旧行为下模板被物理删除留下的指向空行的值),并让新库能加上外键。 + if inspector.has_table("images"): + dangling = conn.execute(text( + "UPDATE containers SET image_id = NULL" + " WHERE image_id IS NOT NULL AND image_id NOT IN (SELECT id FROM images)" + )) + if dangling.rowcount: + logger.warning("container image_id cleared (legacy dangling): %s row(s)", dangling.rowcount) + if renamed or missing: + logger.warning("container image schema upgraded: renamed=%s added=%s", renamed, missing) + + # 顺序是硬约束:回填都要读旧列,所以退役必须排在最后一个回填之后。 + _backfill_container_image_id(current_engine) + unconvertible = _backfill_container_dockerfile_parts(current_engine) + if "image_dockerfile" in existing and unconvertible == 0: + _retire_container_image_dockerfile(current_engine) + _retire_runtime_image(current_engine) + + +def _retire_runtime_image(current_engine) -> None: + """退役 containers.runtime_image(2026-09 二次决策)。 + + 它存的是"本次实跑制品的标签"——一个**派生值**(归属标识 + 版本戳一算就有)。标签改由 + `image_tasks.format_image_build_tag` 纯推导之后,它唯一的读点是展示回落,而那个回落在 + 推导成功时永远轮不到;写点却还在每一行上抄一份,正是本变更一路在清理的"第二来源"。 + + **这一步不只是清理,是必须做的**:旧库里这一列(由 `image` 改名而来)是 NOT NULL, + 新代码不再写它 —— 不删掉,新容器根本插不进去。 + + 排在所有回填之后:`_backfill_container_image_id` 正是从这一列反解归属与版本戳的。 + 删列失败(SQLite < 3.35)只报 ERROR 不抛错,但那是**必须人工处理**的状态:调用方会 + 因此插不进新行。所以日志写清怎么办,而不是只丢一句 warning。 + """ + + import logging + + from sqlalchemy import inspect, text + + logger = logging.getLogger(__name__) + inspector = inspect(current_engine) + if not inspector.has_table("containers"): + return + columns = {column["name"] for column in inspector.get_columns("containers")} + if "runtime_image" not in columns: + return + try: + with current_engine.begin() as conn: + conn.execute(text("ALTER TABLE containers DROP COLUMN runtime_image")) + except Exception as e: + logger.error( + "containers.runtime_image drop FAILED (%s). 该列在旧库是 NOT NULL 而新代码不再写它——" + "不删掉就插不进新容器行。请手工执行:ALTER TABLE containers DROP COLUMN runtime_image;", + e, + ) + return + logger.warning("containers.runtime_image retired (tag is derived, not stored)") + + +def _backfill_container_dockerfile_parts(current_engine) -> int | None: + """配方留痕的存量回填(2026-09 二次决策):从旧的单列 image_dockerfile 转出。 + + 旧列存的是**渲染后**的整段文本,输入不可从文本里反解(渲染结果是纯文本,段边界没有 + 标记,反解只会引入脆弱的启发式)。所以这里不做反解,做的是**验证性对齐**: + + 取该容器 image_id 对应模板此刻的配方,渲染一份,与存量文本逐字节比对; + 相同 ⇒ 那就说明当初写进去的就是这一份,输入已知,写入。 + + 对不上就不写(模板改过、注入改过、或本来就是裸镜像容器)。这些行的配方无从重建, + 恢复会以 `data_not_recoverable` 拒绝——那是诚实的,比拿当前模板冒充它跑过的那份好。 + + **返回值是"没能转出的行数"**,`None` 表示数不出来(读失败)。调用方只在拿到 0 时 + 才退役旧列——还有行转不出来、或压根数不清,旧列就是它们配方的唯一留存,不能删。 + + 幂等:只补 base_image 为空的行;失败仅 warning。 + """ + + import logging + + from sqlalchemy import inspect, text + + logger = logging.getLogger(__name__) + inspector = inspect(current_engine) + if not inspector.has_table("containers"): + return 0 + columns = {column["name"] for column in inspector.get_columns("containers")} + if "image_dockerfile" not in columns or "base_image" not in columns: + return 0 + + # 读与写分两次事务:中间要跑 ORM 查询(resolve_image_build 自带 session), + # 和写事务挤在同一个连接上会在 SQLite 上演成锁等待。 + try: + with current_engine.connect() as conn: + rows = conn.execute(text( + "SELECT id, image_id, image_dockerfile FROM containers" + " WHERE image_dockerfile IS NOT NULL AND base_image IS NULL" + )).all() + except Exception as e: # pragma: no cover + logger.warning("container dockerfile parts backfill read failed: %s", e) + return None + if not rows: + return 0 + + from .services.image_tasks import resolve_image_build + + by_template: dict[int, object] = {} + updates = [] + for container_id, image_id, legacy_text in rows: + if image_id is None: + continue + if image_id not in by_template: + build = resolve_image_build(int(image_id)) + by_template[image_id] = build.dockerfile_parts if build is not None else None + parts = by_template[image_id] + if parts is None or parts.render() != legacy_text: + continue + updates.append({ + "container_id": container_id, + "base_image": parts.base_image, + "dockerfile_body": parts.dockerfile_body, + }) + unconvertible = len(rows) - len(updates) + if updates: + try: + with current_engine.begin() as conn: + for values in updates: + conn.execute( + text( + "UPDATE containers SET base_image = :base_image," + " dockerfile_body = :dockerfile_body WHERE id = :container_id" + ), + values, + ) + except Exception as e: # pragma: no cover + logger.warning("container dockerfile parts backfill write failed: %s", e) + return None + logger.warning( + "container dockerfile parts backfilled: %s/%s row(s), %s left unconvertible", + len(updates), len(rows), unconvertible, + ) + return unconvertible + + +def _retire_container_image_dockerfile(current_engine) -> None: + """退役 containers.image_dockerfile:一列渲染结果 → 两项输入(2026-09 二次决策)。 + + 渲染结果是**派生值**,落库就等于给同一个事实造了第二个来源——本变更要消灭的正是 + 这个。它的两个消费者(展示出口、恢复的内容来源)现在都改读两项输入现场渲染。 + + 与 runtime_image 那个过渡残留不同,这里**直接删列**:该列是本次变更新引入的, + 从未随任何版本发布过,没有"旧库还在依赖它"这回事;而留着它会让派生值继续留在库里。 + 调用方只在**所有存量行都转出成功**时才调到这里(还有转不出来的行,旧列就是它们配方 + 的唯一留存)。删列失败(SQLite < 3.35)只 warning:列留着不参与任何业务。 + """ + + import logging + + from sqlalchemy import text + + logger = logging.getLogger(__name__) + try: + with current_engine.begin() as conn: + conn.execute(text("ALTER TABLE containers DROP COLUMN image_dockerfile")) + except Exception as e: + logger.warning("containers.image_dockerfile drop skipped: %s", e) + + +def _backfill_container_image_id(current_engine) -> None: + """容器镜像留痕的存量回填(2026-09):从 runtime_image 的 tag 反解归属与版本戳。 + + tag 形如 `fuxi/image-<模板id>:<版本戳>`,一段字符串里编码了两个事实: + + - 模板 id → 回填 `image_id`(归属) + - 版本戳 → 回填 `last_build_at`(本次构建所依据的模板版本时刻) + + 只认这个形式;裸镜像 tag(如 ubuntu:24.04)无从推断归属,两项都保持 NULL —— 不猜。 + 回填 `image_id` 前确认 images 行仍在,不制造悬挂值。幂等:只补 NULL;失败仅 warning。 + + 解析放在 Python 侧而非 SQL:MySQL 的 REGEXP 与 SQLite 无正则会把同一规则撕裂成 + 两份方言实现。这是全仓库仅存的 tag 正则,只服务于这一次性迁移 —— + 新代码一律直接读 image_id,不再反解字符串。 + """ + + import logging + import re + from datetime import datetime + + from sqlalchemy import inspect, text + + logger = logging.getLogger(__name__) + inspector = inspect(current_engine) + if not (inspector.has_table("containers") and inspector.has_table("images")): + return + # 该列退役之后这一步就无事可做 —— 早退,否则每次启动都会因为查了不存在的列而报一次 + # "backfill failed",把一条正常状态伪装成故障。 + if "runtime_image" not in {c["name"] for c in inspector.get_columns("containers")}: + return + patched = 0 + stamped = 0 + try: + with current_engine.begin() as conn: + # 两类补口一起取:归属缺、或版本戳缺(存量两样都缺)。 + rows = conn.execute(text( + "SELECT id, runtime_image, image_id, last_build_at FROM containers" + " WHERE runtime_image IS NOT NULL" + " AND (image_id IS NULL OR last_build_at IS NULL)" + )).all() + if not rows: + return + known_ids = {row[0] for row in conn.execute(text("SELECT id FROM images")).all()} + for container_id, runtime_image, current_image_id, current_stamp in rows: + match = re.match(r"^fuxi/image-(\d+):(\d{8}T\d{6}Z)$", runtime_image or "") + if not match: + continue + image_id = int(match.group(1)) + # 版本戳与归属相互独立:模板行没了也只是不补归属,版本戳照补—— + # 它是"这个容器当初按哪个版本建的"这个事实,与模板是否还在无关。 + if current_stamp is None: + try: + stamp = datetime.strptime(match.group(2), "%Y%m%dT%H%M%SZ") + except ValueError: + stamp = None + if stamp is not None: + conn.execute( + text("UPDATE containers SET last_build_at = :stamp WHERE id = :container_id"), + {"stamp": stamp, "container_id": container_id}, + ) + stamped += 1 + if current_image_id is not None or image_id not in known_ids: + continue + conn.execute( + text("UPDATE containers SET image_id = :image_id WHERE id = :container_id"), + {"image_id": image_id, "container_id": container_id}, + ) + patched += 1 + except Exception as e: # pragma: no cover + logger.warning("container image backfill failed: %s", e) + return + if patched or stamped: + logger.warning( + "container image backfilled: image_id=%s last_build_at=%s row(s)", patched, stamped + ) + + +def _strip_legacy_snapshot_image_key() -> None: + """清掉已删容器快照 JSON 里的 `image` 键(2026-09 决策)。 + + 那个键存的是删除当刻的运行标签字符串。它有两个消费者,都已改掉: + + - 恢复路径曾拿它当"标签推导失败时的回落"——标签是**派生值**(归属标识 + 构建版本戳), + 两个输入都在容器行上,从 JSON 里再抄一份既多余又会让异常状态被静默掩盖; + - 已删列表出参曾直接把它当展示值——同样改成了由容器行推导,与容器列表同一口径。 + + 消费者没了,留在数据里就是无意义保留(还是会被抄进每一份新快照的派生值)。 + 就地删除,幂等:只剩这个键已不存在时不动那一行。 + + 与 `active_name` 的清空同款处理——数据层面的退役也走启动自愈,人工迁移不必重复一遍。 + """ + + import json + import logging + + from sqlalchemy import inspect, text + + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("deleted_container_restore_snapshot"): + return + + logger = logging.getLogger(__name__) + stripped = 0 + try: + with current_engine.begin() as conn: + rows = conn.execute( + text("SELECT id, snapshot FROM deleted_container_restore_snapshot") + ).all() + for row_id, snapshot in rows: + data = snapshot + if isinstance(data, str): + try: + data = json.loads(data) + except (TypeError, ValueError): + continue + if not isinstance(data, dict) or "image" not in data: + continue + data.pop("image", None) + conn.execute( + text("UPDATE deleted_container_restore_snapshot SET snapshot = :snapshot WHERE id = :row_id"), + {"snapshot": json.dumps(data, ensure_ascii=False), "row_id": row_id}, + ) + stripped += 1 + except Exception as e: # pragma: no cover + logger.warning("legacy snapshot image key cleanup failed: %s", e) + return + if stripped: + logger.warning("legacy snapshot image key stripped: %s row(s)", stripped) + + +def _ensure_machine_image_schema() -> None: + """machine_image 行身份自愈(2026-09):从 (machine_id, image_tag) 收敛到 + (machine_id, image_id)。 + + 标签是**派生值**(归属标识 + 版本戳),不能承担身份——拿它做检索,格式一变即断。 + 旧库上这张表的唯一键落在标签上,本函数幂等地:补 image_id 列 → 从既存标签反解回填 + → 清掉回填不出来的行 → 换唯一键。 + + 反解只服务这一次性迁移,理由同 `_backfill_container_image_id`:存量数据只有标签 + 这一个载体,别无他途。新代码一律按 `(machine_id, image_id)` 检索,不再反解字符串。 + + 顺序是硬约束:换唯一键必须在回填**之后**,否则 (machine_id, image_id) 上有 NULL 重复 + 的行会当场撞新约束。 + """ + + import logging + import re + + from sqlalchemy import inspect, text + + logger = logging.getLogger(__name__) + current_engine = extensions.engine + inspector = inspect(current_engine) + if not inspector.has_table("machine_image"): + return + is_sqlite = current_engine.dialect.name == "sqlite" + columns = {column["name"] for column in inspector.get_columns("machine_image")} + index_names = { + index["name"] for index in inspector.get_indexes("machine_image") if index.get("name") + } + + with current_engine.begin() as conn: + if "image_id" not in columns: + conn.execute(text("ALTER TABLE machine_image ADD COLUMN image_id INTEGER NULL")) + # 回填:只认 fuxi/image-<模板id>:… 这个形式;模板行不在了的也不留(会成悬挂值)。 + rows = conn.execute(text( + "SELECT id, image_tag FROM machine_image WHERE image_id IS NULL" + )).all() + known_ids = ( + {row[0] for row in conn.execute(text("SELECT id FROM images")).all()} + if inspector.has_table("images") + else set() + ) + patched = 0 + for row_id, image_tag in rows: + match = re.match(r"^fuxi/image-(\d+):", image_tag or "") + if not match or int(match.group(1)) not in known_ids: + continue + conn.execute( + text("UPDATE machine_image SET image_id = :image_id WHERE id = :row_id"), + {"image_id": int(match.group(1)), "row_id": row_id}, + ) + patched += 1 + + # 回填不出来的行:身份无从确定,且在复合键语义下不会被任何检索命中。删掉。 + # (它们的存在只会让新唯一键建不上。) + unresolvable = conn.execute( + text("DELETE FROM machine_image WHERE image_id IS NULL") + ).rowcount + + # 唯一键:旧名落在标签上,必须换。MySQL 是独立索引,可直接换; + # SQLite 的 UNIQUE 是内联表约束,ALTER 删不掉(同 2026-09_container_soft_delete_strict_ids.sql + # 的口径)——那边由"写点永不产生重复标签"兜住,不阻塞。 + if "uq_machine_image_tag" in index_names: + conn.execute(text("DROP INDEX uq_machine_image_tag ON machine_image")) + if "uq_machine_image_machine_id_image_id" not in index_names: + try: + conn.execute(text( + "CREATE UNIQUE INDEX uq_machine_image_machine_id_image_id" + " ON machine_image(machine_id, image_id)" + )) + except Exception as e: + logger.warning("machine_image unique index create failed: %s", e) + if not is_sqlite: + # 回填 + 清理之后不该再有空值;MySQL 可收紧,SQLite 改不了列约束。 + try: + conn.execute(text("ALTER TABLE machine_image MODIFY image_id INTEGER NOT NULL")) + except Exception as e: + logger.warning("machine_image image_id NOT NULL failed: %s", e) + + if patched or unresolvable: + logger.warning( + "machine_image composite key migrated: backfilled=%s removed=%s row(s)", + patched, unresolvable, + ) + + +def _should_start_background_tasks() -> bool: + """Return whether Ctrl background tasks should start.""" + + return not getattr(AppConfig, "TESTING", False) and not getattr(AppConfig, "DISABLE_BACKGROUND_TASKS", False) + + +def _start_background_tasks() -> None: + """Start Ctrl background tasks after their DB access is migrated. + + 三个调度器各自按 settings 自门控(disk / mount 有 enabled 开关,未启用时 + start_* 返回 None 不启动);任务只做纯 DB 扫描 + 到期集合的低频动作, + 不发起逐容器探测请求(mount 清理带机器可达 gate)。 + """ + + from .schedulers.container_cleanup_task import start_container_cleanup_scheduler + from .schedulers.container_disk_check_task import start_container_disk_check_scheduler + from .schedulers.container_mount_cleanup_task import start_mount_cleanup_scheduler + + start_container_cleanup_scheduler() + start_container_disk_check_scheduler() + start_mount_cleanup_scheduler() + + +def create_app(config: str | None = None, overrides: dict | None = None) -> FastAPI: + """Create the Ctrl FastAPI application.""" + + _apply_overrides(overrides) + configure_database(AppConfig.SQLALCHEMY_DATABASE_URI) + configure_daily_logging(AppConfig) + _init_database() + # 内部运行时推送共享 token 预热(API 先于 WSS 子进程启动,保证两进程同一 token) + try: + import logging + + from .services.container_module.node_comms import _read_internal_token + + _read_internal_token() + except Exception as e: # pragma: no cover + logging.getLogger(__name__).warning("internal token warmup failed: %s", e) + + @asynccontextmanager + async def lifespan(_: FastAPI): + if _should_start_background_tasks(): + _start_background_tasks() + yield + + app = FastAPI(title="FuxiYu CtrlKernel API", lifespan=lifespan) + app.state.config = AppConfig + app.state.db = db + + app.add_middleware( + CORSMiddleware, + allow_origins=build_allowed_origins(), + allow_credentials=True, + allow_methods=["*"], + 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") + or request.url.path.endswith("/machines/remove_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) return app diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..4d829db --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,29 @@ +"""Ctrl FastAPI 路由聚合。""" +from fastapi import APIRouter + +from . import announcement_api +from . import container_api +from . import image_api +from . import internal_runtime_api +from . import machine_api +from . import operation_log_api +from . import rbac_api +from . import settings_api +from . import user_api + +router = APIRouter(prefix="/api") + +router.include_router(announcement_api.router) +router.include_router(container_api.router) +router.include_router(image_api.router) +router.include_router(internal_runtime_api.router) +router.include_router(machine_api.router) +router.include_router(operation_log_api.router) +router.include_router(rbac_api.router) +router.include_router(settings_api.router) +router.include_router(user_api.router) + + +def register_api(app) -> None: + """注册 Ctrl FastAPI 路由。""" + app.include_router(router) diff --git a/api/announcement_api.py b/api/announcement_api.py new file mode 100644 index 0000000..76b5b47 --- /dev/null +++ b/api/announcement_api.py @@ -0,0 +1,469 @@ +"""公告系统 API 路由。 + +全部端点要求 Operator 权限;认证由 FastAPI dependency 完成。 +""" + +from typing import Any + +from fastapi import APIRouter, Body, Depends, Query, Request +from fastapi.responses import JSONResponse + +from ..constant import AnnouncementStatus, AnnouncementTemplateCategory +from ..extensions import session_scope +from ..repositories import announcement_repo +from ..services import announcement_tasks +from .deps import require_operator + +router = APIRouter(prefix="/announcements", tags=["announcements"]) + + +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}, + ) + + +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 session_scope(commit=False) as session: + rows, total = announcement_repo.list_templates( + category=category, + limit=limit, + offset=offset, + session=session, + ) + templates = [_template_view(t) for t in rows] + return {"success": 1, "templates": templates, "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 _error(400, "name is required", "missing_field") + if not subject_template or not body_template: + return _error(400, "subject_template and body_template are required", "missing_field") + + try: + with session_scope() as session: + 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"), + session=session, + ) + template_data = _template_view(template) + except Exception: + return _error(409, "template name may already exist", "duplicate_entry") + + return {"success": 1, "template": template_data} + + +@router.get("/templates/{template_id}") +def get_template_api( + request: Request, + template_id: int, + _: int = Depends(require_operator), +): + """查看单个模板。""" + + with session_scope(commit=False) as session: + template = announcement_repo.get_template_by_id(template_id, session=session) + 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.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 session_scope() as session: + template = announcement_repo.update_template( + template_id, + session=session, + **{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 session_scope() as session: + template = announcement_repo.get_template_by_id(template_id, session=session) + 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, session=session) + 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 _error(400, "targets must not be empty", "empty_targets") + + try: + targets = [announcement_tasks.TargetEntry(**target) for target in raw_targets] + result = announcement_tasks.resolve_recipients(targets) + except ValueError as e: + 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 session_scope(commit=False) as session: + rows, total = announcement_repo.list_announcements( + status=status, + limit=limit, + offset=offset, + session=session, + ) + announcements = [_announcement_view(row) for row in rows] + sent_count = announcement_repo.count_announcements_by_status(AnnouncementStatus.SENT, session=session) + partial_count = announcement_repo.count_announcements_by_status(AnnouncementStatus.PARTIAL, session=session) + failed_count = announcement_repo.count_announcements_by_status(AnnouncementStatus.FAILED, session=session) + + 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 session_scope(commit=False) as session: + ann = announcement_repo.get_announcement_by_id(announcement_id, session=session) + if ann is not None: + announcement_data = _announcement_view(ann) + if ann is None: + 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, + operator_user_id: int = Depends(require_operator), +): + """重新发送公告。""" + + try: + result = announcement_tasks.resend_announcement_service(announcement_id, operator_user_id=operator_user_id) + except ValueError as e: + reason = str(e) + if reason == "announcement_still_sending": + 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), +): + """复制公告为草稿。""" + + 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), +): + """将公告转成模板。""" + + 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), +): + """删除公告。""" + + ok = announcement_tasks.delete_announcement_service(announcement_id) + if not ok: + 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), +): + """批量删除公告。""" + + announcement_ids = payload.get("announcement_ids") or [] + if not announcement_ids: + return _error(400, "announcement_ids required", "missing_field") + + 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 session_scope(commit=False) as session: + rows, total = announcement_repo.list_drafts( + created_by=operator_user_id, + limit=limit, + offset=offset, + session=session, + ) + drafts = [_draft_view(row) for row in rows] + return {"success": 1, "drafts": drafts, "total": total} + + +@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 _error(400, "title and content are required", "missing_field") + + try: + with session_scope() as session: + 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"), + session=session, + ) + 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 session_scope(commit=False) as session: + draft = announcement_repo.get_draft_by_id(draft_id, session=session) + if draft is not None: + draft_data = _draft_view(draft) + if draft is None: + return _error(404, "draft not found", "not_found") + return {"success": 1, "draft": draft_data} + + +@router.delete("/drafts/{draft_id}") +def delete_draft_api( + request: Request, + draft_id: int, + _: int = Depends(require_operator), +): + """删除草稿。""" + + with session_scope() as session: + ok = announcement_repo.delete_draft(draft_id, session=session) + if not ok: + return _error(404, "draft not found", "not_found") + return {"success": 1, "message": "draft deleted"} + + +@router.post("/drafts/batch-send") +async def batch_send_drafts_api( + request: Request, + payload: dict[str, Any] = Body(default_factory=dict), + operator_user_id: int = Depends(require_operator), +): + """批量发送草稿。""" + + 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, operator_user_id=operator_user_id) + except ValueError as e: + reason = str(e) + status_map = { + "empty_targets": 400, + "too_many_recipients": 400, + "batch_too_large": 400, + } + return _error(status_map.get(reason, 400), reason, reason) + + return {"success": 1, **_model_data(result)} diff --git a/api/container_api.py b/api/container_api.py new file mode 100644 index 0000000..bab68ca --- /dev/null +++ b/api/container_api.py @@ -0,0 +1,1001 @@ +"""容器系统 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 ..constant import OperationType, ROLE +from ..extensions import session_scope +from ..repositories import containers_repo +from ..services import container_tasks as container_service +from ..services.operation_log_tasks import log_failure +from ..utils.Container import Container_info +from ..utils.parsers import parse_bool +from ..schemas.container import ( + CollaboratorRequest, + CleanDeletedContainerMountRequest, + CleanDeletedContainerMountResponse, + ContainerDetailResponse, + ContainerIdRequest, + ContainerOperationResponse, + ContainerStatusRequest, + ContainerStatusResponse, + CreateContainerRequest, + CreateContainerResponse, + DeleteContainerRequest, + DeleteContainerResponse, + ListDeletedContainersRequest, + ListDeletedContainersResponse, + ListAllContainerBrefInformationRequest, + ListAllContainerBrefInformationResponse, + RefreshLastSshLoginTimeRequest, + RefreshLastSshLoginTimeResponse, + ResurrectContainerRequest, + ResurrectContainerResponse, + SetLongTermContainerRequest, + SetLongTermContainerResponse, + UpdateRoleRequest, +) +from .deps import require_current_user, require_permission, require_resource, require_machine_of_container + +router = APIRouter(prefix="/containers", tags=["containers"]) + +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, + "container_busy": 409, + "container_paused": 409, + "container_failed": 409, + "container_host_offline": 503, + "container_host_maintenance": 503, + "container_status_unknown": 409, + "node_endpoint_not_found": 502, + # 机器准入族(machine_access._ensure_machine_online_for_operation)。 + # 创建路径**没有容器**、派生不出有效状态,所以撞的是这一族,而不是 container_host_*; + # 漏映射会让"维护中/离线/机器不存在"都落到 500,用户看到「服务器出现错误」而不是 + # 「机器正在维护中」——动作类路径(start/stop/remove)由有效状态机先拦,不受影响。 + "machine_maintenance": 503, + "machine_offline": 503, + # 镜像准入族:创建容器时模板不可用。 + # 「已停用」与「不属于该用户」都必须与「模板不存在」区分开——笼统报 not_found + # 会让用户去查一个明明存在的模板。 + "image_not_found": 404, + "image_access_denied": 403, + "image_not_ready": 400, + "machine_not_found": 404, + "container_not_found": 404, + "machine_permission_denied": 403, + "container_permission_denied": 403, + "insufficient_permission": 403, + "long_term_limit_reached": 409, + "data_not_recoverable": 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 _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(container_id: int) -> None: + """异步刷新单个容器磁盘用量。""" + + try: + du = container_service.get_container_disk_usage(container_id, timeout=20.0) + if isinstance(du, dict) and du.get("container"): + from ..repositories import containers_repo as repo + + with session_scope(commit=False) as session: + c = repo.get_by_id(container_id, session=session) + if not c: + return + container_pk = c.id + 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) + # 容器磁盘上限统一以 machine.max_disk_size_gb 现算派生(2026-09-01 决策), + # 不再落库 disk_limit_bytes 机器级拷贝。 + with session_scope() as session: + repo.update_container( + container_pk, + disk_overlay_rw_bytes=overlay, + disk_bind_mount_bytes=bind, + disk_total_bytes=total, + disk_checked_at=datetime.utcnow(), + session=session, + ) + 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_permission("container:create")), + _res: int = Depends(require_resource("machine", "machine_id")), +): + """创建容器。 + + 主体判定在 API 边界:owner 缺失/0/自己 → 自己(一般操作); + 代建(owner≠自己)须持有 container:manage 且 owner 对该机器有权限。 + """ + + data = _payload_data(payload) + machine_id = int(data.get("machine_id", 0) or 0) + owner_user_id = int(data.get("owner_user_id") or 0) or operator_user_id + image_id = int(data.get("image_id") or 0) or None + if owner_user_id != operator_user_id: + # 代建门禁(API 边界):切换主体须 container:manage,且 owner 对该机器有权限 + from ..services.rbac_service import user_has_entity, user_has_resource + if not user_has_entity(operator_user_id, "container:manage"): + return _error( + 403, + "creating container for another user requires container:manage", + "insufficient_permission", + ) + if not user_has_resource(owner_user_id, "machine", machine_id): + return _error( + 403, + f"owner user {owner_user_id} has no access to machine {machine_id}", + "machine_permission_denied", + ) + + # 镜像模板必填(2026-09 决策):容器必须能回答「重建时用哪个模板」,否则重建/恢复 + # 只能拿 tag 赌 Node 上的旧制品还在(制品被 prune 即永久失去恢复能力)。 + # 校验放在 API 边界而非 service 门户:恢复链路复用同一个门户,而它的 legacy 通路 + # (快照无 image_id / 模板已删)必须放行。也不做成 pydantic 必填 —— 那会返回 422, + # 而本仓库统一的错误形状是 400 + error_reason。 + if image_id is None: + return _error(400, "image_id is required", "invalid_payload") + + container_raw = data.get("container") or {} + if not container_raw: + container_raw = { + "GPU_LIST": data.get("GPU_LIST", []), + "CPU_NUMBER": data.get("CPU_NUMBER", 0), + "MEMORY": data.get("MEMORY", 0), + "NAME": data.get("NAME", ""), + } + + public_key = data.get("public_key") or None + try: + from ..services import image_tasks as image_service + + usability = image_service.Can_use_image_for_container(operator_user_id, image_id) + if usability is image_service.ImageUsability.NOT_FOUND: + return _error(404, f"image {image_id} not found", "image_not_found") + if usability is image_service.ImageUsability.DISABLED: + return _error(400, f"image {image_id} is not ready for use", "image_not_ready") + if usability is image_service.ImageUsability.DENIED: + return _error(403, "image access denied", "image_access_denied") + # machine_id 透传:标签解析先看该机器上是否已有未过时的派发记录(预检查) + build = image_service.resolve_image_build(image_id, machine_id) + if build is None: + return _error(404, f"image {image_id} not found", "image_not_found") + image_build = build.payload + # 镜像 tag 一律由平台从模板解析,请求体里带的 image / IMAGE 不再采信 —— + # 那是模板出现前的旧通路(客户端自选任意镜像,且绕开平台注入)。 + container_raw["image"] = image_build["image_tag"] + + 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) + 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 "" + # 上面已无条件写入平台解析出的 tag;请求体里没有第二个来源可回落 + image = container_raw["image"] + 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 _error(400, f"Invalid container payload: {e}", "invalid_payload") + + try: + if not container_service.Create_container( + owner_user_id=owner_user_id, + machine_id=machine_id, + container=container_obj, + public_key=public_key, + operator_user_id=operator_user_id, + image_build=image_build, + image_id=image_id, + # 本次构建所依据的模板版本时刻与实际使用的配方 —— 两者都要落成 + # 容器上的留痕,用于判定"是否落后"与精确还原。 + image_version_at=build.version_at, + dockerfile_parts=build.dockerfile_parts, + ): + 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) + 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) + 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"} + + +@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_permission("container:operation")), + _res: int = Depends(require_resource("container:admin", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """删除容器。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + if not container_service.remove_container(container_id=container_id, operator_user_id=operator_user_id): + return _error(500, "Failed to delete container", "delete_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + 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) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return {"success": 1, "message": "Container deleted successfully"} + + +# Deleted-container management is a method-level admin surface. +# These records are not live container resources, so keep these endpoints on +# container:manage and do not add require_resource("container...", "container_id"). +@router.post("/list_deleted_containers", response_model=ListDeletedContainersResponse) +def list_deleted_containers_api( + payload: ListDeletedContainersRequest = Body(default_factory=ListDeletedContainersRequest), + _: int = Depends(require_permission("container:manage")), +): + data = _payload_data(payload) + try: + result = container_service.list_deleted_containers( + page_number=int(data.get("page_number", 1) or 1), + page_size=int(data.get("page_size", 20) or 20), + ) + except Exception as e: + return _error(500, f"Failed to list deleted containers: {e}", "list_failed") + return {"success": 1, **result} + + +@router.post("/clean_deleted_container_mount", response_model=CleanDeletedContainerMountResponse) +def clean_deleted_container_mount_api( + payload: CleanDeletedContainerMountRequest = Body(default_factory=CleanDeletedContainerMountRequest), + operator_user_id: int = Depends(require_permission("container:manage")), +): + data = _payload_data(payload) + deleted_id = int(data.get("deleted_id", 0) or 0) or None + mount_cleanup_id = int(data.get("mount_cleanup_id", 0) or 0) + try: + result = container_service.clean_deleted_container_mount( + deleted_id=deleted_id, + mount_cleanup_id=mount_cleanup_id, + operator_user_id=operator_user_id, + ) + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + 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) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return { + "success": 1, + "message": "mount cleaned", + "deleted_id": result.get("deleted_id"), + "mount_cleanup_id": result.get("mount_cleanup_id"), + } + + +@router.post("/resurrect_container", response_model=ResurrectContainerResponse) +def resurrect_container_api( + payload: ResurrectContainerRequest = Body(default_factory=ResurrectContainerRequest), + operator_user_id: int = Depends(require_permission("container:manage")), +): + data = _payload_data(payload) + deleted_id = int(data.get("deleted_id", 0) or 0) + # 内容来源只在「模板 READY 但容器落后」那一支必需(后端不设默认,默认预选属于界面)。 + # 传了但落在别的分支时被忽略——那些分支内容不会改变或无从选择。 + content_source = data.get("content_source") or None + if content_source is not None and content_source not in container_service.RESTORE_MODES: + return _error(400, f"invalid content_source: {content_source}", "invalid_payload") + try: + result = container_service.resurrect_container( + deleted_id=deleted_id, + operator_user_id=operator_user_id, + content_source=content_source, + ) + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + 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) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return { + "success": 1, + "message": "container resurrect requested", + "container_id": result.get("container_id"), + } if not result.get("requires_choice") else { + # 二选一:**没有恢复**,把两份内容交回调用方选。容器留痕只在这里出去。 + "success": 1, + "message": "restore requires a choice: snapshot or current template", + **result, + } + + +@router.post("/set_long_term_container", response_model=SetLongTermContainerResponse) +def set_long_term_container_api( + request: Request, + payload: SetLongTermContainerRequest = Body(default_factory=SetLongTermContainerRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:root", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """设置长驻容器。""" + + data = _payload_data(payload) + try: + container_id = int(data.get("container_id")) + except Exception: + return _error(400, "invalid container_id", "invalid_payload") + is_long_term = parse_bool(data.get("is_long_term")) + if is_long_term is None: + return _error(400, "is_long_term must be boolean", "invalid_payload") + + try: + result = container_service.set_long_term_container( + container_id=container_id, + is_long_term=is_long_term, + operator_user_id=operator_user_id, + ) + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.SET_LONG_TERM, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason, + detail={"container_id": container_id, "is_long_term": is_long_term}, + ) + 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) + log_failure( + operation=OperationType.SET_LONG_TERM, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "internal_error", + detail={"container_id": container_id, "is_long_term": is_long_term}, + ) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return {"success": 1, **result} + + +@router.post("/start_container", response_model=ContainerOperationResponse) +def start_container_api( + request: Request, + payload: ContainerIdRequest = Body(default_factory=ContainerIdRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:admin", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """启动容器。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + if not container_service.start_container(container_id=container_id, operator_user_id=operator_user_id): + log_failure( + operation=OperationType.START_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="start_failed", + detail={"container_id": container_id}, + ) + return _error(500, "Failed to start container", "start_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.START_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason, + detail={"container_id": container_id}, + ) + 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) + log_failure( + operation=OperationType.START_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "internal_error", + detail={"container_id": container_id}, + ) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return {"success": 1, "message": "Container start request sent"} + + +@router.post("/stop_container", response_model=ContainerOperationResponse) +def stop_container_api( + request: Request, + payload: ContainerIdRequest = Body(default_factory=ContainerIdRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:admin", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """停止容器。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + if not container_service.stop_container(container_id=container_id, operator_user_id=operator_user_id): + log_failure( + operation=OperationType.STOP_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="stop_failed", + detail={"container_id": container_id}, + ) + return _error(500, "Failed to stop container", "stop_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.STOP_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason, + detail={"container_id": container_id}, + ) + 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) + log_failure( + operation=OperationType.STOP_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "internal_error", + detail={"container_id": container_id}, + ) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return {"success": 1, "message": "Container stop request sent"} + + +@router.post("/restart_container", response_model=ContainerOperationResponse) +def restart_container_api( + request: Request, + payload: ContainerIdRequest = Body(default_factory=ContainerIdRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:admin", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """重启容器。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + if not container_service.restart_container(container_id=container_id, operator_user_id=operator_user_id): + log_failure( + operation=OperationType.RESTART_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="restart_failed", + detail={"container_id": container_id}, + ) + return _error(500, "Failed to restart container", "restart_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.RESTART_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason, + detail={"container_id": container_id}, + ) + 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) + log_failure( + operation=OperationType.RESTART_CONTAINER, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "internal_error", + detail={"container_id": container_id}, + ) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + return {"success": 1, "message": "Container restart request sent"} + + +@router.post("/add_collaborator", response_model=ContainerOperationResponse, status_code=201) +def add_collaborator_api( + request: Request, + payload: CollaboratorRequest = Body(default_factory=CollaboratorRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:root", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """添加协作者。""" + + data = _payload_data(payload) + user_id = data.get("user_id", "") + container_id = int(data.get("container_id", 0) or 0) + 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, + ): + log_failure( + operation=OperationType.ADD_COLLABORATOR, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="add_collaborator_failed", + detail={"user_id": user_id, "role": role}, + ) + return _error(500, "Failed to add collaborator", "add_collaborator_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.ADD_COLLABORATOR, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "add_collaborator_failed", + detail={"user_id": user_id, "role": role}, + ) + if reason == "container_offline": + return _error(400, str(e), reason) + return _error(REASON_STATUS_MAP.get(reason, 500), str(e), reason) + except Exception as e: + log_failure( + operation=OperationType.ADD_COLLABORATOR, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="internal_error", + detail={"user_id": user_id, "role": role}, + ) + return _error(500, f"Internal error: {e}", "internal_error") + return {"success": 1, "message": "Collaborator added successfully"} + + +@router.post("/remove_collaborator", response_model=ContainerOperationResponse) +def remove_collaborator_api( + request: Request, + payload: CollaboratorRequest = Body(default_factory=CollaboratorRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:root", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """移除协作者。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + user_id = data.get("user_id", "") + try: + if not container_service.remove_collaborator( + container_id=container_id, + user_id=user_id, + operator_user_id=operator_user_id, + ): + log_failure( + operation=OperationType.REMOVE_COLLABORATOR, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="remove_collaborator_failed", + detail={"user_id": user_id}, + ) + return _error(500, "Failed to remove collaborator", "remove_collaborator_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.REMOVE_COLLABORATOR, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "remove_collaborator_failed", + detail={"user_id": user_id}, + ) + if reason == "container_offline": + return _error(400, str(e), reason) + return _error(REASON_STATUS_MAP.get(reason, 500), str(e), reason) + except Exception as e: + log_failure( + operation=OperationType.REMOVE_COLLABORATOR, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="internal_error", + detail={"user_id": user_id}, + ) + return _error(500, f"Internal error: {e}", "internal_error") + return {"success": 1, "message": "Collaborator removed successfully"} + + +@router.post("/update_role", response_model=ContainerOperationResponse) +def update_role_api( + request: Request, + payload: UpdateRoleRequest = Body(default_factory=UpdateRoleRequest), + operator_user_id: int = Depends(require_permission("container:operation")), + _res: int = Depends(require_resource("container:root", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """更新协作者角色。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + user_id = data.get("user_id", "") + updated_role = data.get("updated_role", "COLLABORATOR") + try: + if not container_service.update_role( + container_id=container_id, + user_id=user_id, + updated_role=ROLE(updated_role), + operator_user_id=operator_user_id, + ): + log_failure( + operation=OperationType.UPDATE_COLLABORATOR_ROLE, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="update_role_failed", + detail={"user_id": user_id, "new_role": updated_role}, + ) + return _error(500, "Failed to update role", "update_role_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + log_failure( + operation=OperationType.UPDATE_COLLABORATOR_ROLE, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason=reason or "update_role_failed", + detail={"user_id": user_id, "new_role": updated_role}, + ) + if reason == "container_offline": + return _error(400, str(e), reason) + return _error(REASON_STATUS_MAP.get(reason, 500), str(e), reason) + except Exception as e: + log_failure( + operation=OperationType.UPDATE_COLLABORATOR_ROLE, + target_type="container", + target_id=container_id, + operator_user_id=operator_user_id, + error_reason="internal_error", + detail={"user_id": user_id, "new_role": updated_role}, + ) + return _error(500, f"Internal error: {e}", "internal_error") + return {"success": 1, "message": "Role updated successfully"} + + +@router.post("/unpause_container", response_model=ContainerOperationResponse) +def unpause_container_api( + request: Request, + payload: ContainerIdRequest = Body(default_factory=ContainerIdRequest), + operator_user_id: int = Depends(require_permission("container:manage")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """恢复暂停容器(manage)。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + if container_service.unpause_container(container_id=container_id, operator_user_id=operator_user_id): + return {"success": 1, "message": "Container unpaused"} + return _error(500, "Failed to unpause container", "unpause_failed") + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + return _error(REASON_STATUS_MAP.get(reason, 500), str(e), reason) + except Exception as e: + return _error(500, f"Internal error: {e}", "internal_error") + + +@router.post("/get_container_detail_information", response_model=ContainerDetailResponse) +def get_container_detail_information_api( + request: Request, + payload: ContainerIdRequest = Body(default_factory=ContainerIdRequest), + operator_user_id: int = Depends(require_permission("container:view")), + _res: int = Depends(require_resource("container:collaborator", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """查询容器详情。""" + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + container_info = container_service.get_container_detail_information(container_id=container_id) + except ValueError: + return _error(404, "Container not found", "container_not_found") + return {"success": 1, "container_info": _dump_model(container_info)} + + +@router.post("/container_status", response_model=ContainerStatusResponse) +def container_status_api( + request: Request, + payload: ContainerStatusRequest = Body(default_factory=ContainerStatusRequest), + operator_user_id: int = Depends(require_permission("container:view")), + _res: int = Depends(require_resource("container:collaborator", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """查询容器状态(与其他 getter 同构:view + 容器角色 + 机器)。 + + 查询与鉴权统一以 container_id 为键(前端心跳本就传该字段);name+machine + 不再作为查询载体,杜绝「用自己可访问的 container_id 过资源检查、再按任意 + name+machine 探测他人容器状态」的错位。 + """ + + data = _payload_data(payload) + container_id = _machine_id_or_none(data.get("container_id")) + if container_id is None: + return {"effective_status": None} + try: + with session_scope(commit=False) as session: + container = containers_repo.get_by_id(container_id, session=session) + if not container: + return {"effective_status": None} + from ..services.container_module.node_comms import get_cached_container_runtime_metrics + from ..services.container_module.pydantic_models import _derive_effective_status + return { + "effective_status": _derive_effective_status(container.container_status, container.machine_id, container=container), + "failed_reason": getattr(container, "failed_reason", None), + "failed_detail": getattr(container, "failed_detail", None), + "runtime_metrics": get_cached_container_runtime_metrics(container.machine_id, container.name), + } + except Exception as e: + return _error(500, str(e), "internal_error") + + +@router.post("/get_container_operation_logs") +def get_container_operation_logs_api( + request: Request, + payload: ContainerIdRequest = Body(default_factory=ContainerIdRequest), + operator_user_id: int = Depends(require_permission("container:view")), + _res: int = Depends(require_resource("container:collaborator", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """该容器的操作历史(能看容器的即可看其事件;operator 全量仍走 /admin/operation_logs)。""" + + from ..repositories import operation_log_repo + from ..repositories import user_repo + + data = _payload_data(payload) + container_id = int(data.get("container_id", 0) or 0) + try: + with session_scope(commit=False) as session: + container = containers_repo.get_by_id(container_id, session=session) + # 容器 id 复用区分(2026-09):SQLite 删除后 id 可复用,op log 不级联删除, + # 旧容器日志早于当前容器 created_at——按时间锚过滤,新容器只看自己的历史。 + # created_at 为 NULL 的旧容器(未回填)不过滤,回退旧行为。 + created_after = container.created_at.isoformat() if (container and container.created_at) else None + rows, _ = operation_log_repo.list_logs( + session=session, + page=1, + page_size=50, + target_type="container", + target_id=container_id, + start=created_after, + ) + logs = [] + for row in rows: + item = operation_log_repo.serialize(row) + username = None + if item.get("operator_user_id") is not None: + username = user_repo.get_name_by_id(item["operator_user_id"], session=session) + item["operator_username"] = username + logs.append(item) + except Exception as e: + return _error(500, f"failed to list container operation logs: {e}", "internal_error") + return {"success": 1, "logs": logs} + + +@router.post("/refresh_last_ssh_login_time", response_model=RefreshLastSshLoginTimeResponse) +def refresh_last_ssh_login_time_api( + request: Request, + payload: RefreshLastSshLoginTimeRequest = Body(default_factory=RefreshLastSshLoginTimeRequest), + operator_user_id: int = Depends(require_permission("container:view")), + _res: int = Depends(require_resource("container:collaborator", "container_id")), + _machine: int = Depends(require_machine_of_container("container_id")), +): + """刷新容器最近 SSH 登录时间。""" + + data = _payload_data(payload) + container_id = _machine_id_or_none(data.get("container_id", 0)) + if container_id is None: + return _error(400, "invalid container_id", "invalid_payload") + + with session_scope(commit=False) as session: + container = containers_repo.get_by_id(container_id, session=session) + if not container: + return _error(404, "Container not found", "container_not_found") + try: + # 走读侧同一份工具,而不是自己拼:此前这里调用 build_cleanup_info 时**没传顺延**, + # 于是本接口的倒计时一直是"裸"的(不含已结算顺延,也不含正在进行的窗口), + # 与详情/提醒/清理的口径不一致。 + cleanup_info = container_service.get_container_cleanup_state(container) + last_time = cleanup_info.get("last_ssh_login_time") + threading.Thread( + target=_refresh_disk_async, + args=(container.id,), + daemon=True, + ).start() + except container_service.NodeServiceError as e: + reason = getattr(e, "reason", None) + 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) + return _error(REASON_STATUS_MAP.get(reason, 500), f"Internal error: {e}", reason or "internal_error") + + return { + "success": 1, + "container_id": container.id, + "container_name": container.name, + "last_ssh_login_time": last_time, + "cleanup_after_days": cleanup_info.get("cleanup_after_days"), + "cleanup_at": cleanup_info.get("cleanup_at"), + "seconds_until_cleanup": cleanup_info.get("seconds_until_cleanup"), + "cleanup_status": cleanup_info.get("cleanup_status"), + } + + +@router.post("/list_all_container_bref_information", response_model=ListAllContainerBrefInformationResponse) +def list_all_containers_bref_information_api( + request: Request, + payload: ListAllContainerBrefInformationRequest = Body(default_factory=ListAllContainerBrefInformationRequest), + request_user_id: int = Depends(require_permission("container:view")), +): + """分页查询容器摘要。""" + + data = _payload_data(payload) + machine_id = _machine_id_or_none(data.get("machine_id", "")) + user_id = _user_id_or_none(data.get("user_id", "")) + container_search = str(data.get("container_search") or "").strip() or None + page_number = int(data.get("page_number", 0) or 0) + page_size = int(data.get("page_size", 10) or 10) + + try: + result = container_service.list_all_container_bref_information( + machine_id=machine_id, + request_user_id=request_user_id, + page_number=page_number, + page_size=page_size, + user_id=user_id, + container_search=container_search, + viewer_user_id=request_user_id, + ) + containers_info = result.get("containers", []) + total_page = result.get("total_page", 1) + total_number = result.get("total_number", len(containers_info)) + long_term_container_remaining = result.get("long_term_container_remaining") + long_term_container_limit = result.get("long_term_container_limit") + except Exception as e: + reason = getattr(e, "reason", None) or getattr(e, "error_reason", None) or "list_failed" + return _error(REASON_STATUS_MAP.get(reason, 500), f"Failed to list containers: {e}", reason) + + out = [_dump_model(c) for c in containers_info] + payload_out: dict[str, Any] = { + "success": 1, + "containers_info": out, + "total_page": total_page, + "total_number": total_number, + } + if user_id is not None: + payload_out["long_term_container_remaining"] = long_term_container_remaining + payload_out["long_term_container_limit"] = long_term_container_limit + return payload_out diff --git a/api/deps.py b/api/deps.py new file mode 100644 index 0000000..b268264 --- /dev/null +++ b/api/deps.py @@ -0,0 +1,133 @@ +from fastapi import Cookie, Depends, HTTPException, Request + +from ..extensions import session_scope +from ..repositories import authentications_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 session_scope(commit=False) as session: + if not authentications_repo.is_token_valid(auth_token, session=session): + 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, session=session) + + +def require_operator( + request: Request, + user_id: int = Depends(require_current_user), +) -> int: + """校验管理权限(实体通配 bypass_auth_entity)并返回 user_id。""" + + from ..services.rbac_service import _has_entity_direct + + if not _has_entity_direct(user_id, "bypass_auth_entity"): + raise HTTPException( + status_code=403, + detail={"success": 0, "message": "insufficient permissions", "error_reason": "insufficient_permission"}, + ) + return user_id + + +# ── RBAC 两层模型挂载(fuxi平台继续开发.md「RBAC · 两层模型」) ────────── + +def require_permission(entity_code: str): + """第一层 · 方法级判别:用户须持有 authEntity(含 operator 过渡兼容)。 + + 用法:user_id: int = Depends(require_permission("container:create")) + """ + def dep(request: Request, user_id: int = Depends(require_current_user)) -> int: + from ..services.rbac_service import user_has_entity + if not user_has_entity(user_id, entity_code): + raise HTTPException( + status_code=403, + detail={"success": 0, "message": "insufficient permissions", + "error_reason": "insufficient_permission"}, + ) + return user_id + + return dep + + + +async def _read_resource_id(request: Request, id_field: str): + """从 path / POST body / query 读取资源 id(依赖层与路由参数隔离,只能走 request)。""" + rid = request.path_params.get(id_field) + if rid is None and request.method in ("POST", "PUT", "PATCH"): + try: + body = await request.json() + rid = body.get(id_field) if isinstance(body, dict) else None + except Exception: + rid = None + if rid is None: + rid = request.query_params.get(id_field) + if rid == "": + return None + return rid + + + +def require_resource(resource_type: str, id_field: str = "id"): + """第二层 · 资源级判别:当前用户对指定资源有访问权。 + + 用法:user_id: int = Depends(require_resource("container", "container_id")) + """ + async def dep(request: Request, user_id: int = Depends(require_current_user)) -> int: + rid = await _read_resource_id(request, id_field) + if rid is None: + raise HTTPException(status_code=400, + detail={"success": 0, "message": f"missing resource id field {id_field!r}", + "error_reason": "invalid_resource_id"}) + from ..services.rbac_service import user_has_resource + if not user_has_resource(user_id, resource_type, int(rid)): + raise HTTPException( + status_code=403, + detail={"success": 0, "message": "resource access denied", + "error_reason": "resource_access_denied"}, + ) + return user_id + + return dep + + + +def require_machine_of_container(id_field: str = "container_id"): + """容器操作共用层:对容器所在机器有访问权(机器权限语义:可申请/管理该机器)。 + + 每个容器方法都叠加此层——操作容器 = 在机器上做事,须先确认机器访问权。 + """ + async def dep(request: Request, user_id: int = Depends(require_current_user)) -> int: + rid = await _read_resource_id(request, id_field) + if rid is None: + raise HTTPException(status_code=400, + detail={"success": 0, "message": f"missing resource id field {id_field!r}", + "error_reason": "invalid_resource_id"}) + from ..repositories.containers_repo import get_machine_id_by_container_id + with session_scope(commit=False) as session: + machine_id = get_machine_id_by_container_id(int(rid), session=session) + if machine_id is None: + raise HTTPException(status_code=404, + detail={"success": 0, "message": "container not found", + "error_reason": "container_not_found"}) + from ..services.rbac_service import user_has_resource + if not user_has_resource(user_id, "machine", machine_id): + raise HTTPException( + status_code=403, + detail={"success": 0, "message": "machine access denied", + "error_reason": "machine_access_denied"}, + ) + return user_id + + return dep diff --git a/api/image_api.py b/api/image_api.py new file mode 100644 index 0000000..da59406 --- /dev/null +++ b/api/image_api.py @@ -0,0 +1,160 @@ +"""镜像模板 API。""" + +from typing import Any + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError + +from ..schemas.image import ( + CreateImageRequest, + CreateImageResponse, + DeleteImageRequest, + DeleteImageResponse, + ImageDetailResponse, + ListImageBriefResponse, + UpdateImageRequest, + UpdateImageResponse, +) +from ..services import image_tasks as image_service +from .deps import require_current_user, require_permission, require_resource + +router = APIRouter(prefix="/images", tags=["images"]) + + +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) + if hasattr(model, "dict"): + try: + return model.dict(exclude_none=exclude_none) + except TypeError: + return model.dict() + if isinstance(model, dict): + return model + return dict(getattr(model, "__dict__", {})) + + +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) + + +##################### +# 创建镜像 + + +@router.post("/create_image", response_model=CreateImageResponse, status_code=201) +def create_image_api( + message: CreateImageRequest, + operator_user_id: int = Depends(require_permission("image:edit")), +): + """创建镜像模板。""" + + data = _model_data(message) + try: + image_id = image_service.Create_image( + name=data["name"], + description=data.get("description"), + base_image=data["base_image"], + dockerfile_body=data.get("dockerfile_body") or "", + status=data.get("status") or None, + operator_user_id=operator_user_id, + ) + except IntegrityError as exc: + detail = str(exc.orig) if hasattr(exc, "orig") else str(exc) + return _error(409, f"Duplicate entry: {detail}", "duplicate_entry") + except Exception as exc: + reason = getattr(exc, "error_reason", None) + return _error(400 if reason else 500, str(exc), reason or "create_failed") + return {"success": 1, "message": "Image created successfully", "image_id": image_id} + + +##################### +# 更新镜像 + + +@router.post("/update_image", response_model=UpdateImageResponse) +def update_image_api( + message: UpdateImageRequest, + operator_user_id: int = Depends(require_permission("image:edit")), + _: int = Depends(require_resource("image", "image_id")), +): + """更新镜像模板。""" + + data = _model_data(message, exclude_none=True) + try: + ok = image_service.Update_image(operator_user_id=operator_user_id, **data) + except IntegrityError as exc: + detail = str(exc.orig) if hasattr(exc, "orig") else str(exc) + return _error(409, f"Duplicate entry: {detail}", "duplicate_entry") + except Exception as exc: + reason = getattr(exc, "error_reason", None) + return _error(400 if reason else 500, str(exc), reason or "update_failed") + if not ok: + return _error(404, "image not found", "image_not_found") + return {"success": 1, "message": "Image updated successfully"} + + +##################### +# 删除镜像 + + +@router.post("/delete_image", response_model=DeleteImageResponse) +def delete_image_api( + message: DeleteImageRequest, + operator_user_id: int = Depends(require_permission("image:manage")), + _: int = Depends(require_resource("image", "image_id")), +): + """删除镜像模板。""" + + ok = image_service.Delete_image( + image_id=message.image_id, + operator_user_id=operator_user_id, + ) + if not ok: + return _error(404, "image not found", "image_not_found") + return {"success": 1, "message": "Image deleted successfully"} + + +##################### +# 查询镜像 + + +@router.get("/get_image_detail_information", response_model=ImageDetailResponse) +def get_image_detail_information_api( + image_id: int = Query(..., ge=1), + _: int = Depends(require_permission("image:view")), + __: int = Depends(require_resource("image", "image_id")), +): + """查询镜像模板详情,包含基础镜像与业务 Dockerfile 片段。""" + + image = image_service.Get_image_detail(image_id) + if image is None: + return _error(404, "image not found", "image_not_found") + return {"success": 1, "image": image} + + +@router.get("/list_image_bref_information", response_model=ListImageBriefResponse) +def list_image_bref_information_api( + page_number: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1), + image_search: str | None = Query(default=None), + mine_only: bool = Query(default=False), + viewer_user_id: int = Depends(require_current_user), + _: int = Depends(require_permission("image:view")), +): + """分页查询镜像模板概要。""" + + result = image_service.List_image_bref_information( + page_number=page_number, + page_size=page_size, + image_search=(image_search or "").strip() or None, + viewer_user_id=viewer_user_id, + mine_only=mine_only, + ) + return {"success": 1, **result} diff --git a/api/internal_runtime_api.py b/api/internal_runtime_api.py new file mode 100644 index 0000000..3ae3663 --- /dev/null +++ b/api/internal_runtime_api.py @@ -0,0 +1,63 @@ +"""Ctrl 内部运行态 buffer 写入端点。""" + +import hmac +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from ..config import AppConfig +from ..services.container_module import node_comms + +router = APIRouter(prefix="/internal/runtime", tags=["internal-runtime"], include_in_schema=False) + +_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +class RuntimeSnapshotRequest(BaseModel): + machine_id: int = Field(..., ge=1) + snapshot: dict[str, Any] = Field(default_factory=dict) + + +def _is_loopback(request: Request) -> bool: + client = request.client + if client is None: + return False + if client.host in _LOOPBACK_HOSTS: + return True + return bool(getattr(AppConfig, "TESTING", False) and client.host == "testclient") + + +def _valid_internal_token(request: Request) -> bool: + """共享 token 校验(常量时间比较);测试环境(testclient)豁免与 _is_loopback 一致。""" + + client = request.client + if getattr(AppConfig, "TESTING", False) and client is not None and client.host == "testclient": + return True + provided = request.headers.get("X-Internal-Token", "") or "" + expected = node_comms._read_internal_token() or "" + return bool(expected) and hmac.compare_digest(provided, expected) + + +def _forbidden() -> HTTPException: + return HTTPException(status_code=403, detail={"success": 0, "message": "forbidden", "error_reason": "forbidden"}) + + +@router.post("/containers") +def write_container_runtime_snapshot(request: Request, payload: RuntimeSnapshotRequest): + """WSS 子进程写入容器运行态 buffer;非业务 API(loopback + 共享 token 双重校验)。""" + + if not _is_loopback(request) or not _valid_internal_token(request): + raise _forbidden() + updated = node_comms.write_container_runtime_buffer(payload.machine_id, payload.snapshot) + return {"success": 1, "updated": updated} + + +@router.post("/machines") +def write_machine_runtime_snapshot(request: Request, payload: RuntimeSnapshotRequest): + """WSS 子进程写入机器运行态 buffer;非业务 API(loopback + 共享 token 双重校验)。""" + + if not _is_loopback(request) or not _valid_internal_token(request): + raise _forbidden() + updated = node_comms.write_machine_runtime_buffer(payload.machine_id, payload.snapshot) + return {"success": 1, "updated": updated} diff --git a/api/machine_api.py b/api/machine_api.py new file mode 100644 index 0000000..45a9b74 --- /dev/null +++ b/api/machine_api.py @@ -0,0 +1,369 @@ +from typing import Any + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse + +from ..constant import OperationType +from ..extensions import session_scope +from ..repositories import machine_repo +from ..schemas.machine import ( + AddMachinePermissionRequest, + AddMachinePermissionResponse, + ListMachineBriefRequest, + ListMachineBriefResponse, + ListMachinePermissionsResponse, + RemoveMachinePermissionRequest, + RemoveMachinePermissionResponse, + MachineDetailResponse, + MachineIdRequest, + MachineStatusRequest, + MachineStatusResponse, + RegisterMachineByTrustAnchorRequest, + RegisterMachineWithProfileResponse, + RemoveMachineRequest, + RemoveMachineResponse, + RenewMachineTrustRequest, + RenewMachineTrustResponse, + SetMachineMaintenanceRequest, + SetMachineMaintenanceResponse, + UpdateMachineRequest, + UpdateMachineResponse, +) +from ..services import machine_tasks as machine_service +from ..services.container_module import node_comms +from ..services.operation_log_tasks import log_failure, log_success +from .deps import require_current_user, require_operator, require_permission, require_resource + +router = APIRouter(prefix="/machines", tags=["machines"]) + + +def _model_data(model, *, exclude_none: bool = False, exclude_unset: bool = False) -> dict[str, Any]: + """兼容 Pydantic v1/v2 的模型转 dict。""" + + if hasattr(model, "model_dump"): + return model.model_dump(exclude_none=exclude_none, exclude_unset=exclude_unset) + if hasattr(model, "dict"): + try: + return model.dict(exclude_none=exclude_none, exclude_unset=exclude_unset) + except TypeError: + return model.dict() + if isinstance(model, dict): + return model + return dict(getattr(model, "__dict__", {})) + + +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_machine", response_model=RegisterMachineWithProfileResponse) +def register_machine_api( + message: RegisterMachineByTrustAnchorRequest, + _register: int = Depends(require_permission("machine:register")), + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """TOFU 建档入口:首连 pin、下发 UID、采集硬件并创建机器记录。""" + + detail = {"name": message.machine_name, "ip": message.machine_ip, "trigger": "tofu_register"} + try: + result = machine_service.Register_machine( + message.machine_name, + message.machine_ip, + message.machine_description, + port=message.port, + ) + except Exception as e: + err_reason = getattr(e, "reason", None) or getattr(e, "error_reason", None) + log_failure(operator_user_id=operator_user_id, + operation=OperationType.ADD_MACHINE, + target_type="machine", + target_id=0, + detail=detail, + error_reason=err_reason or str(e), + ) + if err_reason: + return _error(422, str(e), err_reason) + return _error(500, f"Internal error: {e}", "internal_error") + + log_success(operator_user_id=operator_user_id, + operation=OperationType.ADD_MACHINE, + target_type="machine", + target_id=result["machine_id"], + detail={**detail, "uid": result["uid"]}, + ) + 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( + message: RemoveMachineRequest, + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """删除一组机器记录。""" + + result = machine_service.Remove_machine( + machine_id=message.machine_ids, + operator_user_id=operator_user_id, + ) + if result["blocked"]: + # 2026-09 决策:机器上仍有容器 → 拒绝删除,提示先手动清理(不自动级联删) + details = ";".join( + f"机器「{b.get('name') or b['machine_id']}」仍有 {b['container_count']} 个容器" + for b in result["blocked"] + ) + return _error(409, f"以下机器未删除,请先手动清理容器:{details}", "machine_has_containers") + return {"success": 1, "message": "Machine(s) removed successfully"} + + +##################### +# 更新机器 + + +@router.post("/update_machine", response_model=UpdateMachineResponse) +def update_machine_api( + message: UpdateMachineRequest, + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """更新机器管理字段或资源分配限制。""" + + # exclude_unset 而非 exclude_none:要能分辨「客户端显式传了 null」与「没传这个字段」。 + # 前者是「清空该字段」(目前只有 port 需要,清空即回落全局默认),后者是「别动它」。 + # 其余字段传 null 也无害——仓库层对 None 一律跳过,只有 nullable_clear_fields 例外。 + fields = _model_data(message.fields, exclude_unset=True) + try: + 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("/renew_machine_trust", response_model=RenewMachineTrustResponse) +def renew_machine_trust_api( + message: RenewMachineTrustRequest, + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """重钉已登记机器的信任锚:重抓对端证书覆盖 pin,必要时重发 uid。 + + 用于 Node 换过自签证书、本地 pin 失效导致链路连不上的场景。只更新原行, + 不建档——与 register_machine 的 INSERT 语义分开。 + """ + + try: + result = machine_service.Renew_machine_trust( + machine_id=message.machine_id, + operator_user_id=operator_user_id, + ) + except Exception as e: + err_reason = getattr(e, "reason", None) or getattr(e, "error_reason", None) + # 留痕在服务层完成(成功与失败都写),这里只负责把原因映射成状态码 + if err_reason == "machine_not_found": + return _error(404, str(e), err_reason) + if err_reason: + return _error(422, str(e), err_reason) + return _error(500, f"Internal error: {e}", "internal_error") + + return { + "success": 1, + "message": "Machine trust renewed successfully", + **{key: value for key, value in result.items() if key != "success"}, + } + + +##################### +# 设置机器维护开关 + + +@router.post("/set_maintenance", response_model=SetMachineMaintenanceResponse) +def set_machine_maintenance_api( + message: SetMachineMaintenanceRequest, + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """独立切换维护模式;不改写真实在线/离线状态。""" + + try: + success = machine_service.Set_maintenance( + machine_id=message.machine_id, + is_maintenance=message.is_maintenance, + operator_user_id=operator_user_id, + ) + 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 maintenance updated successfully"} + return _error(404, "Machine not found", "machine_not_found") + + +##################### +# 查询机器详情 + + +@router.post("/get_detail_information", response_model=MachineDetailResponse) +def get_detail_information_api( + message: MachineIdRequest, + _: int = Depends(require_permission("machine:view")), + __: int = Depends(require_resource("machine", "machine_id")), +): + """查询机器详情(view 权限 AND 机器资源访问权)。""" + + 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("/machine_status", response_model=MachineStatusResponse) +def machine_status_api( + message: MachineStatusRequest, + _: int = Depends(require_permission("machine:view")), + __: int = Depends(require_resource("machine", "machine_id")), +): + """查询机器状态;只读 Ctrl DB 与 WSS snapshot buffer,不主动请求 Node。""" + + try: + with session_scope(commit=False) as session: + machine = machine_repo.get_by_id(message.machine_id, session=session) + if not machine: + return _error(404, "Machine not found", "machine_not_found") + machine_status = machine.machine_status.value if hasattr(machine.machine_status, "value") else machine.machine_status + return { + "machine_status": machine_status, + "is_maintenance": bool(getattr(machine, "is_maintenance", False)), + "runtime_snapshot": node_comms.get_cached_machine_runtime_snapshot(machine.id), + } + except Exception as e: + return _error(500, f"Internal error: {e}", "internal_error") + + +##################### +# 查询机器概要列表 + + +@router.post("/list_all_machine_bref_information", response_model=ListMachineBriefResponse) +def list_all_machine_bref_information_api( + message: ListMachineBriefRequest, + user_id: int = Depends(require_permission("machine:view")), +): + """分页查询机器概要。""" + + 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, + machine_search=(message.machine_search or "").strip() or None, + ) + 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, + "is_maintenance": bool(getattr(machine, "is_maintenance", False)), + "runtime_snapshot": getattr(machine, "runtime_snapshot", None), + } + ) + return {"machines": machines, "total_pages": total_pages} + + +##################### +# 添加机器权限 + + +@router.post("/add_machine_permission", response_model=AddMachinePermissionResponse) +def add_machine_permission_api( + message: AddMachinePermissionRequest, + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """给用户添加机器权限。""" + + try: + 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.post("/remove_machine_permission", response_model=RemoveMachinePermissionResponse) +def remove_machine_permission_api( + message: RemoveMachinePermissionRequest, + operator_user_id: int = Depends(require_permission("machine:manage")), +): + """收回用户的机器权限(机器权限可分配也可收回)。""" + + ok = machine_service.Remove_machine_permission( + message.machine_id, + message.user_id, + operator_user_id=operator_user_id, + ) + if not ok: + return _error(404, "machine or permission not found", "permission_not_found") + return {"success": 1, "message": "machine permission removed"} + + +##################### +# 查询机器权限 + + +@router.get("/list_machine_permissions", response_model=ListMachinePermissionsResponse) +def list_machine_permissions_api( + machine_id: int = Query(..., ge=1), + _: int = Depends(require_permission("machine:manage")), +): + """查询机器授权用户 id 列表(管理面信息)。""" + + user_ids = machine_service.List_machine_permissions(machine_id) + return {"success": 1, "machine_id": machine_id, "user_ids": user_ids} diff --git a/api/operation_log_api.py b/api/operation_log_api.py new file mode 100644 index 0000000..2ceb7f4 --- /dev/null +++ b/api/operation_log_api.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse + +from ..schemas.operation_log import OperationLogListResponse, OperationLogStatsResponse +from ..services import operation_log_tasks +from .deps import require_operator + +router = APIRouter(prefix="/admin/operation_logs", tags=["operation_logs"]) + + +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}, + ) + + +@router.get("", response_model=OperationLogListResponse) +def list_operation_logs_api( + _: 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, +): + """操作日志查询。""" + + try: + 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 _error(500, f"query failed: {e}", "list_failed") + + return {"success": 1, **result} + + +@router.get("/stats", response_model=OperationLogStatsResponse) +def operation_log_stats_api( + _: 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=start, + end=end, + tz_offset_minutes=tz_offset_minutes, + ) + except Exception as e: + return _error(500, f"stats failed: {e}", "list_failed") + + return {"success": 1, **result} diff --git a/api/rbac_api.py b/api/rbac_api.py new file mode 100644 index 0000000..e7d2d51 --- /dev/null +++ b/api/rbac_api.py @@ -0,0 +1,205 @@ +"""RBAC 权限矩阵 API。""" + +import logging + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse + +from ..constant import OperationType +from ..schemas.rbac import ( + CreateRbacGroupRequest, + CreateRbacGroupResponse, + RbacMatrixResponse, + RbacUserGroupsRequest, + RbacUserGroupsResponse, + UpdateRbacGroupEntitiesRequest, + UpdateRbacGroupEntitiesResponse, +) +from ..services import rbac_service +from ..services.operation_log_tasks import log_failure, log_success +from .deps import require_permission + +router = APIRouter(prefix="/rbac", tags=["rbac"]) + +logger = logging.getLogger("FuxiYu_CtrKernel.api.rbac_api") + + +def _log_rbac_failure(*, operation, target_id, operator_user_id, error_reason, exc=None, detail=None, target_type="rbac_group"): + """失败双写:op-log(审计)+ ctrl 日志(调试,带层级归因与"差在哪"的 why)。 + + 层归因约定:service ValueError = 业务校验层(layer=service); + FastAPI 422 = 入参校验层(layer=validation,带 RequestValidationError detail)。 + """ + why = getattr(exc, "detail", "") if exc is not None else "" + merged_detail = dict(detail or {}) + if why: + merged_detail["why"] = why + log_failure(operator_user_id=operator_user_id, + operation=operation, + target_type=target_type, + target_id=target_id, + detail=merged_detail, + error_reason=error_reason, + ) + logger.warning( + "rbac %s FAILED: operator=%s layer=service reason=%s why=%s detail=%s", + operation, + operator_user_id, + error_reason, + why or "-", + merged_detail, + ) + + +def _error(status_code: int, message: str, error_reason: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"success": 0, "message": message, "error_reason": error_reason}, + ) + + +@router.get("/matrix", response_model=RbacMatrixResponse) +def get_rbac_matrix_api( + _: int = Depends(require_permission("rbac:manage")), +): + """读取 auth_group × auth_entity 权限矩阵。""" + + matrix = rbac_service.list_rbac_matrix() + return {"success": 1, **matrix} + + +@router.post("/groups/{group_id}/entities", response_model=UpdateRbacGroupEntitiesResponse) +def update_rbac_group_entities_api( + group_id: int, + message: UpdateRbacGroupEntitiesRequest, + operator_user_id: int = Depends(require_permission("rbac:manage")), +): + """替换某个权限组持有的 auth_entity 集合。""" + + try: + group = rbac_service.update_group_entities(group_id, message.entity_codes) + except ValueError as e: + reason = str(e) + _log_rbac_failure( + operation=OperationType.UPDATE_RBAC_GROUP_ENTITIES, + target_id=group_id, + operator_user_id=operator_user_id, + error_reason=reason, + exc=e, + detail={"group_id": group_id, "requested_entities": message.entity_codes}, + ) + if reason == "group_not_found": + return _error(404, "rbac group not found", reason) + if reason.startswith("unknown_auth_entities:"): + return _error(400, "unknown auth entity", "unknown_auth_entity") + return _error(400, "invalid rbac update", "invalid_rbac_update") + log_success(operator_user_id=operator_user_id, + operation=OperationType.UPDATE_RBAC_GROUP_ENTITIES, + target_type="rbac_group", + target_id=group_id, + detail={ + "name": group["name"], + "entities": group["entity_codes"], + "locked_entities": group.get("locked_entity_codes") or [], + }, + ) + return {"success": 1, "message": "rbac group updated", "group": group} + + +@router.post("/groups", response_model=CreateRbacGroupResponse, status_code=201) +def create_rbac_group_api( + message: CreateRbacGroupRequest, + operator_user_id: int = Depends(require_permission("rbac:manage")), +): + """创建新的权限组,并写入初始 auth_entity 集合。""" + + try: + group = rbac_service.create_group( + name=message.name, + description=message.description, + entity_codes=message.entity_codes, + ) + except ValueError as e: + reason = str(e) + _log_rbac_failure( + operation=OperationType.CREATE_RBAC_GROUP, + target_id=0, + operator_user_id=operator_user_id, + error_reason=reason, + exc=e, + detail={"name": message.name, "requested_entities": message.entity_codes}, + ) + if reason == "group_exists": + return _error(409, "rbac group already exists", reason) + if reason == "invalid_group_name": + return _error(400, "invalid rbac group name", reason) + if reason.startswith("unknown_auth_entities:"): + return _error(400, "unknown auth entity", "unknown_auth_entity") + return _error(400, "invalid rbac group", "invalid_rbac_group") + log_success(operator_user_id=operator_user_id, + operation=OperationType.CREATE_RBAC_GROUP, + target_type="rbac_group", + target_id=group["id"], + detail={ + "name": group["name"], + "description": group.get("description") or "", + "entities": group["entity_codes"], + }, + ) + return {"success": 1, "message": "rbac group created", "group": group} + + +##################### +# 用户 ↔ 权限组 + + +@router.get("/users/{user_id}/groups", response_model=RbacUserGroupsResponse) +def get_user_groups_api( + user_id: int, + operator_user_id: int = Depends(require_permission("rbac:manage")), +): + """查询用户当前绑定的权限组 id 列表(生效权限 = 各组 entities 并集)。""" + + return {"success": 1, "user_id": user_id, "group_ids": rbac_service.get_user_group_ids(user_id)} + + +@router.post("/users/{user_id}/groups", response_model=RbacUserGroupsResponse) +def set_user_groups_api( + user_id: int, + message: RbacUserGroupsRequest, + operator_user_id: int = Depends(require_permission("rbac:manage")), +): + """整组替换用户绑定的权限组(管理动作,敏感审计)。 + + 护栏:operator 不能把自己移出所有持有 rbac:manage 的组(自锁死)。 + """ + + group_ids = [int(g) for g in (message.group_ids or [])] + try: + final_ids = rbac_service.set_user_groups(user_id, group_ids, operator_user_id=operator_user_id) + except ValueError as e: + reason = str(e) + _log_rbac_failure( + operation=OperationType.UPDATE_USER_GROUPS, + target_type="user", + target_id=user_id, + operator_user_id=operator_user_id, + error_reason=reason, + exc=e, + detail={"user_id": user_id, "group_ids": group_ids}, + ) + if reason == "user_not_found": + return _error(404, "user not found", reason) + if reason == "cannot_remove_own_manage": + return _error(400, "cannot remove yourself from rbac:manage groups", reason) + if reason.startswith("unknown_group:"): + return _error(400, "unknown rbac group", reason) + return _error(400, "invalid rbac user groups", reason) + + log_success(operator_user_id=operator_user_id, + operation=OperationType.UPDATE_USER_GROUPS, + target_type="user", + target_id=user_id, + detail={"user_id": user_id, "group_ids": final_ids}, + ) + return {"success": 1, "user_id": user_id, "group_ids": final_ids} diff --git a/api/settings_api.py b/api/settings_api.py new file mode 100644 index 0000000..c47b27a --- /dev/null +++ b/api/settings_api.py @@ -0,0 +1,99 @@ +"""系统设置 API。""" + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse + +from ..schemas.settings import ( + ImagePlatformInjectionSettingResponse, + SystemSettingsResponse, + UpdateImagePlatformInjectionSettingRequest, + UpdateImagePlatformInjectionSettingResponse, + UpdateSystemSettingsRequest, + UpdateSystemSettingsResponse, +) +from ..services import settings_tasks +from .deps import require_permission + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +def _error(status_code: int, message: str, error_reason: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"success": 0, "message": message, "error_reason": error_reason}, + ) + + +##################### +# 系统设置矩阵 + + +@router.get("", response_model=SystemSettingsResponse) +def list_settings_api( + _: int = Depends(require_permission("settings:manage")), +): + """读取可运行时调整的系统设置矩阵。""" + + return {"success": 1, "settings": settings_tasks.list_settings()} + + +@router.post("", response_model=UpdateSystemSettingsResponse) +def update_settings_api( + message: UpdateSystemSettingsRequest, + operator_user_id: int = Depends(require_permission("settings:manage")), +): + """批量更新系统设置。""" + + try: + settings = settings_tasks.update_settings( + message.values, + operator_user_id=operator_user_id, + ) + except ValueError as e: + return _error(422, str(e), "invalid_setting") + except Exception as e: + return _error(500, f"failed to update settings: {e}", "update_failed") + return {"success": 1, "message": "settings updated", "settings": settings} + + +##################### +# 镜像注入模板设置 + + +@router.get( + "/image_platform_injection", + response_model=ImagePlatformInjectionSettingResponse, +) +def get_image_platform_injection_api( + _: int = Depends(require_permission("settings:manage")), +): + """读取镜像平台注入片段。""" + + return { + "success": 1, + "content": settings_tasks.get_image_platform_injection_content(), + } + + +@router.post( + "/image_platform_injection", + response_model=UpdateImagePlatformInjectionSettingResponse, +) +def update_image_platform_injection_api( + message: UpdateImagePlatformInjectionSettingRequest, + operator_user_id: int = Depends(require_permission("settings:manage")), +): + """更新镜像平台注入片段。""" + + try: + settings_tasks.set_setting_value( + settings_tasks.IMAGE_PLATFORM_INJECTION_KEY, + message.content, + description="镜像构建时由 Ctrl 拼入最终 Dockerfile 的平台注入片段。", + operator_user_id=operator_user_id, + ) + except ValueError as e: + return _error(422, str(e), "invalid_setting") + except Exception as e: + return _error(500, f"failed to update image platform injection: {e}", "update_failed") + return {"success": 1, "message": "Image platform injection updated successfully"} diff --git a/api/user_api.py b/api/user_api.py new file mode 100644 index 0000000..816b468 --- /dev/null +++ b/api/user_api.py @@ -0,0 +1,333 @@ +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import JSONResponse + +from ..config import AppConfig +from ..extensions import session_scope +from ..repositories import authentications_repo, 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 .deps import auth_token_from_cookie, require_current_user, require_permission, require_resource + +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) + if hasattr(model, "dict"): + try: + return model.dict(exclude_none=exclude_none) + except TypeError: + return model.dict() + if isinstance(model, dict): + return model + return dict(getattr(model, "__dict__", {})) + + +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: + 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", + } + 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): + """发送注册验证码。""" + + 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。""" + + success, user_or_reason, token = user_tasks.Login( + message.username, + message.password, + remember=message.remember, + ) + ssl_enabled = getattr(AppConfig, "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, + } + + 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.post("/logout", response_model=SuccessMessageResponse) +def logout(response: Response, auth_token: str = Depends(auth_token_from_cookie)): + """用户登出:删除认证记录并清理浏览器 cookie。""" + + if auth_token: + with session_scope() as session: + authentications_repo.delete_auth(auth_token, session=session) + + ssl_enabled = getattr(AppConfig, "SSL_ENABLED", True) + response.delete_cookie( + "auth_token", + path="/", + secure=ssl_enabled, + samesite="Lax", + ) + return {"success": 1, "message": "logout successful"} + + +##################### +# 用户详情 + + +@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_resource("user", "user_id")), +): + """查询用户详情。""" + + 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/me/permissions") +def my_permissions_api( + request: Request, + user_id: int = Depends(require_current_user), +): + """当前用户持有的全部权限点(前端导航/按钮按 manage 过滤)。""" + + from ..services.rbac_service import list_user_entities + return {"success": 1, "entities": list_user_entities(user_id)} + + +@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), + user_search: str | None = Query(default=None), + viewer_user_id: int = Depends(require_current_user), +): + """分页查询用户概要(内部按查看者资源级过滤)。""" + + try: + users = user_tasks.List_all_user_bref_information( + page_number=int(page_number), + page_size=int(page_size), + user_search=(user_search or "").strip() or None, + viewer_user_id=viewer_user_id, + ) + 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, + current_user_id: int = Depends(require_current_user), +): + """修改用户密码(仅本人:user_id 必须等于会话用户)。""" + + if message.user_id != current_user_id: + return _error(403, "can only change your own password", "insufficient_permission") + + with session_scope(commit=False) as session: + user = user_repo.get_by_id(message.user_id, session=session) + 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, + operator_user_id: int = Depends(require_permission("user:manage")), +): + """删除用户。""" + + try: + ok = user_tasks.Delete_user( + message.user_id, + operator_user_id=operator_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_resource("user", "user_id")), +): + """更新用户基础字段(user 资源判定 或 operator,deps 并集门禁)。""" + + fields = _model_data(message.fields, exclude_none=True) + if not fields: + return _error(400, "user_id and fields required", "missing_fields") + + try: + 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, + operator_user_id: int = Depends(require_permission("user:manage")), +): + """重置用户密码(管理操作;本人改密走 change_password)。""" + + new_password = user_tasks.Reset_password( + message.user_id, + operator_user_id=operator_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/wsgi.py b/asgi.py similarity index 100% rename from wsgi.py rename to asgi.py index d2a953e..c0ab74f 100644 --- a/wsgi.py +++ b/asgi.py @@ -1,4 +1,4 @@ from . import create_app -app = create_app("production") +app = create_app("production") 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/announcement_api.py b/blueprints/announcement_api.py deleted file mode 100644 index 46ed731..0000000 --- a/blueprints/announcement_api.py +++ /dev/null @@ -1,492 +0,0 @@ -"""公告系统 API 路由。 - -全部端点要求 Operator 权限。认证模式沿用项目现有风格: -- token 来自请求头 -- authentications_repo.is_token_valid + user_repo.check_permission -""" - -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 - - -def _current_user_id() -> int: - """返回当前 token 对应的 user_id。调用前必须已通过 _require_operator 校验。""" - return authentications_repo.get_user_id_by_token(_get_token()) - - -# ── 模板 CRUD ───────────────────────────────────────────────────────── - - -@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 - - -@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") - - if not name: - return jsonify({"success": 0, "message": "name is required", "error_reason": "missing_field"}), 400 - 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 - - 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"), - ) - 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 - - -@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 - - -@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) - 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 [] - if not raw_targets: - return jsonify({"success": 0, "message": "targets must not be empty", "error_reason": "empty_targets"}), 400 - - targets = [announcement_tasks.TargetEntry(**t) for t in raw_targets] - try: - 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) - 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 - - -@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) - 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) - if not ok: - return jsonify({"success": 0, "message": "announcement not found", "error_reason": "not_found"}), 404 - return jsonify({"success": 1, "message": "announcement deleted"}), 200 - - -@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 [] - 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 - - -@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") - - 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) - 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 - - -@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 - - -# ── 批量发送(唯一的发送入口)────────────────────────────────────────── - - -@api_bp.post("/announcements/drafts/batch-send") -def batch_send_drafts_api(): - err = _require_operator() - if err: - return err - - 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] - - try: - result = announcement_tasks.batch_send_drafts_service(draft_ids, targets) - except ValueError as e: - reason = str(e) - status_map = { - "empty_targets": 400, - "too_many_recipients": 400, - "batch_too_large": 400, - } - return jsonify({"success": 0, "message": reason, "error_reason": reason}), status_map.get(reason, 400) - - return jsonify({"success": 1, **result.model_dump()}), 200 diff --git a/blueprints/container_api.py b/blueprints/container_api.py deleted file mode 100644 index ab8b554..0000000 --- a/blueprints/container_api.py +++ /dev/null @@ -1,736 +0,0 @@ -from sqlalchemy.exc import IntegrityError -from flask import jsonify, request -from flask import current_app -from . import api_bp -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.parsers import parse_bool -from ..repositories import containers_repo, authentications_repo, user_repo -from ..schemas.user_schema import user_schema, users_schema - -# 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, -} - - -def _log_failure(*, operation, target_type, target_id, operator_user_id, error_reason, detail=None): - """蓝图层失败补记:task 层直接上抛/返回 False 的失败在这里统一记一条。""" - 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 {} - owner_name = data.get("user_name", "") - machine_id = data.get("machine_id", 0) - operator_user_id = authentications_repo.get_user_id_by_token(token) - - # 似乎是一些结构问题 - 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", []), - "CPU_NUMBER": data.get("CPU_NUMBER", 0), - "MEMORY": data.get("MEMORY", 0), - "NAME": data.get("NAME", ""), - "image": data.get("image", ""), - } - - public_key = data.get("public_key", None) - if public_key == '': # treat empty string as None - public_key = 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) - 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 - 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 - - 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, - ) - 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 - 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 - - -@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 - - try: - 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": "