forked from luopingyi/xc_agent_platform_demo
Phase1 skeleton: replace demo main.py with src.server contract
- Overlay ModelHub XC Phase1 (health/status, STRATEGY_ID gate, dry-run) - Dockerfile CMD -> python -m src.server; FROM stays platform TCR base - Remove demo main.py and requirements.txt (stdlib only) - Add tests/, scripts/, docs/, Makefile, config/examples (no secrets)
This commit is contained in:
@@ -1,4 +1,21 @@
|
|||||||
.git
|
.git
|
||||||
__pycache__/
|
.gitignore
|
||||||
*.pyc
|
.venv
|
||||||
.pytest_cache/
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
reports/*
|
||||||
|
!reports/.gitkeep
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
scripts
|
||||||
|
config
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Secrets / local env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
config/local.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Dry-run / local artifacts (keep directory)
|
||||||
|
reports/*
|
||||||
|
!reports/.gitkeep
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.tar
|
||||||
18
Dockerfile
18
Dockerfile
@@ -1,14 +1,18 @@
|
|||||||
FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim
|
# ModelHub XC adaptation agent — Phase1 skeleton
|
||||||
|
# Default base MUST stay the platform image (do not swap to public mirrors).
|
||||||
ENV PYTHONUNBUFFERED=1
|
ARG BASE_IMAGE=modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
# Copy only what the runtime needs (see .dockerignore)
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY src/ ./src/
|
||||||
|
|
||||||
COPY . .
|
ENV PORT=8080
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
# STRATEGY_ID and tokens are injected by the platform at run time — never bake them in.
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
# Start HTTP service only (no register / submit side effects)
|
||||||
|
CMD ["python", "-m", "src.server"]
|
||||||
|
|||||||
32
Makefile
Normal file
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# ModelHub XC agent contrib — Phase1
|
||||||
|
# Allowed: test / build / smoke-health / dry-run
|
||||||
|
# FORBIDDEN: register / submit / deploy / push targets
|
||||||
|
|
||||||
|
.PHONY: test build smoke-health dry-run help
|
||||||
|
|
||||||
|
PYTHON ?= python3
|
||||||
|
IMAGE ?= modelhub-agent-contrib:local
|
||||||
|
PORT ?= 8080
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "test stdlib unit tests + local SIGTERM integration (needs sockets)"
|
||||||
|
@echo "build Docker build using platform base (needs Docker + TCR access)"
|
||||||
|
@echo "smoke-health local HTTP smoke (needs sockets; default test STRATEGY_ID)"
|
||||||
|
@echo "dry-run local example report; pending-submit integration outstanding"
|
||||||
|
@echo "No register/submit/deploy/push — Phase1 hard boundary."
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(PYTHON) -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
build:
|
||||||
|
@command -v docker >/dev/null 2>&1 || { \
|
||||||
|
echo "ERROR: docker not found. See docs/build-notes.md"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
docker build -t $(IMAGE) .
|
||||||
|
|
||||||
|
smoke-health:
|
||||||
|
$(PYTHON) scripts/smoke_health.py
|
||||||
|
|
||||||
|
dry-run:
|
||||||
|
$(PYTHON) scripts/dry_run.py
|
||||||
85
README.md
85
README.md
@@ -1,39 +1,74 @@
|
|||||||
# Strategy Platform Demo Repo
|
# modelhub-agent-contrib
|
||||||
|
|
||||||
Minimal strategy repository for testing the platform Kaniko build and deployment flow.
|
ModelHub XC(信创模盒)**适配智能体** Phase1 本地骨架。
|
||||||
|
|
||||||
It satisfies the runtime contract from the parent README:
|
> **不代注册、不代提交。** 发布智能体本身 **不得分**。积分来自成功适配,数字一律 **待平台核实**。
|
||||||
|
> 冲突裁决:**适配 W1 优先**;本仓默认 dry-run,不抢验证队列。
|
||||||
|
|
||||||
- Root-level `Dockerfile`
|
## 契约要点
|
||||||
- Listens on port `8080`
|
|
||||||
- Exposes `GET /health`
|
|
||||||
- Reads `EXTERNAL_SERVICE_TOKEN`
|
|
||||||
- Handles `SIGTERM` for graceful shutdown
|
|
||||||
|
|
||||||
## Local Run
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| 端口 | 8080 |
|
||||||
|
| 健康检查 | `GET /health` → `{"status":"ok"}` |
|
||||||
|
| 启动 | **必须**有非空 `STRATEGY_ID`,否则 exit ≠0 |
|
||||||
|
| Live | `ALLOW_LIVE_SUBMIT=1` **且** `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT`;Phase1 仍无真实 `task/add` 客户端 |
|
||||||
|
| Base 镜像 | 平台 `xc_agent_platform/python:3.11-slim`(勿擅自改默认 FROM) |
|
||||||
|
|
||||||
|
闸门以 `src/gate.py` 实现为准:仅 `ALLOW_LIVE_SUBMIT=1` 与
|
||||||
|
`LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` 同时满足才开逻辑闸门。PRD 示例
|
||||||
|
`ALLOW_LIVE_SUBMIT=true` / `CONFIRM_LIVE=YES` 不受支持;本仓不读取确认文件。
|
||||||
|
即使开闸,无真实客户端仍返回 blocked;请保持默认关闭。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
cd /workspace/modelhub-agent-contrib
|
||||||
curl http://localhost:8080/health
|
make test
|
||||||
curl http://localhost:8080/
|
make smoke-health # 自动起本地 server(需 STRATEGY_ID,脚本可注入测试值)
|
||||||
|
make dry-run # 写出 reports/dry-run-*.md
|
||||||
|
make build # 需 Docker + 可达平台 base;失败见 docs/build-notes.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build Locally
|
本地起服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t strategy-demo:v1.0.0 .
|
export STRATEGY_ID=local-dev
|
||||||
|
python3 -m src.server
|
||||||
docker run --rm -p 8080:8080 strategy-demo:v1.0.0
|
# → http://127.0.0.1:8080/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## Platform Test
|
## 目录
|
||||||
|
|
||||||
Push this directory to Gitea and keep the `v1.0.0` tag. Then create a strategy with:
|
```text
|
||||||
|
Dockerfile Makefile README.md
|
||||||
```json
|
src/ # server / health / strategy / gate
|
||||||
{
|
config/ # example.env, candidates.example.json
|
||||||
"name": "strategy-demo",
|
scripts/ # smoke_health.py, dry_run.py, tag_release.py
|
||||||
"repo_url": "https://<your-gitea-host>/<user>/strategy-demo",
|
reports/ # dry-run 输出(gitignore)
|
||||||
"tag": "v1.0.0"
|
docs/ # 契约 / runbook / kickoff / issues
|
||||||
}
|
tests/ # stdlib unittest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Makefile 目标
|
||||||
|
|
||||||
|
- `test` / `build` / `smoke-health` / `dry-run`
|
||||||
|
- **没有** `register` / `submit` / `deploy` / `push`
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [`docs/kickoff-for-victor.md`](docs/kickoff-for-victor.md) — 一页开工 + 验收勾选
|
||||||
|
- [`docs/platform-contract-agent.md`](docs/platform-contract-agent.md) — 契约表
|
||||||
|
- [`docs/register-runbook.md`](docs/register-runbook.md) — 仅人工注册
|
||||||
|
- [`docs/api-probe-design.md`](docs/api-probe-design.md) — 写接口禁止代调
|
||||||
|
- [`docs/diff-vs-demo.md`](docs/diff-vs-demo.md) — 对照官方 demo
|
||||||
|
- [`docs/issues.md`](docs/issues.md) — M0–M4 / Phase 清单
|
||||||
|
- [`docs/build-notes.md`](docs/build-notes.md) — 镜像构建说明
|
||||||
|
|
||||||
|
## 资源声明
|
||||||
|
|
||||||
|
按平台量级设计:约 **100m–1 CPU / 256–512Mi**。
|
||||||
|
|
||||||
|
## 许可与边界
|
||||||
|
|
||||||
|
本仓库用于 Victor 本地/Gitea 交付骨架;任何 ModelHub 写操作须 Victor 本人在控制台或显式批准后执行。
|
||||||
|
|||||||
21
config/candidates.example.json
Normal file
21
config/candidates.example.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"model_id": "example-org/example-model",
|
||||||
|
"modelAddress": "example-org/example-model",
|
||||||
|
"taskType": "text-generation",
|
||||||
|
"targetGpu": "Iluvatar_bi-100",
|
||||||
|
"framework": "vllm",
|
||||||
|
"notes": "Phase1 example only — not a real submit payload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "example-org/another-model",
|
||||||
|
"taskType": "text-generation",
|
||||||
|
"notes": "Minimal fields: model_id required; others optional"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_meta": {
|
||||||
|
"description": "Minimal dry-run input schema for Phase1",
|
||||||
|
"pending_align": "Future Phase2 may read /workspace/modelhub-adapt/pending-submit.md A→B order"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
config/example.env
Normal file
20
config/example.env
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy to config/local.env (gitignored) for local runs. Never commit real tokens.
|
||||||
|
|
||||||
|
# Required for process start (Phase1): non-empty STRATEGY_ID
|
||||||
|
STRATEGY_ID=local-dev-strategy-placeholder
|
||||||
|
|
||||||
|
# Optional listen port (default 8080)
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Implementation is authoritative: ALLOW_LIVE_SUBMIT=1 AND
|
||||||
|
# LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT. No confirmation file is read.
|
||||||
|
# PRD examples ALLOW_LIVE_SUBMIT=true / CONFIRM_LIVE=YES are unsupported.
|
||||||
|
# Even with both set, no real client exists: still blocked. Keep defaults below.
|
||||||
|
# Dual live gate — BOTH required for live; default is dry-run / blocked
|
||||||
|
ALLOW_LIVE_SUBMIT=0
|
||||||
|
LIVE_CONFIRM=
|
||||||
|
|
||||||
|
# Platform injects EXTERNAL_SERVICE_TOKEN at runtime.
|
||||||
|
# Community aliases (presence only; never log values):
|
||||||
|
# XC_TOKEN_FILE / XC_TOKEN / MODELHUB_XC_TOKEN / EXTERNAL_SERVICE_TOKEN
|
||||||
|
# EXTERNAL_SERVICE_TOKEN=
|
||||||
38
docs/api-probe-design.md
Normal file
38
docs/api-probe-design.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# API 探针设计(只读意图 · 禁止代调写接口)
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- Phase1:**仅设计文档**;本仓不实现真实 HTTP 写客户端
|
||||||
|
|
||||||
|
## 管理 API `/agent_platform/strategies*`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 本仓态度 |
|
||||||
|
|------|------|----------|
|
||||||
|
| POST | `/agent_platform/strategies` | **禁止代调**(注册) |
|
||||||
|
| POST | `.../{id}/deploy` | **禁止代调** |
|
||||||
|
| POST | `.../{id}/stop` | **禁止代调** |
|
||||||
|
| DELETE | `.../{id}` | **禁止代调** |
|
||||||
|
| GET | `.../{id}` | 未来只读探针可选(Phase3+,需 Victor 授权) |
|
||||||
|
| GET | `.../{id}/logs` | 未来只读探针可选 |
|
||||||
|
|
||||||
|
鉴权(浏览器):`Authorization: Bearer <login token>`(等级 A)。
|
||||||
|
|
||||||
|
## 适配任务 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 本仓态度 |
|
||||||
|
|------|------|----------|
|
||||||
|
| GET | `/api/adapt/task/page` | 未来只读鉴权探针(对齐 huni-probe,`ALLOW_SUBMIT=0`) |
|
||||||
|
| POST | `/api/adapt/task/build-config` | 设计可引用;Phase1 不实现 |
|
||||||
|
| POST | `/api/adapt/task/add` | **禁止代调**;live 仅闸门 + 单测假客户端 |
|
||||||
|
| GET | `/api/adapt/task/calculate-credit` | 只读索引;勿写死 KPI |
|
||||||
|
|
||||||
|
容器内社区鉴权(C):`Xc-Token` + body `strategyId` — **待 Victor 账号核实**后再实现探针。
|
||||||
|
|
||||||
|
## Phase1 落地边界
|
||||||
|
|
||||||
|
- `src/gate.py`:闸门逻辑 + `attempt_live_submit(..., client=Fake)` 仅供单测
|
||||||
|
- **无**默认 `urllib`/`requests` 客户端指向 `task/add` 或 `strategies` POST
|
||||||
|
- Makefile **无** register / submit / deploy / push
|
||||||
|
|
||||||
|
## 冲突裁决提醒
|
||||||
|
|
||||||
|
适配 W1:队列 ≥95 → 禁 live;智能体不抢验证空位。
|
||||||
59
docs/build-notes.md
Normal file
59
docs/build-notes.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Build notes
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 默认 `FROM`:`modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim`
|
||||||
|
- **禁止**因本地拉不下而改默认 FROM 为公网镜像
|
||||||
|
|
||||||
|
## 本机(box)首次尝试
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
|----|------|
|
||||||
|
| `docker` CLI | **未安装 / 不可用**(`which docker` 空) |
|
||||||
|
| `make build` | 预期失败:`docker not found`(Makefile 显式报错) |
|
||||||
|
| 默认 FROM | **未修改** |
|
||||||
|
|
||||||
|
### 后续建议(Victor)
|
||||||
|
|
||||||
|
1. 在有 Docker 且能访问腾讯云 TCR 平台镜像的环境执行 `make build`
|
||||||
|
2. 若 `docker pull` 平台 base 失败:把完整错误贴本文件「拉取失败记录」,仍保持默认 FROM
|
||||||
|
3. 无镜像时用本地验收:`STRATEGY_ID=test make smoke-health` / `make test`
|
||||||
|
|
||||||
|
## 拉取失败记录
|
||||||
|
|
||||||
|
(尚无 Docker,无 pull 日志。有 Docker 后在此追加。)
|
||||||
|
|
||||||
|
## 2026-10-16 13:46 Asia/Shanghai — `make build` 实测
|
||||||
|
|
||||||
|
```text
|
||||||
|
ERROR: docker not found. See docs/build-notes.md
|
||||||
|
make: *** [Makefile:19: build] Error 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 原因:box 无 Docker 引擎/CLI,**未发生**平台 base `docker pull`(因此无 TCR 拉取错误日志)
|
||||||
|
- 默认 `FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` **未改**
|
||||||
|
- 替代验收:`make test` 绿;`STRATEGY_ID=test make smoke-health` 绿(本地 `python -m src.server`)
|
||||||
|
|
||||||
|
## 2026-10-16 Asia/Shanghai — Victor Mac `make build`
|
||||||
|
|
||||||
|
- Docker Desktop:已连接
|
||||||
|
- 结果:失败 — `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` pull access denied / insufficient_scope
|
||||||
|
- 默认 FROM:未修改
|
||||||
|
|
||||||
|
## 2026-10-16 Asia/Shanghai — Codex 沙箱复测
|
||||||
|
|
||||||
|
- `make build` 实际退出 2:`ERROR: docker not found. See docs/build-notes.md`。
|
||||||
|
- 无 Docker,未尝试拉取 TCR;本机结果不能证明 TCR 可达或不可达。上文 Mac 的拒绝记录为已有人工证据,未在本轮复现。
|
||||||
|
- `STRATEGY_ID=test make smoke-health` 实际退出 2:沙箱创建 socket 被拒,`PermissionError: [Errno 1] Operation not permitted`。
|
||||||
|
- `make test`:优化后 19 项中 18 项通过,SIGTERM 集成项在监听前同样被 socket 权限阻塞;未跳过或标绿。
|
||||||
|
- 默认平台 base / FROM 保持原样;须在允许本地 socket、具备 Docker 与 TCR 权限的环境复验完整 DoD。
|
||||||
|
|
||||||
|
## 2026-10-16 ~15:20 Asia/Shanghai — tcr-then-build 探测(Mac)
|
||||||
|
|
||||||
|
- Docker Desktop:已连接;`docker` CLI 可用
|
||||||
|
- `~/.docker/config.json`:`auths` 空,`credsStore=desktop` → **未登录** TCR
|
||||||
|
- `docker pull modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim`:
|
||||||
|
`Error response from daemon: pull access denied ... may require 'docker login'`
|
||||||
|
- 默认 FROM:**未修改**
|
||||||
|
- 既有 `agent-contrib:local`(sha256:95a4d01e2b55…):**不算**本轮 DoD(CMD=`src.main`,基底公网 python:3.11-slim)
|
||||||
|
- 验收标准:`/Users/vc/modelhub-adapt-logs/agent-image-dod-2026-10-16.md`(box 同源 `/workspace/modelhub-adapt/logs/agent-image-dod-2026-10-16.md`)
|
||||||
|
- 阻塞:需 Victor 提供 TCR 账号并对 `xc_agent_platform/python` 有 pull,执行 `docker login modelhubxc-4pd.tencentcloudcr.com` 后重试 pull + `make build`
|
||||||
40
docs/diff-vs-demo.md
Normal file
40
docs/diff-vs-demo.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 对照官方演示仓 `luopingyi/xc_agent_platform_demo`
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 官方演示:https://dev.modelhub.org.cn/luopingyi/xc_agent_platform_demo
|
||||||
|
- 安全探针参照:`huni/huni-probe-agent`(只读)
|
||||||
|
- **不要照搬**:`huni/huni-adapt-agent` 默认真提交循环
|
||||||
|
|
||||||
|
## 对齐项(保留)
|
||||||
|
|
||||||
|
| 项 | demo / 规划 | 本仓 |
|
||||||
|
|----|-------------|------|
|
||||||
|
| 根目录 Dockerfile | 是 | 是 |
|
||||||
|
| 默认 base 镜像 | 平台 `python:3.11-slim` | 同(`BASE_IMAGE` 仅 build-arg,默认不改公网) |
|
||||||
|
| `EXPOSE 8080` | 是 | 是 |
|
||||||
|
| `GET /health` → 200 `{"status":"ok"}` | 是 | 是 |
|
||||||
|
| 读 `EXTERNAL_SERVICE_TOKEN` 是否存在 | demo | `/status` 布尔;永不打值 |
|
||||||
|
| stdlib / 轻依赖 | demo 极简 | Phase1 仅 stdlib |
|
||||||
|
|
||||||
|
## 本仓相对 demo 的补强
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| `STRATEGY_ID` 启动校验 | Phase1:**缺失则 exit ≠0**(产品规划;相对「仅读 token 保活」的社区变体) |
|
||||||
|
| 双重 live 闸门 | `ALLOW_LIVE_SUBMIT` + `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` |
|
||||||
|
| `GET /status` | 仅布尔字段 |
|
||||||
|
| dry-run 报告 | `scripts/dry_run.py` → `reports/dry-run-*.md` |
|
||||||
|
| 单测 | health / 缺 STRATEGY_ID / 闸门默认拒绝 / SIGTERM |
|
||||||
|
| 无默认真提交 | live 仅闸门 + **注入假客户端**单测;仓库无默认真打 `/api/adapt/task/add` 的 HTTP 客户端 |
|
||||||
|
|
||||||
|
## 明确不照搬
|
||||||
|
|
||||||
|
| 来源 | 行为 | 本仓 |
|
||||||
|
|------|------|------|
|
||||||
|
| huni-adapt-agent | 默认真提交循环 | **禁止** |
|
||||||
|
| CoolBoy / 社区刷分脚本 | 绕队列 / 无闸门 | **禁止** |
|
||||||
|
| 旧对话口径「缺 STRATEGY_ID 保活 + /health 仍 200」 | 与产品规划 Phase1 冲突 | **以产品规划为准:启动失败** |
|
||||||
|
|
||||||
|
## 资源声明
|
||||||
|
|
||||||
|
文档按约 **100m–1 CPU / 256–512Mi** 设计;Phase1 不做 k8s manifests。
|
||||||
47
docs/issues.md
Normal file
47
docs/issues.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Issue / Phase 清单
|
||||||
|
|
||||||
|
对齐 PRD M0–M4 与产品规划 Phase 0–5。
|
||||||
|
|
||||||
|
## Phase / Milestone 映射
|
||||||
|
|
||||||
|
| Phase(产品规划) | PRD | 状态(本仓) |
|
||||||
|
|-------------------|-----|--------------|
|
||||||
|
| Phase 0 调研冻结 | M0 | 文档已落 `docs/` |
|
||||||
|
| Phase 1 本地可部署样例 | M1 | **本轮交付** |
|
||||||
|
| Phase 2 Dry-run 闭环 | M2 | 骨架:`scripts/dry_run.py`;**待对接 pending-submit**:A→B / PASS / 硬门尚未自动核验 |
|
||||||
|
| Phase 3 人工注册 + 只读探针 | M3 | 仅 runbook / api-probe-design;**不代注册** |
|
||||||
|
| Phase 4 一次 live 验证 | M4 | 闸门已备;**无真实客户端** |
|
||||||
|
| Phase 5 规模化 | — | 未开 |
|
||||||
|
|
||||||
|
## P0 Issues
|
||||||
|
|
||||||
|
- [x] docs: 冻结智能体平台契约(health/STRATEGY_ID/SIGTERM/资源)
|
||||||
|
- [x] docs: 对照 xc_agent_platform_demo 写 diff-vs-demo
|
||||||
|
- [x] chore: 初始化 Dockerfile / Makefile / example.env
|
||||||
|
- [x] feat: GET /health 200 + STRATEGY_ID 校验(缺则启动失败)
|
||||||
|
- [x] feat: SIGTERM 优雅退出(≤30s;测试 <5s)
|
||||||
|
- [x] feat: dry-run 报告生成器(默认模式)
|
||||||
|
- [x] feat: live 人工确认闸门 + 单测(默认拒绝)
|
||||||
|
- [x] docs: 注册智能体 runbook — 仅人工
|
||||||
|
- [x] chore: tag_release(只打本地 tag,不 push / 不注册)
|
||||||
|
- [x] docs: api-probe-design(禁止代调写接口)
|
||||||
|
|
||||||
|
Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。
|
||||||
|
|
||||||
|
## P1(未做)
|
||||||
|
|
||||||
|
- [ ] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读
|
||||||
|
- [ ] 结构化 JSON 日志(禁密钥)
|
||||||
|
- [ ] evidence-agent 模板填写
|
||||||
|
|
||||||
|
## P2(未做)
|
||||||
|
|
||||||
|
- [ ] 贡献台账 CSV
|
||||||
|
- [ ] 多 Tag 矩阵
|
||||||
|
|
||||||
|
## 硬边界(持续)
|
||||||
|
|
||||||
|
- 无 register / submit / deploy / push Makefile 目标
|
||||||
|
- 无 `/agent_platform/strategies*` 写调用
|
||||||
|
- 无 `/api/adapt/task/add` 真客户端
|
||||||
|
- 适配 W1 优先;不抢队列
|
||||||
43
docs/kickoff-for-victor.md
Normal file
43
docs/kickoff-for-victor.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 开工包(一页)— Victor
|
||||||
|
|
||||||
|
**仓路径(本地)**:`/workspace/modelhub-agent-contrib/`
|
||||||
|
**远程**:Gitea 命名空间/仓名 **未定** → Phase1 以本地验收为主
|
||||||
|
**模式**:默认 dry-run;**不代注册、不代提交**
|
||||||
|
|
||||||
|
## 闸门
|
||||||
|
|
||||||
|
| 条件 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| `ALLOW_LIVE_SUBMIT` | 默认 `0` |
|
||||||
|
| `LIVE_CONFIRM` | 空;live 须 `I_CONFIRM_LIVE_SUBMIT` |
|
||||||
|
| `STRATEGY_ID` | **启动必需**;缺失 → exit ≠0 |
|
||||||
|
| 真实 `task/add` | Phase1 **未实现**(仅假客户端单测) |
|
||||||
|
|
||||||
|
## 本地命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /workspace/modelhub-agent-contrib
|
||||||
|
make test
|
||||||
|
make build # 需 Docker + 可达平台 base;失败见 docs/build-notes.md
|
||||||
|
make smoke-health # 无镜像时脚本会拉起本地 server
|
||||||
|
make dry-run # 生成 reports/dry-run-*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
入口:`python -m src.server`(Dockerfile `CMD` 同)
|
||||||
|
|
||||||
|
## 验收勾选(Phase1)
|
||||||
|
|
||||||
|
- [ ] `make test` 绿(health / 缺 STRATEGY_ID / 闸门拒绝 / SIGTERM)
|
||||||
|
- [ ] `make build` 绿 **或** `docs/build-notes.md` 记录平台镜像拉取失败且 **未改默认 FROM**
|
||||||
|
- [ ] `make smoke-health` 绿(镜像或本地 server)
|
||||||
|
- [ ] 根目录 Dockerfile + `EXPOSE 8080`
|
||||||
|
- [ ] README 写清:发布 = 0 分;积分待核实;不代注册/提交
|
||||||
|
- [ ] Makefile **无** register/submit/deploy/push
|
||||||
|
- [ ] dry-run 报告含「需 Victor 人工提交」「积分:待平台核实」
|
||||||
|
- [ ] 未调用任何 ModelHub 写接口
|
||||||
|
|
||||||
|
## 人工下一步(非本 Phase 自动化)
|
||||||
|
|
||||||
|
1. 定 Gitea `<namespace>/<repo>`
|
||||||
|
2. push + tag(`scripts/tag_release.py` 仅本地 tag)
|
||||||
|
3. 控制台新增智能体(见 `docs/register-runbook.md`)
|
||||||
62
docs/platform-contract-agent.md
Normal file
62
docs/platform-contract-agent.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 平台契约 — 适配智能体(Phase1)
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 依据:产品规划 `agent-product-plan-2026-10-16.md` §1 + PRD `02-agent-contribution-prd.md`
|
||||||
|
- **冲突裁决:适配 W1 优先**(队列 ≥95 禁 live;本骨架默认 dry-run)
|
||||||
|
|
||||||
|
## 核实等级
|
||||||
|
|
||||||
|
| 等级 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| **A** | 官方演示仓 / 主站前端明文 |
|
||||||
|
| **B** | PRD 已核实(docs/6 + 控制台,基线 2026-10-12) |
|
||||||
|
| **C** | 社区样例,非平台承诺 |
|
||||||
|
| **D** | 待核实 |
|
||||||
|
|
||||||
|
## 运行契约
|
||||||
|
|
||||||
|
| 项 | 内容 | 等级 |
|
||||||
|
|----|------|------|
|
||||||
|
| 代码托管 | `dev.modelhub.org.cn` 用户仓 | B |
|
||||||
|
| 注册 UI | 名称、仓库地址、**Git Tag**(不可用分支名) | B |
|
||||||
|
| 构建 | 平台 Kaniko,`refs/tags/<tag>` | B |
|
||||||
|
| Dockerfile | **仓库根目录**;base `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` | A |
|
||||||
|
| 端口 | **8080**,`EXPOSE 8080` | A |
|
||||||
|
| 健康检查 | `GET /health` → 200 `{"status":"ok"}` | A |
|
||||||
|
| 可选状态 | `GET /status` → 布尔 `strategy_id_present` / `external_service_token_present`(**不回传值**) | 本仓 Phase1 |
|
||||||
|
| 优雅退出 | `SIGTERM`,约 **≤30s** 平台窗口;本实现目标 ~1s 退出主循环 | B |
|
||||||
|
| 平台注入 | `STRATEGY_ID`;`EXTERNAL_SERVICE_TOKEN`(及社区别名) | A/B |
|
||||||
|
| **STRATEGY_ID 缺失** | **Phase1:进程启动失败(exit ≠0)** | 产品规划 Phase1 + PRD |
|
||||||
|
| 资源 | 约 100m–1 CPU / 256–512Mi | B |
|
||||||
|
| **发布智能体得分** | **0 分**(发布 ≠ 得分) | B |
|
||||||
|
| Live 默认 | **关**;双重闸门 `ALLOW_LIVE_SUBMIT=1` + `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` | 本仓 |
|
||||||
|
| 积分 | 来自**成功适配**;智能体代提成功计同一成功分;公式/周报 **待平台核实** | B / D |
|
||||||
|
|
||||||
|
## 鉴权双轨(文档索引,Phase1 不代调)
|
||||||
|
|
||||||
|
| 场景 | 头 | 等级 |
|
||||||
|
|------|----|------|
|
||||||
|
| 浏览器 / `agent_platform` | `Authorization: Bearer <login token>` | A |
|
||||||
|
| 容器内社区实践 | `Xc-Token: <token>`;body 带 `strategyId` | C |
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 代注册、代 live 提交、Makefile 含 register/submit/deploy/push
|
||||||
|
- 日志/响应/镜像层写入 token 或 STRATEGY_ID **值**
|
||||||
|
- 把「智能体已发布」写入千分进度
|
||||||
|
|
||||||
|
## 冲突裁决(强制)
|
||||||
|
|
||||||
|
| 资源 | 裁决 |
|
||||||
|
|------|------|
|
||||||
|
| 异步验证队列 | 适配 W1 优先;≥95 全体停交(含智能体 live) |
|
||||||
|
| Codex | hold 期扩池/归因 > 智能体脚手架 |
|
||||||
|
| 本骨架 | 默认 dry-run;本轮无真实 `task/add` HTTP 客户端 |
|
||||||
|
|
||||||
|
## 闸门配置以实现为准
|
||||||
|
|
||||||
|
`src/gate.py` 只接受 `ALLOW_LIVE_SUBMIT=1` 与
|
||||||
|
`LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT`(去除首尾空白后精确匹配)。
|
||||||
|
PRD 的 `ALLOW_LIVE_SUBMIT=true` / `CONFIRM_LIVE=YES` 仅为需求示例,
|
||||||
|
本仓不支持这些值/别名,也不读取确认文件。开闸无客户端仍 blocked。
|
||||||
|
这只是本地逻辑闸门,不代表已获 live 授权或已校验队列与候选硬门。
|
||||||
39
docs/register-runbook.md
Normal file
39
docs/register-runbook.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 注册智能体 Runbook(仅人工 · Victor)
|
||||||
|
|
||||||
|
> Agent / Bot **不代填、不代点、不代调写接口**。
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
1. **本地绿**:`make test`(及有 Docker 时 `make build` + `make smoke-health`)
|
||||||
|
2. **Gitea 建仓**(Victor):`dev.modelhub.org.cn/<namespace>/<repo>`
|
||||||
|
- 命名空间/仓名 **未定**时:仅本地验收,不阻塞 Phase1
|
||||||
|
3. **推送代码**(Victor 本人):本 Makefile **无 push 目标**
|
||||||
|
4. **打 Git Tag**(可用本仓 `scripts/tag_release.py` **仅本地 tag**;push tag 由 Victor 执行)
|
||||||
|
5. **控制台新增**:个人主页 → 我的适配智能体 → 新增
|
||||||
|
- 名称
|
||||||
|
- 仓库地址(远程 Git URL)
|
||||||
|
- **Git Tag**(须先出现在远程;`GET .../tags` 下拉可选后再填)
|
||||||
|
6. **Kaniko 构建**:等待状态;证据同时记中文文案与枚举(`building` / `ready` / `failed` / `running` / `stopped`)
|
||||||
|
7. **部署后**:确认容器 `GET /health` → 200;积分应 **无变化**(发布 = 0 分)
|
||||||
|
|
||||||
|
## POST `/agent_platform/strategies`(文档说明 · 禁止代调)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `name` | 策略显示名 |
|
||||||
|
| `repo_url` | Gitea 仓库 URL |
|
||||||
|
| `tag` | Git Tag(非分支) |
|
||||||
|
|
||||||
|
成功响应含 `.id`(等级 A:前端 bundle)。
|
||||||
|
**本仓与 Agent 禁止代调该写接口。**
|
||||||
|
|
||||||
|
相关写接口(同样禁止代调):`/{id}/deploy`、`/{id}/stop`、`DELETE /{id}`。
|
||||||
|
|
||||||
|
## 环境变量(平台注入 / 控制台)
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `STRATEGY_ID` | 策略 id;本仓启动必需 |
|
||||||
|
| `EXTERNAL_SERVICE_TOKEN` | 服务凭证;只检存在,不记值 |
|
||||||
|
| `ALLOW_LIVE_SUBMIT` | 默认 `0` |
|
||||||
|
| `LIVE_CONFIRM` | live 时须为 `I_CONFIRM_LIVE_SUBMIT` |
|
||||||
70
main.py
70
main.py
@@ -1,70 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
|
|
||||||
HOST = "0.0.0.0"
|
|
||||||
PORT = 8080
|
|
||||||
shutdown_requested = False
|
|
||||||
|
|
||||||
|
|
||||||
def _config() -> dict:
|
|
||||||
token = os.getenv("EXTERNAL_SERVICE_TOKEN", "")
|
|
||||||
return {
|
|
||||||
"external_service_token_present": bool(token),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
if self.path == "/health":
|
|
||||||
self._send_json({"status": "ok"})
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path == "/":
|
|
||||||
self._send_json(
|
|
||||||
{
|
|
||||||
"name": "strategy-demo",
|
|
||||||
"status": "running",
|
|
||||||
"config": _config(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json({"error": "not found"}, status=404)
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
|
||||||
print(f"{self.address_string()} - {fmt % args}", flush=True)
|
|
||||||
|
|
||||||
def _send_json(self, body: dict, status: int = 200) -> None:
|
|
||||||
payload = json.dumps(body).encode()
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_signal(signum: int, _frame: object) -> None:
|
|
||||||
global shutdown_requested
|
|
||||||
shutdown_requested = True
|
|
||||||
print(f"received signal {signum}, shutting down", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
signal.signal(signal.SIGTERM, _handle_signal)
|
|
||||||
signal.signal(signal.SIGINT, _handle_signal)
|
|
||||||
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
||||||
server.timeout = 1
|
|
||||||
print(f"strategy-demo listening on {HOST}:{PORT}", flush=True)
|
|
||||||
|
|
||||||
while not shutdown_requested:
|
|
||||||
server.handle_request()
|
|
||||||
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
0
reports/.gitkeep
Normal file
0
reports/.gitkeep
Normal file
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
107
scripts/dry_run.py
Executable file
107
scripts/dry_run.py
Executable file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a dry-run adaptation report (no platform side effects).
|
||||||
|
|
||||||
|
Reads config/candidates.example.json (or DRY_RUN_CANDIDATES path).
|
||||||
|
Writes reports/dry-run-<Asia/Shanghai-timestamp>.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_candidates(path: Path) -> list[dict]:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict) and "candidates" in data:
|
||||||
|
return list(data["candidates"])
|
||||||
|
raise SystemExit(f"unexpected candidates schema in {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _render(candidates: list[dict], ts: str, src: Path) -> str:
|
||||||
|
lines = [
|
||||||
|
f"# Dry-run 适配建议报告 — {ts} (Asia/Shanghai)",
|
||||||
|
"",
|
||||||
|
"> **模式:dry-run** — 本报告不产生平台侧真实提交。",
|
||||||
|
"> **需 Victor 人工提交**:请登录模盒控制台按草稿确认后手工提交。",
|
||||||
|
"> **积分:待平台核实**(发布智能体本身不得分;成功适配按账本计)。",
|
||||||
|
"",
|
||||||
|
"## 闸门状态",
|
||||||
|
"",
|
||||||
|
f"- ALLOW_LIVE_SUBMIT is 1: {os.environ.get('ALLOW_LIVE_SUBMIT', '0').strip() == '1'}",
|
||||||
|
f"- LIVE_CONFIRM set: {'yes' if os.environ.get('LIVE_CONFIRM', '').strip() else 'no'}",
|
||||||
|
f"- STRATEGY_ID present: {'yes' if os.environ.get('STRATEGY_ID', '').strip() else 'no'}",
|
||||||
|
"",
|
||||||
|
"## 输入",
|
||||||
|
"",
|
||||||
|
f"- 候选文件:`{src}`",
|
||||||
|
f"- 候选条数:{len(candidates)}",
|
||||||
|
"",
|
||||||
|
"## 建议草稿(字段示意,非真实 POST)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for i, c in enumerate(candidates, 1):
|
||||||
|
model = c.get("modelAddress") or c.get("model_id") or "(missing model_id)"
|
||||||
|
lines.append(f"### {i}. `{model}`")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("```json")
|
||||||
|
draft = {
|
||||||
|
"modelAddress": model,
|
||||||
|
"taskType": c.get("taskType", "text-generation"),
|
||||||
|
"targetGpu": c.get("targetGpu", "(unset)"),
|
||||||
|
"framework": c.get("framework", "(unset)"),
|
||||||
|
"strategyId": "<PLATFORM_INJECTED_STRATEGY_ID>",
|
||||||
|
"configParams": "<from build-config — not fetched in Phase1 dry-run>",
|
||||||
|
"notes": c.get("notes", ""),
|
||||||
|
}
|
||||||
|
lines.append(json.dumps(draft, ensure_ascii=False, indent=2))
|
||||||
|
lines.append("```")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("- 风险:Phase1 未做唯一性/硬门预检;提交前请对照 do-not-resubmit 与队列空位。")
|
||||||
|
lines.append("- **需 Victor 人工提交**(或经双重闸门的受控 live;本骨架默认拒绝 live)。")
|
||||||
|
lines.append("- **积分:待平台核实**")
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"## 后续对接(Phase2)",
|
||||||
|
"",
|
||||||
|
"- **待对接 pending-submit**:尚未读取 `/workspace/modelhub-adapt/pending-submit.md`,未验证 A→B、PASS、硬门与 do-not-resubmit;本报告仅保留输入顺序。",
|
||||||
|
"- 适配 W1 优先;同一 100 队列,≥95 全体停交;空位先 A 再 B,人工核验后决定,不凭示例报告提交。",
|
||||||
|
"- 禁止 Makefile / 脚本调用 `/api/adapt/task/add` 或 `/agent_platform/strategies*` 写接口。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
src = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"DRY_RUN_CANDIDATES",
|
||||||
|
str(ROOT / "config" / "candidates.example.json"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not src.is_file():
|
||||||
|
raise SystemExit(f"candidates file not found: {src}")
|
||||||
|
|
||||||
|
candidates = _load_candidates(src)
|
||||||
|
now = datetime.now(TZ)
|
||||||
|
ts = now.strftime("%Y%m%d-%H%M%S")
|
||||||
|
out_dir = ROOT / "reports"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = out_dir / f"dry-run-{ts}.md"
|
||||||
|
out_path.write_text(_render(candidates, now.strftime("%Y-%m-%d %H:%M:%S"), src), encoding="utf-8")
|
||||||
|
print(f"Wrote {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
100
scripts/smoke_health.py
Executable file
100
scripts/smoke_health.py
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smoke: GET /health must return 200 {"status":"ok"}.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Against already-running server:
|
||||||
|
SMOKE_BASE_URL=http://127.0.0.1:8080 python3 scripts/smoke_health.py
|
||||||
|
|
||||||
|
# Or let this script start a local server (needs STRATEGY_ID):
|
||||||
|
STRATEGY_ID=test python3 scripts/smoke_health.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, timeout: float = 2.0) -> tuple[int, dict]:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return resp.status, json.loads(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_health(base: str, attempts: int = 30) -> None:
|
||||||
|
url = base.rstrip("/") + "/health"
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for _ in range(attempts):
|
||||||
|
try:
|
||||||
|
code, data = _get(url)
|
||||||
|
if code == 200 and data.get("status") == "ok":
|
||||||
|
print(f"OK: GET {url} -> {code} {data}")
|
||||||
|
return
|
||||||
|
raise AssertionError(f"unexpected response: {code} {data}")
|
||||||
|
except (urllib.error.URLError, ConnectionError, AssertionError, TimeoutError) as exc:
|
||||||
|
last_err = exc
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise SystemExit(f"smoke-health failed: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
external = os.environ.get("SMOKE_BASE_URL", "").strip()
|
||||||
|
if external:
|
||||||
|
_wait_health(external)
|
||||||
|
# optional status check
|
||||||
|
try:
|
||||||
|
code, data = _get(external.rstrip("/") + "/status")
|
||||||
|
print(f"OK: GET /status -> {code} {data}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"WARN: /status check skipped: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.environ.get("STRATEGY_ID", "").strip():
|
||||||
|
os.environ["STRATEGY_ID"] = "smoke-test-strategy"
|
||||||
|
|
||||||
|
port = os.environ.get("SMOKE_PORT", "18080")
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PORT"] = port
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
env["STRATEGY_ID"] = env.get("STRATEGY_ID") or "smoke-test-strategy"
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_health(f"http://127.0.0.1:{port}")
|
||||||
|
code, data = _get(f"http://127.0.0.1:{port}/status")
|
||||||
|
print(f"OK: GET /status -> {code} {data}")
|
||||||
|
assert "strategy_id_present" in data
|
||||||
|
assert data["strategy_id_present"] is True
|
||||||
|
# never assert on secret values — only booleans
|
||||||
|
finally:
|
||||||
|
proc.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
if proc.returncode not in (0, -signal.SIGTERM, signal.SIGTERM):
|
||||||
|
# 0 or terminated by SIGTERM is fine
|
||||||
|
err = (proc.stderr.read() or b"").decode("utf-8", errors="replace")
|
||||||
|
if proc.returncode is not None and proc.returncode > 0:
|
||||||
|
print(f"WARN: server exit code {proc.returncode}: {err}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
43
scripts/tag_release.py
Executable file
43
scripts/tag_release.py
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a local git tag only. Does NOT push. Does NOT register on ModelHub.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/tag_release.py v0.1.0
|
||||||
|
python3 scripts/tag_release.py v0.1.0 --message "Phase1 skeleton"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Local git tag only (no push, no register)")
|
||||||
|
parser.add_argument("tag", help="Tag name, e.g. v0.1.0")
|
||||||
|
parser.add_argument("--message", "-m", default="", help="Annotated tag message")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not (ROOT / ".git").exists():
|
||||||
|
print(
|
||||||
|
"WARN: not a git repository yet. Init locally if needed; "
|
||||||
|
"this script will not push or register.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
# Still attempt so the failure is clear
|
||||||
|
msg = args.message or f"Release {args.tag} (local tag only — no push / no ModelHub register)"
|
||||||
|
cmd = ["git", "tag", "-a", args.tag, "-m", msg]
|
||||||
|
print(f"Running (cwd={ROOT}): {' '.join(cmd)}")
|
||||||
|
print("NOTE: This does NOT git push and does NOT call ModelHub APIs.")
|
||||||
|
result = subprocess.run(cmd, cwd=str(ROOT))
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SystemExit(result.returncode)
|
||||||
|
print(f"OK: local tag {args.tag!r} created. Push tag yourself when ready for Kaniko.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
src/__init__.py
Normal file
3
src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""ModelHub XC adaptation agent — Phase1 local skeleton."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
107
src/gate.py
Normal file
107
src/gate.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Dual live-submit gate. Default: dry-run / blocked.
|
||||||
|
|
||||||
|
Live is allowed only when ALL of:
|
||||||
|
- ALLOW_LIVE_SUBMIT=1
|
||||||
|
- LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT
|
||||||
|
- STRATEGY_ID present
|
||||||
|
|
||||||
|
Phase1: no real HTTP client for /api/adapt/task/add. Live path accepts an
|
||||||
|
injected fake client for unit tests only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Dict, Optional, Protocol
|
||||||
|
|
||||||
|
from .strategy import strategy_id_present
|
||||||
|
|
||||||
|
LIVE_CONFIRM_VALUE = "I_CONFIRM_LIVE_SUBMIT"
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitClient(Protocol):
|
||||||
|
"""Minimal interface for a submit client (injected fake in tests)."""
|
||||||
|
|
||||||
|
def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GateDecision:
|
||||||
|
mode: str # "dry-run" | "live" | "blocked"
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_live_flag() -> bool:
|
||||||
|
return os.environ.get("ALLOW_LIVE_SUBMIT", "0").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _live_confirm_ok() -> bool:
|
||||||
|
return os.environ.get("LIVE_CONFIRM", "").strip() == LIVE_CONFIRM_VALUE
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_gate() -> GateDecision:
|
||||||
|
"""Evaluate whether live submit is permitted."""
|
||||||
|
if not strategy_id_present():
|
||||||
|
return GateDecision(
|
||||||
|
mode="blocked",
|
||||||
|
allowed=False,
|
||||||
|
reason="STRATEGY_ID missing or empty",
|
||||||
|
)
|
||||||
|
if not _allow_live_flag():
|
||||||
|
return GateDecision(
|
||||||
|
mode="dry-run",
|
||||||
|
allowed=False,
|
||||||
|
reason="ALLOW_LIVE_SUBMIT is not 1 (default dry-run)",
|
||||||
|
)
|
||||||
|
if not _live_confirm_ok():
|
||||||
|
return GateDecision(
|
||||||
|
mode="blocked",
|
||||||
|
allowed=False,
|
||||||
|
reason=f"LIVE_CONFIRM must be exactly {LIVE_CONFIRM_VALUE!r}",
|
||||||
|
)
|
||||||
|
return GateDecision(
|
||||||
|
mode="live",
|
||||||
|
allowed=True,
|
||||||
|
reason="dual gate open (ALLOW_LIVE_SUBMIT=1 and LIVE_CONFIRM set)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def attempt_live_submit(
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
client: Optional[SubmitClient] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Attempt a live submit only if the gate opens and a client is injected.
|
||||||
|
|
||||||
|
Phase1: without an injected client, even an open gate returns blocked with
|
||||||
|
a message that real HTTP submit is not implemented in this skeleton.
|
||||||
|
Never calls platform write APIs from this module.
|
||||||
|
"""
|
||||||
|
decision = evaluate_gate()
|
||||||
|
if not decision.allowed:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"mode": decision.mode,
|
||||||
|
"reason": decision.reason,
|
||||||
|
"submitted": False,
|
||||||
|
}
|
||||||
|
if client is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"mode": "blocked",
|
||||||
|
"reason": (
|
||||||
|
"Phase1 skeleton: no real task/add client; "
|
||||||
|
"inject a fake client in tests only"
|
||||||
|
),
|
||||||
|
"submitted": False,
|
||||||
|
}
|
||||||
|
result = client.submit(payload)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"mode": "live",
|
||||||
|
"reason": decision.reason,
|
||||||
|
"submitted": True,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
33
src/health.py
Normal file
33
src/health.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Health and status HTTP handlers. Never echo secrets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
from .strategy import external_service_token_present, strategy_id_present
|
||||||
|
|
||||||
|
|
||||||
|
def health_payload() -> Dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def status_payload() -> Dict[str, Any]:
|
||||||
|
"""Booleans only — never include STRATEGY_ID or token values."""
|
||||||
|
return {
|
||||||
|
"strategy_id_present": strategy_id_present(),
|
||||||
|
"external_service_token_present": external_service_token_present(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_get(path: str) -> Tuple[int, Dict[str, str], bytes]:
|
||||||
|
"""Return (status_code, headers, body) for GET paths."""
|
||||||
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||||
|
if path == "/health" or path.startswith("/health?"):
|
||||||
|
body = json.dumps(health_payload(), ensure_ascii=False).encode("utf-8")
|
||||||
|
return 200, headers, body
|
||||||
|
if path == "/status" or path.startswith("/status?"):
|
||||||
|
body = json.dumps(status_payload(), ensure_ascii=False).encode("utf-8")
|
||||||
|
return 200, headers, body
|
||||||
|
body = json.dumps({"error": "not_found"}, ensure_ascii=False).encode("utf-8")
|
||||||
|
return 404, headers, body
|
||||||
83
src/server.py
Normal file
83
src/server.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Minimal HTTP server for the adaptation agent (stdlib only).
|
||||||
|
|
||||||
|
Listens on PORT (default 8080). Handles SIGTERM and exits the main loop
|
||||||
|
within ~1s (platform window documented as ≤30s).
|
||||||
|
|
||||||
|
Startup: require STRATEGY_ID (Phase1 product plan — fail if missing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .health import handle_get
|
||||||
|
from .strategy import require_strategy_id
|
||||||
|
|
||||||
|
_shutdown = threading.Event()
|
||||||
|
_httpd: Optional[ThreadingHTTPServer] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentHandler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None: # noqa: A003
|
||||||
|
# Avoid noisy default logging; never log env secrets.
|
||||||
|
sys.stderr.write("[http] request handled (details omitted)\n")
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
code, headers, body = handle_get(self.path)
|
||||||
|
self.send_response(code)
|
||||||
|
for k, v in headers.items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
body = b'{"error":"method_not_allowed"}'
|
||||||
|
self.send_response(405)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_sigterm(signum: int, frame) -> None: # noqa: ARG001
|
||||||
|
sys.stderr.write(f"Received signal {signum}; shutting down...\n")
|
||||||
|
_shutdown.set()
|
||||||
|
httpd = _httpd
|
||||||
|
if httpd is not None:
|
||||||
|
# wake serve_forever
|
||||||
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
global _httpd
|
||||||
|
|
||||||
|
require_strategy_id() # exit 1 if missing
|
||||||
|
|
||||||
|
port = int(os.environ.get("PORT", "8080"))
|
||||||
|
host = os.environ.get("HOST", "0.0.0.0")
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _on_sigterm)
|
||||||
|
signal.signal(signal.SIGINT, _on_sigterm)
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer((host, port), AgentHandler)
|
||||||
|
httpd.daemon_threads = True
|
||||||
|
_httpd = httpd
|
||||||
|
|
||||||
|
sys.stderr.write(f"Listening on {host}:{port} (GET /health, GET /status)\n")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever(poll_interval=0.5)
|
||||||
|
finally:
|
||||||
|
httpd.server_close()
|
||||||
|
sys.stderr.write("Server stopped.\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
81
src/strategy.py
Normal file
81
src/strategy.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""STRATEGY_ID and external-token presence helpers.
|
||||||
|
|
||||||
|
Phase1 (product plan): missing STRATEGY_ID → process must fail to start
|
||||||
|
(exit non-zero). Values are never logged or returned in responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Community alias order (level C); we only check presence, never echo values.
|
||||||
|
_TOKEN_ENV_KEYS = (
|
||||||
|
"XC_TOKEN_FILE",
|
||||||
|
"XC_TOKEN",
|
||||||
|
"MODELHUB_XC_TOKEN",
|
||||||
|
"EXTERNAL_SERVICE_TOKEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy_id() -> Optional[str]:
|
||||||
|
"""Return STRATEGY_ID if set and non-empty after strip; else None."""
|
||||||
|
raw = os.environ.get("STRATEGY_ID")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
value = raw.strip()
|
||||||
|
return value if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def require_strategy_id() -> str:
|
||||||
|
"""Return STRATEGY_ID or exit the process with a non-zero status.
|
||||||
|
|
||||||
|
Does not print the value. Safe for startup path.
|
||||||
|
"""
|
||||||
|
sid = get_strategy_id()
|
||||||
|
if sid is None:
|
||||||
|
print(
|
||||||
|
"ERROR: STRATEGY_ID is required and must be non-empty. "
|
||||||
|
"Refusing to start (Phase1 contract).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def strategy_id_present() -> bool:
|
||||||
|
return get_strategy_id() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_from_file(path: str) -> bool:
|
||||||
|
"""True if path exists and has non-empty content (value never read into logs)."""
|
||||||
|
try:
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
content = fh.read().strip()
|
||||||
|
return bool(content)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def external_service_token_present() -> bool:
|
||||||
|
"""Whether any known token env / file alias is present (boolean only)."""
|
||||||
|
for key in _TOKEN_ENV_KEYS:
|
||||||
|
raw = os.environ.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
value = raw.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
if key == "XC_TOKEN_FILE":
|
||||||
|
if _token_from_file(value):
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
# Reject obvious placeholders (community practice, level C)
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered in ("tmp", "placeholder", "changeme", "xxx"):
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
138
tests/test_gate.py
Normal file
138
tests/test_gate.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Gate defaults to dry-run / blocked; live only with dual confirm + STRATEGY_ID."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from src.gate import LIVE_CONFIRM_VALUE, attempt_live_submit, evaluate_gate
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def submit(self, payload):
|
||||||
|
self.calls.append(payload)
|
||||||
|
return {"task_id": "fake-1"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGate(unittest.TestCase):
|
||||||
|
def _clear_gate_env(self):
|
||||||
|
return mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "sid-1",
|
||||||
|
"ALLOW_LIVE_SUBMIT": "0",
|
||||||
|
"LIVE_CONFIRM": "",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_dry_run(self) -> None:
|
||||||
|
with self._clear_gate_env():
|
||||||
|
d = evaluate_gate()
|
||||||
|
self.assertFalse(d.allowed)
|
||||||
|
self.assertEqual(d.mode, "dry-run")
|
||||||
|
|
||||||
|
def test_missing_strategy_blocked(self) -> None:
|
||||||
|
env = {
|
||||||
|
"ALLOW_LIVE_SUBMIT": "1",
|
||||||
|
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||||
|
}
|
||||||
|
# Remove STRATEGY_ID
|
||||||
|
base = {k: v for k, v in os.environ.items() if k != "STRATEGY_ID"}
|
||||||
|
base.update(env)
|
||||||
|
with mock.patch.dict(os.environ, base, clear=True):
|
||||||
|
d = evaluate_gate()
|
||||||
|
self.assertFalse(d.allowed)
|
||||||
|
self.assertEqual(d.mode, "blocked")
|
||||||
|
|
||||||
|
def test_allow_without_confirm_blocked(self) -> None:
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "sid-1",
|
||||||
|
"ALLOW_LIVE_SUBMIT": "1",
|
||||||
|
"LIVE_CONFIRM": "YES",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
d = evaluate_gate()
|
||||||
|
self.assertFalse(d.allowed)
|
||||||
|
self.assertEqual(d.mode, "blocked")
|
||||||
|
|
||||||
|
def test_dual_gate_opens(self) -> None:
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "sid-1",
|
||||||
|
"ALLOW_LIVE_SUBMIT": "1",
|
||||||
|
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
d = evaluate_gate()
|
||||||
|
self.assertTrue(d.allowed)
|
||||||
|
self.assertEqual(d.mode, "live")
|
||||||
|
|
||||||
|
def test_open_gate_without_client_still_blocked(self) -> None:
|
||||||
|
"""Phase1: no real HTTP client even when gate is open."""
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "sid-1",
|
||||||
|
"ALLOW_LIVE_SUBMIT": "1",
|
||||||
|
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
result = attempt_live_submit({"modelAddress": "x/y"})
|
||||||
|
self.assertFalse(result["submitted"])
|
||||||
|
self.assertIn("no real", result["reason"].lower())
|
||||||
|
self.assertEqual(result["mode"], "blocked")
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
|
||||||
|
def test_prd_example_and_whitespace_id_never_submit(self) -> None:
|
||||||
|
cases = [
|
||||||
|
{"ALLOW_LIVE_SUBMIT": "true", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE},
|
||||||
|
{"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": "", "CONFIRM_LIVE": "YES"},
|
||||||
|
{"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, "STRATEGY_ID": " "},
|
||||||
|
]
|
||||||
|
for overrides in cases:
|
||||||
|
with self.subTest(overrides=overrides):
|
||||||
|
client = FakeClient()
|
||||||
|
env = {"STRATEGY_ID": "sid-test", **overrides}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True):
|
||||||
|
result = attempt_live_submit({}, client=client)
|
||||||
|
self.assertFalse(result["submitted"])
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(client.calls, [])
|
||||||
|
self.assertTrue(result["reason"])
|
||||||
|
|
||||||
|
def test_injected_fake_client(self) -> None:
|
||||||
|
client = FakeClient()
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "sid-1",
|
||||||
|
"ALLOW_LIVE_SUBMIT": "1",
|
||||||
|
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
result = attempt_live_submit({"modelAddress": "x/y"}, client=client)
|
||||||
|
self.assertTrue(result["submitted"])
|
||||||
|
self.assertEqual(len(client.calls), 1)
|
||||||
|
|
||||||
|
def test_default_attempt_rejects(self) -> None:
|
||||||
|
client = FakeClient()
|
||||||
|
with self._clear_gate_env():
|
||||||
|
result = attempt_live_submit({"modelAddress": "x/y"}, client=client)
|
||||||
|
self.assertFalse(result["submitted"])
|
||||||
|
self.assertEqual(len(client.calls), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
60
tests/test_health.py
Normal file
60
tests/test_health.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for /health and /status handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from src.health import handle_get, health_payload, status_payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth(unittest.TestCase):
|
||||||
|
def test_health_payload(self) -> None:
|
||||||
|
self.assertEqual(health_payload(), {"status": "ok"})
|
||||||
|
|
||||||
|
def test_handle_get_health(self) -> None:
|
||||||
|
code, headers, body = handle_get("/health")
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertIn("application/json", headers["Content-Type"])
|
||||||
|
data = json.loads(body.decode("utf-8"))
|
||||||
|
self.assertEqual(data, {"status": "ok"})
|
||||||
|
|
||||||
|
def test_handle_get_unknown(self) -> None:
|
||||||
|
code, _, body = handle_get("/nope")
|
||||||
|
self.assertEqual(code, 404)
|
||||||
|
self.assertIn("not_found", body.decode("utf-8"))
|
||||||
|
|
||||||
|
def test_status_booleans_only_no_secret_values(self) -> None:
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "secret-strategy-id-xyz",
|
||||||
|
"EXTERNAL_SERVICE_TOKEN": "secret-token-abc",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
payload = status_payload()
|
||||||
|
self.assertTrue(payload["strategy_id_present"])
|
||||||
|
self.assertTrue(payload["external_service_token_present"])
|
||||||
|
blob = json.dumps(payload)
|
||||||
|
self.assertNotIn("secret-strategy-id-xyz", blob)
|
||||||
|
self.assertNotIn("secret-token-abc", blob)
|
||||||
|
|
||||||
|
def test_status_absent(self) -> None:
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in (
|
||||||
|
"STRATEGY_ID",
|
||||||
|
"EXTERNAL_SERVICE_TOKEN",
|
||||||
|
"XC_TOKEN",
|
||||||
|
"XC_TOKEN_FILE",
|
||||||
|
"MODELHUB_XC_TOKEN",
|
||||||
|
)}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True):
|
||||||
|
payload = status_payload()
|
||||||
|
self.assertFalse(payload["strategy_id_present"])
|
||||||
|
self.assertFalse(payload["external_service_token_present"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
32
tests/test_report_safety.py
Normal file
32
tests/test_report_safety.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Local report and HTTP log regression checks; no network requests."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from scripts.dry_run import _render
|
||||||
|
from src.server import AgentHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportSafety(unittest.TestCase):
|
||||||
|
def test_report_marks_pending_and_does_not_echo_gate_values(self):
|
||||||
|
sentinel = "synthetic-sensitive-value"
|
||||||
|
with mock.patch.dict(os.environ, {
|
||||||
|
"ALLOW_LIVE_SUBMIT": sentinel,
|
||||||
|
"LIVE_CONFIRM": sentinel,
|
||||||
|
"STRATEGY_ID": sentinel,
|
||||||
|
"EXTERNAL_SERVICE_TOKEN": sentinel,
|
||||||
|
}, clear=True):
|
||||||
|
report = _render([], "test-time", Path("example.json"))
|
||||||
|
self.assertNotIn(sentinel, report)
|
||||||
|
for required in ("待对接 pending-submit", "A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"):
|
||||||
|
self.assertIn(required, report)
|
||||||
|
|
||||||
|
def test_http_log_omits_request_details(self):
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(output):
|
||||||
|
AgentHandler.log_message(None, '"%s" %s', '/health?token=synthetic-sensitive-value', 200)
|
||||||
|
self.assertNotIn("synthetic-sensitive-value", output.getvalue())
|
||||||
|
self.assertIn("request handled", output.getvalue())
|
||||||
69
tests/test_sigterm.py
Normal file
69
tests/test_sigterm.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""SIGTERM should stop the server subprocess promptly (<< 30s platform window)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSigterm(unittest.TestCase):
|
||||||
|
def test_sigterm_exits_quickly(self) -> None:
|
||||||
|
port = "19090"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["STRATEGY_ID"] = "sigterm-test"
|
||||||
|
env["PORT"] = port
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# wait until health is up
|
||||||
|
url = f"http://127.0.0.1:{port}/health"
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=0.5) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
if resp.status == 200 and data.get("status") == "ok":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
self.fail(f"server exited before health check: {proc.stderr.read().decode(errors='replace')}")
|
||||||
|
self.fail("server did not become healthy in time")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
proc.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
self.fail("server did not exit within 5s after SIGTERM (platform allows ≤30s)")
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
self.assertLess(elapsed, 5.0, f"exit took {elapsed:.2f}s")
|
||||||
|
self.assertEqual(proc.returncode, 0, "SIGTERM must exit cleanly, not kill the process")
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
if proc.stderr:
|
||||||
|
proc.stderr.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
57
tests/test_strategy_startup.py
Normal file
57
tests/test_strategy_startup.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Missing STRATEGY_ID must cause startup failure (exit non-zero)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrategyStartup(unittest.TestCase):
|
||||||
|
def test_missing_strategy_id_exits_nonzero(self) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["STRATEGY_ID"] = ""
|
||||||
|
env["PORT"] = "19081"
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(proc.returncode, 0)
|
||||||
|
self.assertIn("STRATEGY_ID", proc.stderr)
|
||||||
|
|
||||||
|
def test_whitespace_strategy_id_exits_nonzero(self) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["STRATEGY_ID"] = " "
|
||||||
|
env["PORT"] = "19082"
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(proc.returncode, 0)
|
||||||
|
|
||||||
|
def test_require_strategy_id_helper(self) -> None:
|
||||||
|
from src.strategy import require_strategy_id
|
||||||
|
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
with mock.patch.dict(os.environ, {"STRATEGY_ID": ""}, clear=False):
|
||||||
|
require_strategy_id()
|
||||||
|
self.assertNotEqual(ctx.exception.code, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user