chore(judge): document explicit managed runtime rollout

This commit is contained in:
2026-10-09 23:29:23 +08:00
parent d20721313e
commit 67c3bc55ba
8 changed files with 437 additions and 3 deletions
+6 -2
View File
@@ -71,8 +71,12 @@ make sandbox-probe # 只读观察 Linux 环境,不创建隔离资源
开发中的规划文档仅在本地维护,不包含在远端仓库中。
单机提交服务的配置、最小权限、停止恢复与独立 VM 验收要求见
[判题说明](judge/README.md#单机持久化协调与常驻-worker)和[部署说明](deploy/README.md#单机提交服务的启动与停止)。
当前范围为单机普通题、已有账号、固定 GNU C17/C++20 与精确字节比较;自助注册、
自测、SPJ/交互题、Python/Rust 用户程序、分布式调度和学习能力仍待后续实现。
当前普通题执行支持固定 GNU C17/C++20、CPython 3.13.16 和 OpenJDK 21.0.12.1+1-LTS,
使用精确字节比较。Python 固定单文件 main.py 与标准库;Java 固定默认包 Main.java/Main,
允许有界辅助/内部/匿名类。题目必须显式声明对应语言能力,旧题目不会自动获得新语言。
新版源码与普通 Worker 的沙箱执行已在独立 VM 验收;完整平台/浏览器及故障验收按工程阶段
分别记录,生产部署需另行进行。自助注册、自测、SPJ/交互题、Rust 用户程序、分布式调度
和学习能力仍待后续实现。
## 许可证
+13 -1
View File
@@ -10,7 +10,7 @@
要求配置 `KF_OJ_ARTIFACT_ROOT` 和可信 `KF_OJ_JUDGE_ADMISSION` JSON;同一准入配置应交付给节点,
不能由题目放宽。结构示例来自共享 `judge-admission.valid.json`,不是当前宿主隔离验收证明。
清单固定 C17/C++20、工具链/镜像版本、精确字节比较、编译/运行/整个作业预算,并声明按序
v1 清单固定 C17/C++20、工具链/镜像版本、精确字节比较、编译/运行/整个作业预算,并声明按序
测试点 `{key,input_file,expected_file}` 和最多十对公开样例 `{input_file,output_file}`。
题面以 `statement_file` 读取 UTF-8。每份样例至多 64 KiB,响应采用 base64 保留二进制数据。
同一 slug 受事务锁保护,每次发布递增 revision;发布后题面、限额、样例、测试点不可修改。
@@ -19,3 +19,15 @@
`GET /api/v1/problems` 以 slug 游标分页(`after` / `limit`,最多 100 项),
`GET /api/v1/problems/versions/{uuid}` 读取已发布版本。禁用逻辑题目时其版本对公开读取不可用。
内部判题投影仅供授权应用编排使用;外部 Worker 读取由 submissions 的有效租约端口控制。
v2 使用 `schema_version: 2` 与 `language_configurations`,每个配置包含 `language`、
完整 `toolchain` 身份、`compile_limits`、`run_limits` 和 `job_wall_time_us`。
可分别声明 `c17`、`cpp20`、`python313`、`java21`;不再同时提供 v1 顶层工具链或预算。
所有语言按可信节点配置准入,缺失能力/错误补丁版本/镜像摘要/过宽预算在发布前拒绝。
`judge.admission.example.json` 的镜像摘要为占位值,使用前替换为管理员制作镜像的实际摘要。
公开题面仅显示固定语言、版本、入口与预算,不公布宿主镜像路径和隐藏工件。
导入同一 slug 的 v2 版本保持原 v1 版本与提交不变。已有提交按其保存的协议版本解析,
不能重新序列化旧 canonical 数据来补入新字段。Java 使用 Main.java 与默认包 Main;
Python 使用 main.py。每个新版本的语言集合独立,浏览器不得为旧版本补开新语言。
@@ -235,3 +235,35 @@ def test_generic_admission_revalidates_constructed_values_in_both_generations():
with pytest.raises(ValueError):
v2.admit_manifest(registry, wrong, 1)
v2.admit_manifest(registry, legacy, 1)
def test_operator_registry_example_preserves_old_protocol_and_binds_managed_runtime():
policy = v2.AdmissionRegistry.model_validate_json(
(ROOT / "deploy/judge.admission.example.json").read_bytes()
)
old = v1.decode_wire(
v1.JobManifest, (ROOT / "tests/fixtures/contracts/judge-manifest.valid.json").read_bytes()
)
policy.admit(old, 1)
assert policy.multi_runtime is not None
root = json.loads((ROOT / "deploy/sandbox.managed.runtime.example.json").read_text())
for cap in policy.multi_runtime.capabilities:
selected = next(
item
for item in root["runtime_images"]
if item["runtime_identity"] == cap.toolchain.model_dump(mode="json")
)
ceiling = v1.RunLimits.model_validate(selected["per_task_ceiling"])
assert cap.compile_ceiling.fits(ceiling) and cap.run_ceiling.fits(ceiling)
manifest = fixture(f"judge-manifest.valid-{cap.language}", v2.JobManifest).model_copy(
update={"compile_limits": cap.compile_ceiling, "run_limits": cap.run_ceiling}
)
policy.admit(manifest, 1)
changed = manifest.model_copy(
update={"toolchain": manifest.toolchain.model_copy(update={"image_digest": "f" * 64})}
)
with pytest.raises(ValueError):
policy.admit(changed, 1)
legacy_only = v2.AdmissionRegistry(schema_version=2, legacy=policy.legacy, multi_runtime=None)
with pytest.raises(ValueError):
legacy_only.admit(fixture("judge-manifest.valid-java21", v2.JobManifest), 1)
+34
View File
@@ -106,3 +106,37 @@ attempt 或强改状态。旧租约不能覆盖新结果,有限基础设施重
挂载、无业务凭据且整体上限已读回的独立测试 VM 内验收。开发 Compose 仍只提供可信
数据库和 broker,不在宿主或业务服务器运行用户代码。workflow 如需启用由指定
GitHub 私有仓库承载,并设置有限并发;Gitea 只保存逐阶段工程提交。
## 四语言节点的受控升级
新增公开示例 `judge.admission.example.json` 为显式 v1/v2 注册表,
`sandbox.managed.runtime.example.json` 为一槽位的共享 Root 配置;原 v1 示例保持原语义。
所有 `aaaaaaaa…` 镜像摘要均是无权限占位值,不能当作已验镜像。管理员制作固定镜像后,
对 manifest.json 的实际字节计算 SHA-256,逐文件验证版本/字节/权限,并将相同完整身份
写入准入注册表与 Root runtime_images。Root 镜像路径仅在私有可信配置中保存。
配置解析通过或 check-runtime 可见仍不允许跳过实际隔离门控。
1. 停止创建新提交/消费投递,向所有普通 Worker 发 SIGTERM;保留 DB、清单、工件和 journal。
2. 取得工厂实际 stopped/cleanup_complete 证明,读回空任务 cgroup 和 owned 资源;
未排空或存在未知资源时停止升级,保留证据,不递归清空任务目录。
3. 使用独立迁移账号执行 Backend Alembic upgrade;新语言数据存在时旧迁移降级会拒绝,
不删除数据以实现回退。平台与 Coordinator 继续使用各自最小权限角色,Worker 无 DB 账号。
4. 安装固定版本的 Backend/Coordinator/Worker/Sandbox;同时更新三个普通服务的同一
`judge_admission`/`admission` 注册表,Root 配置单独由管理员持有。旧消费者不混用新协议。
5. 为整体节点、控制面各服务、工厂和任务设置有限 CPU/memory/swap/pids/文件/期限,
读回内核值。节点至少预留一份512MiB编译任务以及独立控制面;Java运行示例总预算384MiB,
Xmx不能代替整体预算。并发1/prefetch1,共享UID池,不因第二Worker存在而扩大节点槽位。
6. 在独立无NIC、无宿主共享挂载、无业务凭据 VM 用合成题目验证四语言、故障与实际排空,
再恢复工厂/Coordinator/API/publisher/Worker。生产启用需单独安排,仓库不自动部署。
请求不在线安装 Python/Java 依赖,不运行 pip/uvx,不接收自定义解释器参数/JAR/agents。
Python main.py 仅标准库;Java Main.java 使用默认包 Main,最多256个有界编译class。
题目语言与预算以公开版本配置为准,旧题目和草稿保留,Rust继续明确不支持。
新版检查阶段CE/运行RE/真实限额与SystemError的区别见 [判题说明](../judge/README.md#python--java-与明确协议版本)。
停止/故障恢复沿原单机流程:停止Worker使任务全树排空,旧租约/report拒绝后恢复;
publisher/broker中断保留outbox,有限重试而非清空记录。恢复旧软件前应先排空v2任务,
保留不可变v2版本和报告供兼容消费者读取;不能把旧配置静默转换为宽松新能力。
这些公开配置不含业务令牌。数据库/AMQP/服务令牌分别在仓库外私下生成,Root不持有它们。
需要CI时只在GitHub私有仓库 `KiriAky107/KF-OnlineJudge` 运行并设置有限并发,Gitea保存源码。
+185
View File
@@ -0,0 +1,185 @@
{
"schema_version": 2,
"legacy": {
"max_concurrent_jobs": 1,
"prefetch": 1,
"max_retries": 2,
"max_job_wall_time_us": 60000000,
"max_package_bytes": 67108864,
"compile_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
},
"run_ceiling": {
"cpu_time_us": 1000000,
"wall_time_us": 3000000,
"memory_bytes": 67108864,
"stack_bytes": 8388608,
"processes": 8,
"open_files": 64,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 16777216,
"workdir_bytes": 67108864
},
"toolchain": {
"toolchain_id": "gnu-cxx-v1",
"image_kind": "linux_gnu_cxx_x86_64_v1",
"image_revision": 1
}
},
"multi_runtime": {
"max_concurrent_jobs": 1,
"prefetch": 1,
"max_retries": 2,
"max_job_wall_time_us": 60000000,
"max_package_bytes": 67108864,
"schema_version": 2,
"capabilities": [
{
"language": "c17",
"toolchain": {
"toolchain_id": "gnu-cxx-v1",
"image_kind": "linux_gnu_cxx_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "gnu-cxx-v1"
},
"compile_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
},
"run_ceiling": {
"cpu_time_us": 1000000,
"wall_time_us": 3000000,
"memory_bytes": 67108864,
"stack_bytes": 8388608,
"processes": 8,
"open_files": 64,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 16777216,
"workdir_bytes": 67108864
}
},
{
"language": "cpp20",
"toolchain": {
"toolchain_id": "gnu-cxx-v1",
"image_kind": "linux_gnu_cxx_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "gnu-cxx-v1"
},
"compile_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
},
"run_ceiling": {
"cpu_time_us": 1000000,
"wall_time_us": 3000000,
"memory_bytes": 67108864,
"stack_bytes": 8388608,
"processes": 8,
"open_files": 64,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 16777216,
"workdir_bytes": 67108864
}
},
{
"language": "python313",
"toolchain": {
"toolchain_id": "cpython313-v1",
"image_kind": "linux_cpython313_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "3.13.16"
},
"compile_ceiling": {
"cpu_time_us": 1000000,
"wall_time_us": 3000000,
"memory_bytes": 67108864,
"stack_bytes": 8388608,
"processes": 8,
"open_files": 64,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 16777216,
"workdir_bytes": 67108864
},
"run_ceiling": {
"cpu_time_us": 1000000,
"wall_time_us": 3000000,
"memory_bytes": 67108864,
"stack_bytes": 8388608,
"processes": 8,
"open_files": 64,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 16777216,
"workdir_bytes": 67108864
}
},
{
"language": "java21",
"toolchain": {
"toolchain_id": "openjdk21-v1",
"image_kind": "linux_openjdk21_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "21.0.12.1+1-LTS"
},
"compile_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
},
"run_ceiling": {
"cpu_time_us": 1000000,
"wall_time_us": 3000000,
"memory_bytes": 402653184,
"stack_bytes": 8388608,
"processes": 32,
"open_files": 64,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 16777216,
"workdir_bytes": 67108864
}
}
]
}
}
+111
View File
@@ -0,0 +1,111 @@
{
"schema_version": 1,
"state_root": "/var/lib/kf-judge",
"tasks_root": "/var/lib/kf-judge/tasks",
"image_root": "/var/lib/kf-judge/images/gnu-cxx-x86_64-v1",
"image_kind": "linux_gnu_cxx_x86_64_v1",
"delegated_cgroup_root": "/sys/fs/cgroup/kf-judge.service",
"identities": {
"worker_uid": 1000,
"worker_gid": 1000,
"first_task_uid": 100000,
"first_task_gid": 100000,
"count": 1
},
"max_concurrent_runs": 1,
"per_task_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
},
"aggregate": {
"memory_bytes": 805306368,
"supervisor_memory_bytes": 268435456,
"processes": 64,
"supervisor_processes": 32,
"workdir_bytes": 402653184,
"supervisor_workdir_bytes": 67108864,
"cpu_period_us": 100000,
"cpu_quota_us": 150000,
"task_cpu_quota_us": 100000,
"supervisor_cpu_quota_us": 50000
},
"runtime_images": [
{
"image_root": "/var/lib/kf-judge/images/gnu-cxx-v2",
"image_kind": "linux_gnu_cxx_x86_64_v1",
"runtime_identity": {
"toolchain_id": "gnu-cxx-v1",
"image_kind": "linux_gnu_cxx_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "gnu-cxx-v1"
},
"per_task_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
}
},
{
"image_root": "/var/lib/kf-judge/images/cpython313-v1",
"image_kind": "linux_cpython313_x86_64_v1",
"runtime_identity": {
"toolchain_id": "cpython313-v1",
"image_kind": "linux_cpython313_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "3.13.16"
},
"per_task_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
}
},
{
"image_root": "/var/lib/kf-judge/images/openjdk21-v1",
"image_kind": "linux_openjdk21_x86_64_v1",
"runtime_identity": {
"toolchain_id": "openjdk21-v1",
"image_kind": "linux_openjdk21_x86_64_v1",
"image_revision": 1,
"image_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runtime_version": "21.0.12.1+1-LTS"
},
"per_task_ceiling": {
"cpu_time_us": 10000000,
"wall_time_us": 30000000,
"memory_bytes": 536870912,
"stack_bytes": 16777216,
"processes": 32,
"open_files": 128,
"stdout_bytes": 1048576,
"stderr_bytes": 1048576,
"file_bytes": 67108864,
"workdir_bytes": 268435456
}
}
]
}
+30
View File
@@ -152,3 +152,33 @@ Worker SIGTERM 取消当前执行后退出,SIGKILL 由 Root EOF 清理、租
Worker 的 `lapin 2.5.5` / `futures-lite 2.6.1` 负责 AMQP 通知,`reqwest 0.12.24`
负责有界 loopback 请求并禁用代理与重定向;`uuid 1.27.0` 生成身份,`sha2 0.10.9`
验证清单/工件/报告摘要。Engine 不引入数据库、HTTP、队列或权限依赖。
## Python / Java 与明确协议版本
新语言使用 v2 清单、typed 程序工件、阶段和报告。旧 v1 清单/报告/canonical 摘要保持原样,
不默加新字段。需要同一节点处理两代任务时,管理员显式配置 `AdmissionRegistry` 的
`legacy` 与 `multi_runtime`;仅配置旧能力的消费者继续拒绝 v2。两代配置必须共享
并发/prefetch/重试预算,所有镜像共用一个 Root 工厂、UID 池和容量预留。
CPython 固定 3.13.16,仅 main.py/标准库。准备 profile 只分析/编译源码,不执行顶层代码,
不导入用户 pyc、marshal、pickle 或 ZIP;运行使用已检查的封印源码。
OpenJDK 固定 Temurin 21.0.12.1+1-LTS,固定 Main.java/default-package Main;javac
使用固定 UTF-8 与禁用 annotation processing 的参数,允许最多256个有界平铺 class,
包括辅助/内部/匿名类,class 文件名仅允许受控 ASCII 字符且最长128字节。链接、目录替换、空类集、重复/超量/过长或不支持名称拒绝;
完整类集封印后逐点使用。同一题的每点都重新建立 helper、UID 与工作根。
每次检查、编译、运行均通过 Root 隔离门控,不在普通 Worker 执行解释器或 javac。
请求不能指定运行镜像宿主路径、任意 flags、classpath、JAR、agent 或环境变量;
PYTHONPATH、JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS 等不从宿主继承。
语法/编译/入口错误为 CE,普通运行异常为 RE;真实可信 CPU/墙钟、cgroup OOM 和
输出超限分别为 TLE/MLE/OLE。用户自抛 MemoryError/OOM、打印文字或 exit code
不能证明 MLE。JVM Xmx 仅限制堆,元空间、代码缓存、native、每个线程栈均包含在
完整 cgroup 预算;低于启动/非堆预留的配置提前拒绝。镜像/门控/通信/清理故障为
SystemError,未知计量不补零。
镜像制作使用 `scripts/build_judging_images.py`,固定来源/摘要与闭合动态库列表,
不复制平台 venv、site-packages、服务配置或凭据。产物经 root 所有权、不可写目录、
逐文件摘要及 catalog 摘要核对后发布;路径只由可信 Root 配置决定。
生产标准预算不使用 VM 模拟器验收时明确设置的较慢启动预算。
新版节点配置与排空步骤见 [部署说明](../deploy/README.md#四语言节点的受控升级)。
@@ -336,3 +336,29 @@ fn task_and_image_roots_cannot_overlap_private_capture_storage() {
}
}
}
#[test]
fn managed_operator_example_shares_one_pool_and_rejects_unreserved_capacity() {
let config: RuntimeConfig = serde_json::from_str(include_str!(
"../../../../deploy/sandbox.managed.runtime.example.json"
))
.unwrap();
let validated = config.clone().validate().unwrap();
assert_eq!(validated.task_identity(0), Some((100000, 100000)));
assert_eq!(validated.task_identity(1), None);
assert_eq!(config.runtime_images.len(), 3);
// Adding languages never creates independent identity or resource pools.
let mut unreserved = config.clone();
unreserved.max_concurrent_runs = 2;
unreserved.identities.count = 2;
assert!(unreserved.validate().is_err());
let mut unknown = config.clone();
unknown.runtime_images[1].runtime_identity.runtime_version =
"3.14.7".to_string().try_into().unwrap();
assert!(unknown.validate().is_err());
let mut duplicate = config.clone();
duplicate
.runtime_images
.push(config.runtime_images[0].clone());
assert!(duplicate.validate().is_err());
}