Bind svc user CLI to instance manifest and reject flock bypass #429
File diff suppressed because one or more lines are too long
@@ -1,10 +1,10 @@
|
||||
# U1 实例—账户绑定补齐(规格 v0.1)
|
||||
|
||||
状态:设计候选,随主提案 r1 评审。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U1。
|
||||
状态:已启动实施(分支 `feature/svc-deploy-u1-account-binding`)。主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` §7 U1。
|
||||
前置:恢复方案 recovery U1 已合入 main(Dev #415)——`docs/recovery/u1-identity-manifest.md`、`scripts/svc_manifest.py`、启动身份校验与 `svc init`。
|
||||
本增量**不重做** manifest 层;只补齐账户操作与安装入口对既有身份合同的消费缺口。
|
||||
|
||||
基线(写作时 SELF):Dev `af9c2de`(含 recovery U1)。实施前以任务 `task-sync` 后 SHA 为准。业务实施 **NOT_RUN**。
|
||||
基线:任务卡 R0;实施中。真实 PG 绑定专项缺环境标 NOT_RUN。
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
# 草稿:feature/svc-deploy-u1-account-binding 任务卡
|
||||
# 草稿:feature/svc-deploy-u1-account-binding
|
||||
|
||||
状态:**待授权**。主提案批准并获实施授权后,在共享主检出执行:
|
||||
|
||||
```bash
|
||||
RECEIPT=/tmp/precheck-svc-deploy-u1.json CMD="make precheck" make evidence
|
||||
make task-start NAME=feature/svc-deploy-u1-account-binding PRECHECK_RECEIPT=/tmp/precheck-svc-deploy-u1.json
|
||||
```
|
||||
|
||||
将本草稿字段拷入新卡并删除本文件或改为「已启动」链接。
|
||||
|
||||
## 建议填写
|
||||
|
||||
- 主记录:`modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md` r1 §7 U1
|
||||
- 规格:`docs/svc-deploy/u1-instance-account-binding.md` v0.1
|
||||
- 目标:账户 CLI 绑定实例 manifest;OpenExisting;无 flock 拒绝冲突操作;不破坏 recovery U1
|
||||
- 非目标:U2 下载、U3 doctor、U4 Key 语义、U5 页面
|
||||
- 兄弟任务:可与 `feature/svc-deploy-u4-login-key` 共用同一 R0
|
||||
- 验收:规格 §6 T1–T5;治理回归;真实 PG 缺环境 NOT_RUN
|
||||
- 启动授权:未授权(须主提案阶段批准 + 明确实施授权)
|
||||
状态:**已启动** → 任务卡 `.task/feature-svc-deploy-u1-account-binding.md`;工作区
|
||||
`/home/weichen/projects/Agent-System-Dev-wt-feature-svc-deploy-u1-account-binding`。
|
||||
|
||||
+74
-6
@@ -892,18 +892,25 @@ port_owner() {
|
||||
|
||||
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
|
||||
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失时降级为无锁直通(尽力而为)。
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
|
||||
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
|
||||
SVC_LOCK_HELD=0
|
||||
svc_lock() {
|
||||
[ "$SVC_LOCK_HELD" = 1 ] && return 0
|
||||
command -v flock >/dev/null 2>&1 || { SVC_LOCK_HELD=1; return 0; }
|
||||
if ! command -v flock >/dev/null 2>&1; then
|
||||
c_err "[$ENV_NAME] 缺少 flock:拒绝生命周期/账户冲突操作(安装 util-linux 后重试)"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
|
||||
exec 9>>"$ENV_DIR/.start.lock" || return 1
|
||||
if [ "$1" = "-n" ]; then
|
||||
flock -n 9 || { exec 9>&-; return 1; }
|
||||
else
|
||||
flock -w 300 9 || c_warn "[$ENV_NAME] 生命周期锁等待超时(继续执行,注意并发)"
|
||||
if ! flock -w 300 9; then
|
||||
exec 9>&-
|
||||
c_err "[$ENV_NAME] 生命周期锁等待超时:拒绝继续(避免并发冲突)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
SVC_LOCK_HELD=1
|
||||
return 0
|
||||
@@ -1405,6 +1412,66 @@ materialize_release() {
|
||||
install_artifacts "$bin" "$art/plugins" || return 1
|
||||
}
|
||||
|
||||
# ---- agent:账户 CLI(绑定实例存储;禁止回落默认 ./data)----
|
||||
# 用法:scripts/svc.sh <env> user <create|list|...> [--owner …]
|
||||
# 从实例最终配置 / manifest identity 解析 data/DSN,强制 --open-existing,
|
||||
# 调用 ENV_BIN 对应版本。拒绝调用方覆盖 --data/--dsn/--open-existing。
|
||||
agent_do_user() {
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--data|--dsn|--open-existing|--data=*|--dsn=*|--open-existing=*)
|
||||
c_err "[$ENV_NAME] 账户操作禁止覆盖存储绑定(勿传 --data/--dsn/--open-existing;由实例 manifest 决定)"
|
||||
return 2 ;;
|
||||
esac
|
||||
done
|
||||
svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁(账户操作与启停互斥)"; return 1; }
|
||||
# 有 manifest 时以 identity.data_root 为权威,拒绝 ENV_DATA 漂移到默认 ./data。
|
||||
if [ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1; then
|
||||
local mf_root
|
||||
mf_root="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m["identity"]["data_root"])' \
|
||||
"$ENV_DIR/manifest.json" 2>/dev/null || true)"
|
||||
if [ -n "$mf_root" ]; then
|
||||
ENV_DATA="$mf_root"
|
||||
fi
|
||||
fi
|
||||
case "${ENV_DATA:-}" in
|
||||
""|./data|data)
|
||||
c_err "[$ENV_NAME] 未绑定数据目录:拒绝账户操作(不回落默认 ./data)"
|
||||
return 1 ;;
|
||||
esac
|
||||
resolve_db_config || {
|
||||
c_err "[$ENV_NAME] 数据库绑定无效:拒绝账户操作(不回落默认 ./data)"
|
||||
return 1
|
||||
}
|
||||
if [ "$DB_MODE" = mock ]; then
|
||||
c_err "[$ENV_NAME] mock 档不支持账户操作"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "${ENV_BIN:-}" ]; then
|
||||
c_err "[$ENV_NAME] 实例二进制不存在:$ENV_BIN(先 deploy)"
|
||||
return 1
|
||||
fi
|
||||
if [ "$DB_MODE" = pg ] && [ -z "${DB_DSN:-}" ] && [ "${DB_SOURCE:-}" != conffile ]; then
|
||||
c_err "[$ENV_NAME] PostgreSQL 档缺少 DSN:拒绝账户操作"
|
||||
return 1
|
||||
fi
|
||||
# 嵌入式档:数据文件必须已存在(OpenExisting);PG 档由 --open-existing 在后端核验。
|
||||
if [ "$DB_MODE" != pg ]; then
|
||||
if [ ! -d "$ENV_DATA" ] || [ ! -f "$ENV_DATA/agent-system.db" ]; then
|
||||
c_err "[$ENV_NAME] 既有库不存在:$ENV_DATA/agent-system.db(OpenExisting:拒绝创建空库)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
local bin_args=(--open-existing --data "$ENV_DATA")
|
||||
if [ "$DB_MODE" = pg ] && [ -n "${DB_DSN:-}" ]; then
|
||||
bin_args+=(--dsn "$DB_DSN")
|
||||
fi
|
||||
c_info "[$ENV_NAME] 账户 CLI → $ENV_BIN user $* (data=$ENV_DATA open-existing)"
|
||||
# 子命令参数在前,绑定 flags 在后(Go flag 不区分顺序)。
|
||||
AGENT_SYSTEM_DSN="${DB_DSN:-}" "$ENV_BIN" user "$@" "${bin_args[@]}"
|
||||
}
|
||||
|
||||
# ---- agent:部署与启动 ----
|
||||
|
||||
agent_do_deploy() {
|
||||
@@ -4124,18 +4191,19 @@ main() {
|
||||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||||
local action="deploy"
|
||||
case "${1:-}" in
|
||||
deploy|stop|start|restart|status|logs|db|config|backup|backups) action="$1"; shift ;;
|
||||
deploy|stop|start|restart|status|logs|db|config|init|user|backup|backups) action="$1"; shift ;;
|
||||
esac
|
||||
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
CLI_LOGS_N="$1"; shift
|
||||
fi
|
||||
# db / config / backup / backups 子命令:自带参数解析(位置参数不走 parse_args;
|
||||
# db / config / user / backup / backups:自带参数解析(位置参数不走 parse_args;
|
||||
# 未知 flags 由其自行拒绝)。
|
||||
if [ "$action" = db ] || [ "$action" = config ]; then
|
||||
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
|
||||
env_config "$ENV_NAME"
|
||||
case "$action" in
|
||||
db) svc_db_bind "$@" || exit $? ;;
|
||||
config) svc_config "$@" || exit $? ;;
|
||||
user) agent_do_user "$@" || exit $? ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -95,6 +95,13 @@ PYSRV
|
||||
child=$!
|
||||
wait "$child"
|
||||
;;
|
||||
user)
|
||||
printf 'user %s\n' "$*" >> "$trace_dir/cli.txt"
|
||||
case "${2:-}" in
|
||||
list) echo '[{"owner":"op","name":"op","isAdmin":true,"mustChangePassword":false}]' ;;
|
||||
create) echo '{"owner":"new","issued":true}' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
*)
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -156,10 +163,10 @@ class SvcAutoRecoverTests(unittest.TestCase):
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
def svc(self, *args, check=True):
|
||||
def svc(self, *args, check=True, env=None):
|
||||
proc = subprocess.run(
|
||||
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
|
||||
cwd=self.root, env=self.env, text=True,
|
||||
cwd=self.root, env=env if env is not None else self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
|
||||
if check:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""svc.sh 账户 CLI 实例绑定(U1 规格 §6 T1–T5)。
|
||||
|
||||
覆盖:合法 manifest 下 user list 命中 data_root+open-existing(T1)、禁止覆盖
|
||||
--data(T2)、删除 data 根后 OpenExisting 拒绝且不建空库(T3)、无 flock 拒绝
|
||||
user/deploy(T4)。T5 身份回归由 test_svc_identity 同发现执行;此处仅冒烟。
|
||||
桩与夹具复用 tests/test_svc_recover.py;manifest 辅助对齐 test_svc_identity。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
|
||||
tsr = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tsr)
|
||||
|
||||
|
||||
class UserBindingTests(tsr.SvcAutoRecoverTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
|
||||
self.root / "scripts" / "svc_manifest.py")
|
||||
self.trace.mkdir(exist_ok=True)
|
||||
|
||||
def manifest(self, name):
|
||||
p = self.runtime(name) / "manifest.json"
|
||||
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
|
||||
|
||||
def running(self, name):
|
||||
pid = self.pid_of(name)
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def touch_db(self, name):
|
||||
data = Path(self.manifest(name)["identity"]["data_root"])
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
(data / "agent-system.db").write_bytes(b"sqlite-stub")
|
||||
|
||||
def cli_trace(self):
|
||||
f = self.trace / "cli.txt"
|
||||
return f.read_text(encoding="utf-8") if f.exists() else ""
|
||||
|
||||
def clear_cli_trace(self):
|
||||
f = self.trace / "cli.txt"
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
|
||||
# ---- T1 合法 manifest:user list 绑定 data_root + --open-existing ----
|
||||
|
||||
def test_T1_user_list_binds_manifest_data(self):
|
||||
self.seed_agent("ub1", 9441)
|
||||
self.start_ok("ub1")
|
||||
self.svc("ub1", "stop")
|
||||
root = self.manifest("ub1")["identity"]["data_root"]
|
||||
self.touch_db("ub1")
|
||||
self.clear_cli_trace()
|
||||
result = self.svc("ub1", "user", "list")
|
||||
self.assertIn("op", result.stdout)
|
||||
trace = self.cli_trace()
|
||||
self.assertIn("user list", trace)
|
||||
self.assertIn("--open-existing", trace)
|
||||
self.assertIn("--data %s" % root, trace)
|
||||
self.assertNotIn("./data", trace)
|
||||
|
||||
# ---- T2 禁止覆盖 --data;未绑定默认目录拒绝 ----
|
||||
|
||||
def test_T2_reject_override_data_flag(self):
|
||||
self.seed_agent("ub2", 9442)
|
||||
self.start_ok("ub2")
|
||||
self.svc("ub2", "stop")
|
||||
self.touch_db("ub2")
|
||||
other = self.root / "evil-data"
|
||||
other.mkdir()
|
||||
(other / "agent-system.db").write_bytes(b"x")
|
||||
result = self.svc("ub2", "user", "list", "--data", str(other), check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(any(k in result.stdout for k in ("禁止覆盖", "勿传")), result.stdout)
|
||||
self.assertFalse((self.trace / "cli.txt").exists(), "拒绝后不得调用二进制")
|
||||
|
||||
def test_T2b_reject_literal_default_data(self):
|
||||
d = self.runtime("ub2b")
|
||||
(d / "bin").mkdir(parents=True)
|
||||
shutil.copy2(self.stub, d / "bin" / "agent-system")
|
||||
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
|
||||
("kind", "agent"), ("env", "ub2b"), ("pid", ""),
|
||||
("addr", "127.0.0.1:9443"), ("tags", ""),
|
||||
("source", "local@deadbee"), ("version", "stub"), ("started", ""),
|
||||
("pidcheck", str(d / "bin" / "agent-system")),
|
||||
("exec", "--addr 127.0.0.1:9443 --data ./data --plugins %s" % (d / "plugins")),
|
||||
]))
|
||||
env = dict(self.env, SVC_UB2B_DATA="./data")
|
||||
result = self.svc("ub2b", "user", "list", check=False, env=env)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(any(k in result.stdout for k in ("未绑定数据目录", "不回落")),
|
||||
result.stdout)
|
||||
|
||||
# ---- T3 删除 data 根:OpenExisting 失败,不建空库 ----
|
||||
|
||||
def test_T3_missing_data_root_rejected(self):
|
||||
self.seed_agent("ub3", 9444)
|
||||
self.start_ok("ub3")
|
||||
self.svc("ub3", "stop")
|
||||
root = Path(self.manifest("ub3")["identity"]["data_root"])
|
||||
self.touch_db("ub3")
|
||||
shutil.rmtree(root)
|
||||
self.assertFalse(root.exists())
|
||||
result = self.svc("ub3", "user", "create", "--owner", "x", "--name", "x",
|
||||
check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(any(k in result.stdout for k in ("既有库不存在", "OpenExisting")),
|
||||
result.stdout)
|
||||
self.assertFalse(root.exists(), "拒绝后不得重建数据根")
|
||||
self.assertFalse((root / "agent-system.db").exists())
|
||||
|
||||
# ---- T4 隐藏 flock:user/deploy 拒绝;已运行服务仍在 ----
|
||||
|
||||
def test_T4_no_flock_rejects_user_and_deploy(self):
|
||||
self.seed_agent("ub4", 9445)
|
||||
self.start_ok("ub4")
|
||||
self.assertTrue(self.running("ub4"))
|
||||
self.touch_db("ub4")
|
||||
# 构造不含 flock 的 PATH:链入常用外部命令,刻意排除 flock。
|
||||
bin_dir = self.root / "nobin"
|
||||
bin_dir.mkdir()
|
||||
for name in (
|
||||
"bash", "python3", "python", "dirname", "basename", "tr", "cksum",
|
||||
"cut", "mkdir", "cat", "sed", "grep", "head", "rm", "mv", "cp",
|
||||
"ls", "chmod", "uname", "date", "sleep", "kill", "ps", "awk",
|
||||
"sort", "uniq", "env", "printf", "realpath", "readlink", "stat",
|
||||
"tee", "wc", "find", "xargs", "touch", "ln", "mktemp", "id",
|
||||
"git", "make", "tar", "gzip", "which", "command",
|
||||
):
|
||||
src = shutil.which(name)
|
||||
if src:
|
||||
dest = bin_dir / name
|
||||
if not dest.exists():
|
||||
dest.symlink_to(src)
|
||||
self.assertIsNone(shutil.which("flock", path=str(bin_dir)))
|
||||
env = dict(self.env, PATH=str(bin_dir))
|
||||
r_user = self.svc("ub4", "user", "list", check=False, env=env)
|
||||
self.assertNotEqual(r_user.returncode, 0)
|
||||
self.assertIn("缺少 flock", r_user.stdout)
|
||||
r_dep = self.svc("ub4", "deploy", "--dry-run", check=False, env=env)
|
||||
self.assertNotEqual(r_dep.returncode, 0, r_dep.stdout)
|
||||
self.assertIn("缺少 flock", r_dep.stdout)
|
||||
self.assertTrue(self.running("ub4"), "拒绝后旧服务应仍在跑")
|
||||
self.svc("ub4", "stop")
|
||||
|
||||
# ---- T5 recovery U1 身份冒烟(完整套件见 test_svc_identity)----
|
||||
|
||||
def test_T5_identity_regression_smoke(self):
|
||||
self.seed_agent("ub5", 9446)
|
||||
self.start_ok("ub5")
|
||||
iid = self.manifest("ub5")["instance_id"]
|
||||
self.svc("ub5", "stop")
|
||||
self.start_ok("ub5")
|
||||
self.assertEqual(self.manifest("ub5")["instance_id"], iid)
|
||||
self.svc("ub5", "stop")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user