chore: 忽略离线部署包,提交安全加固、数据库初始化与文档
- .gitignore: 忽略 docker/offline 离线部署包(镜像/运行时等大文件) - 安全加固: 新增 compute/api/security.py 及各端安全测试,补充 docs/security-hardening.md - 数据库: 新增完整初始化 SQL 与 docs/database-config.md - 数据转换与评测: 修复类型检查、增强校验并补充测试 - Docker 配置与环境变量更新 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,13 @@ from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFi
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
from compute.agent.process_manager import ProcessManager
|
||||
from compute.api.security import docs_kwargs
|
||||
from compute.engines.llama_factory.adapter import build_command, parse_log_line, prepare_runtime_files
|
||||
from compute.engines.llama_factory.inference import get_inference_session
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="YG Fine-Tune Compute API")
|
||||
app = FastAPI(title="YG Fine-Tune Compute API", **docs_kwargs())
|
||||
jobs: dict[str, dict[str, Any]] = {}
|
||||
route_prefix = os.getenv("MODELTF_ROUTE_PREFIX", "/modelTF").rstrip("/") or "/modelTF"
|
||||
process_manager = ProcessManager(os.getenv("TRAINING_LOG_ROOT", "/opt/yg-ft/logs/training"))
|
||||
@@ -860,10 +861,17 @@ def create_app() -> FastAPI:
|
||||
@app.get(f"{route_prefix}/compute/files/{{file_id}}/download")
|
||||
async def download_file(file_id: str) -> FileResponse:
|
||||
upload_root = Path(os.getenv("YG_FT_DATA_ROOT", "/data/yg-ft")) / "uploads"
|
||||
# file_id 仅允许普通标识符,拒绝 ../、/、\ 等路径穿越字符。
|
||||
if not file_id or not all(character.isalnum() or character in {"_", "-"} for character in file_id):
|
||||
raise HTTPException(status_code=400, detail="invalid file id")
|
||||
matches = list(upload_root.glob(f"{file_id}_*"))
|
||||
if not matches:
|
||||
raise HTTPException(status_code=404, detail="file not found")
|
||||
return FileResponse(matches[0])
|
||||
# 解析符号链接后仍必须位于 upload 根目录内,防止符号链接指向目录外文件。
|
||||
resolved = matches[0].resolve()
|
||||
if not _path_inside(upload_root, resolved):
|
||||
raise HTTPException(status_code=404, detail="file not found")
|
||||
return FileResponse(resolved)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
29
compute/api/security.py
Normal file
29
compute/api/security.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""计算节点 API 安全配置:Swagger / ReDoc / OpenAPI 文档路由开关。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def docs_enabled() -> bool:
|
||||
"""判断 FastAPI 文档路由(/docs、/redoc、/openapi.json)是否开放。
|
||||
|
||||
显式配置 ENABLE_DOCS 时以之为准;否则仅在关闭 token 鉴权
|
||||
(COMPUTE_AUTH_ENABLED=false,本地开发)时开放,生产环境默认关闭,
|
||||
避免未授权访问泄露 API 结构。
|
||||
"""
|
||||
raw = os.getenv("ENABLE_DOCS", "").strip().lower()
|
||||
if raw in {"true", "false"}:
|
||||
return raw == "true"
|
||||
auth_enabled = os.getenv("COMPUTE_AUTH_ENABLED", "true").lower() == "true"
|
||||
return not auth_enabled
|
||||
|
||||
|
||||
def docs_kwargs() -> dict[str, Any]:
|
||||
"""返回传入 FastAPI 的文档路由参数。
|
||||
|
||||
关闭时 FastAPI 不注册 /docs、/redoc、/openapi.json,访问一律返回 404。
|
||||
"""
|
||||
if docs_enabled():
|
||||
return {}
|
||||
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
@@ -15,27 +15,58 @@ class LlamaFactoryCommand:
|
||||
|
||||
|
||||
def _load_dataset_preview(path: Path) -> list[dict[str, Any]]:
|
||||
"""Load a preview of JSON/JSONL records from a dataset file.
|
||||
|
||||
Content-sniffs instead of trusting the extension so that BOM-prefixed files,
|
||||
JSONL files containing a single JSON array, and mislabeled extensions all work.
|
||||
"""
|
||||
if not path.exists():
|
||||
return []
|
||||
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
text = path.read_text(encoding="utf-8-sig", errors="replace").strip()
|
||||
if not text:
|
||||
return []
|
||||
if path.suffix.lower() == ".jsonl":
|
||||
items: list[dict[str, Any]] = []
|
||||
for line in text.splitlines()[:20]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
value = json.loads(line)
|
||||
if isinstance(value, dict):
|
||||
items.append(value)
|
||||
return items
|
||||
value = json.loads(text)
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
value = None
|
||||
if isinstance(value, list):
|
||||
return [item for item in value[:20] if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for line in text.splitlines()[:20]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, list):
|
||||
items.extend(item for item in parsed[:20] if isinstance(item, dict))
|
||||
elif isinstance(parsed, dict):
|
||||
items.append(parsed)
|
||||
if len(items) >= 20:
|
||||
break
|
||||
return items[:20]
|
||||
|
||||
|
||||
def _required_columns_for(formatting: str, columns: dict[str, Any]) -> list[str]:
|
||||
"""Required data columns per dataset format.
|
||||
|
||||
Mirrors LLaMA-Factory's leniency: optional columns (e.g. ``input`` / ``query``
|
||||
in Alpaca) are never required, only fields the format structurally needs.
|
||||
"""
|
||||
fmt = str(formatting or "").lower()
|
||||
if fmt == "sharegpt":
|
||||
return [str(columns.get("messages") or "messages")]
|
||||
if fmt in {"dpo", "rm", "kto", "ppo"}:
|
||||
return [str(columns[key]) for key in ("chosen", "rejected") if columns.get(key)]
|
||||
if fmt in {"cpt", "pt", "pretrain"}:
|
||||
return [str(columns.get("prompt") or columns.get("text") or "text")]
|
||||
# alpaca family: prompt (instruction) + response (output) required,
|
||||
# query (input) / history are optional and common to omit in jsonl datasets.
|
||||
return [str(columns[key]) for key in ("prompt", "response") if columns.get(key)]
|
||||
|
||||
|
||||
def _validate_dataset_columns(config: dict[str, Any]) -> list[str]:
|
||||
@@ -51,7 +82,7 @@ def _validate_dataset_columns(config: dict[str, Any]) -> list[str]:
|
||||
file_name = item.get("file_name")
|
||||
file_names = file_name if isinstance(file_name, list) else [file_name]
|
||||
columns = item.get("columns") if isinstance(item.get("columns"), dict) else {}
|
||||
required_columns = [str(value) for value in columns.values() if value]
|
||||
required_columns = _required_columns_for(str(item.get("formatting") or ""), columns)
|
||||
for name in file_names:
|
||||
if not name:
|
||||
continue
|
||||
|
||||
@@ -22,7 +22,10 @@ from typing import Any
|
||||
|
||||
|
||||
def _load_dataset(path: str) -> list[dict[str, Any]]:
|
||||
"""Load a JSON or JSONL dataset file.
|
||||
"""Load a JSON or JSONL dataset file (jsonl-compatible).
|
||||
|
||||
Content-sniffs instead of trusting the extension so jsonl files with a BOM,
|
||||
a single JSON array on one line, or mislabeled extensions all load correctly.
|
||||
|
||||
Supports common field names used across the platform:
|
||||
* ``instruction`` + ``input`` + ``output`` (Alpaca-style)
|
||||
@@ -30,14 +33,17 @@ def _load_dataset(path: str) -> list[dict[str, Any]]:
|
||||
* ``messages`` (ShareGPT-style – the last assistant message is treated as reference)
|
||||
"""
|
||||
file_path = Path(path)
|
||||
text = file_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
text = file_path.read_text(encoding="utf-8-sig", errors="replace").strip()
|
||||
if not text:
|
||||
return []
|
||||
if file_path.suffix.lower() == ".json":
|
||||
try:
|
||||
value = json.loads(text)
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
return [value] if isinstance(value, dict) else []
|
||||
except json.JSONDecodeError:
|
||||
value = None
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
|
||||
samples: list[dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
|
||||
42
compute/tests/test_eval_runner.py
Normal file
42
compute/tests/test_eval_runner.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from compute.engines.llama_factory.eval_runner import _load_dataset
|
||||
|
||||
|
||||
def _write(tmp_path, name: str, text: str) -> str:
|
||||
path = tmp_path / name
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_load_jsonl_multiline(tmp_path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"eval.jsonl",
|
||||
'{"question": "q1", "answer": "a1"}\n{"question": "q2", "answer": "a2"}\n',
|
||||
)
|
||||
assert _load_dataset(path) == [
|
||||
{"question": "q1", "answer": "a1"},
|
||||
{"question": "q2", "answer": "a2"},
|
||||
]
|
||||
|
||||
|
||||
def test_load_json_array(tmp_path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"eval.json",
|
||||
json.dumps([{"question": "x", "answer": "y"}]),
|
||||
)
|
||||
assert _load_dataset(path) == [{"question": "x", "answer": "y"}]
|
||||
|
||||
|
||||
def test_load_jsonl_with_bom_and_embedded_array(tmp_path) -> None:
|
||||
"""jsonl 带 BOM 且单行内嵌 JSON 数组,都应正常加载。"""
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"eval.jsonl",
|
||||
"" + json.dumps([{"question": "a", "answer": "b"}, {"question": "c", "answer": "d"}]),
|
||||
)
|
||||
assert len(_load_dataset(path)) == 2
|
||||
63
compute/tests/test_file_download_security.py
Normal file
63
compute/tests/test_file_download_security.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""compute ``download_file`` 端点安全回归测试。
|
||||
|
||||
修复前 ``file_id`` 直接拼进 glob 模式且不校验路径包含关系,可通过 ``../``
|
||||
穿越出 upload 目录,并在 Linux 上跟随符号链接读取任意文件。
|
||||
修复后:file_id 仅允许字母/数字/下划线/连字符,返回前对解析后的路径
|
||||
做 upload 根目录包含性校验。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _make_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setenv("TRAINING_LOG_ROOT", str(tmp_path / "logs"))
|
||||
monkeypatch.setenv("YG_FT_DATA_ROOT", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("COMPUTE_EXECUTION_MODE", "simulator")
|
||||
monkeypatch.setenv("COMPUTE_AUTH_ENABLED", "false")
|
||||
monkeypatch.delenv("ENABLE_DOCS", raising=False)
|
||||
from compute.api.main import create_app
|
||||
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def _upload_root(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "data" / "uploads"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def test_download_legit_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_client(tmp_path, monkeypatch)
|
||||
(_upload_root(tmp_path) / "file_123456_hello.txt").write_text("HELLO-DOWNLOAD", encoding="utf-8")
|
||||
response = client.get("/modelTF/compute/files/file_123456/download")
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"HELLO-DOWNLOAD"
|
||||
|
||||
|
||||
def test_download_rejects_traversal_file_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_client(tmp_path, monkeypatch)
|
||||
outside = tmp_path / "secret" / "passwd_1.txt"
|
||||
outside.parent.mkdir(parents=True, exist_ok=True)
|
||||
outside.write_text("TOP-SECRET", encoding="utf-8")
|
||||
for file_id in ["..", "file.123", "..%2F..%2Fsecret%2Fpasswd", "file%20name"]:
|
||||
response = client.get(f"/modelTF/compute/files/{file_id}/download")
|
||||
assert response.status_code in (400, 404), f"file_id={file_id!r} -> {response.status_code}"
|
||||
assert b"TOP-SECRET" not in response.content
|
||||
|
||||
|
||||
def test_download_blocks_symlink_escape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _make_client(tmp_path, monkeypatch)
|
||||
upload_root = _upload_root(tmp_path)
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("TOP-SECRET", encoding="utf-8")
|
||||
try:
|
||||
(upload_root / "file_999999_link.txt").symlink_to(outside)
|
||||
except OSError:
|
||||
pytest.skip("symlink creation not permitted on this platform")
|
||||
response = client.get("/modelTF/compute/files/file_999999/download")
|
||||
assert response.status_code == 404
|
||||
assert b"TOP-SECRET" not in response.content
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from compute.engines.llama_factory.adapter import build_command
|
||||
import json
|
||||
|
||||
from compute.engines.llama_factory.adapter import _validate_dataset_columns, build_command
|
||||
|
||||
|
||||
def test_build_command_uses_explicit_validation_dataset_without_resplitting() -> None:
|
||||
@@ -21,3 +23,70 @@ def test_build_command_uses_explicit_validation_dataset_without_resplitting() ->
|
||||
)
|
||||
assert "--do_eval" in result.command
|
||||
assert "--val_size" not in result.command
|
||||
|
||||
|
||||
def _write(tmp_path, name: str, lines: list[dict]) -> object:
|
||||
path = tmp_path / name
|
||||
path.write_text(
|
||||
"".join(json.dumps(line, ensure_ascii=False) + "\n" for line in lines),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_jsonl_alpaca_without_input_column_passes_validation(tmp_path) -> None:
|
||||
"""纯 jsonl Alpaca 数据缺省 input 字段(常见),不应被校验拦截。"""
|
||||
_write(tmp_path, "train.jsonl", [{"instruction": "hi", "output": "hello"}])
|
||||
errors = _validate_dataset_columns(
|
||||
{
|
||||
"dataset_dir": str(tmp_path),
|
||||
"dataset_info": {
|
||||
"ygft_a": {
|
||||
"file_name": "train.jsonl",
|
||||
"formatting": "alpaca",
|
||||
"columns": {"prompt": "instruction", "query": "input", "response": "output"},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_jsonl_sharegpt_passes_validation(tmp_path) -> None:
|
||||
"""ShareGPT 格式 jsonl(messages)应通过校验。"""
|
||||
_write(
|
||||
tmp_path,
|
||||
"msg.jsonl",
|
||||
[{"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]}],
|
||||
)
|
||||
errors = _validate_dataset_columns(
|
||||
{
|
||||
"dataset_dir": str(tmp_path),
|
||||
"dataset_info": {
|
||||
"ygft_m": {
|
||||
"file_name": "msg.jsonl",
|
||||
"formatting": "sharegpt",
|
||||
"columns": {"messages": "messages"},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_jsonl_missing_response_still_rejected(tmp_path) -> None:
|
||||
"""缺 output(response)仍应报错——没有答案无法做有监督微调。"""
|
||||
_write(tmp_path, "train.jsonl", [{"instruction": "hi"}])
|
||||
errors = _validate_dataset_columns(
|
||||
{
|
||||
"dataset_dir": str(tmp_path),
|
||||
"dataset_info": {
|
||||
"ygft_a": {
|
||||
"file_name": "train.jsonl",
|
||||
"formatting": "alpaca",
|
||||
"columns": {"prompt": "instruction", "query": "input", "response": "output"},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert errors and "output" in errors[0]
|
||||
|
||||
40
compute/tests/test_security.py
Normal file
40
compute/tests/test_security.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""计算节点文档路由(/docs、/redoc、/openapi.json)安全开关测试。
|
||||
|
||||
生产默认(COMPUTE_AUTH_ENABLED=true)关闭文档路由,避免未授权泄露 API 结构;
|
||||
显式配置 ENABLE_DOCS 可覆盖默认行为。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from compute.api.security import docs_enabled, docs_kwargs
|
||||
|
||||
|
||||
def test_docs_disabled_when_auth_enabled(monkeypatch) -> None:
|
||||
monkeypatch.delenv("ENABLE_DOCS", raising=False)
|
||||
monkeypatch.setenv("COMPUTE_AUTH_ENABLED", "true")
|
||||
assert docs_enabled() is False
|
||||
assert docs_kwargs() == {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
|
||||
|
||||
def test_docs_enabled_when_auth_disabled(monkeypatch) -> None:
|
||||
monkeypatch.delenv("ENABLE_DOCS", raising=False)
|
||||
monkeypatch.setenv("COMPUTE_AUTH_ENABLED", "false")
|
||||
assert docs_enabled() is True
|
||||
assert docs_kwargs() == {}
|
||||
|
||||
|
||||
def test_docs_env_override_enables_with_auth(monkeypatch) -> None:
|
||||
monkeypatch.setenv("ENABLE_DOCS", "true")
|
||||
monkeypatch.setenv("COMPUTE_AUTH_ENABLED", "true")
|
||||
assert docs_enabled() is True
|
||||
|
||||
|
||||
def test_docs_env_override_disables_without_auth(monkeypatch) -> None:
|
||||
monkeypatch.setenv("ENABLE_DOCS", "false")
|
||||
monkeypatch.setenv("COMPUTE_AUTH_ENABLED", "false")
|
||||
assert docs_enabled() is False
|
||||
|
||||
|
||||
def test_docs_default_when_auth_env_missing(monkeypatch) -> None:
|
||||
monkeypatch.delenv("ENABLE_DOCS", raising=False)
|
||||
monkeypatch.delenv("COMPUTE_AUTH_ENABLED", raising=False)
|
||||
assert docs_enabled() is False
|
||||
Reference in New Issue
Block a user