Why: deploy needs to choose registered plugins and ship them inside one agent-system, then refuse the release when they are not active. Evidence: python3 -m unittest tests.test_svc_plugins tests.test_svc_user_help tests.test_svc tests.test_svc_u2 (SELF, 63 passed, 1 skipped). Real compose build and make test-lifecycle are NOT_RUN. Impact: --with is mutually exclusive with --from rdm and --from release. Existing non-interactive deploys stay without plugins unless plugins= is already recorded. Changing the plugin id set on a live service requires --allow-source-switch. Refs: .task/feature-svc-plugin-compose.md modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md Co-authored-by: Cursor <cursoragent@cursor.com>
298 lines
12 KiB
Python
298 lines
12 KiB
Python
"""svc.sh --with 编译插件组合的桩回归。
|
|
|
|
用临时 git 仓和桩二进制覆盖:登记校验、dry-run、与 rdm/release 互斥、
|
|
首次部署、非交互复用、换集合门禁、交互勾选,以及激活校验里“命令被禁用则启用”。
|
|
SVC_COMPOSE_STUB=1 不执行 go build。真实组合构建与 make test-lifecycle 不在这里。
|
|
"""
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
SOURCE = Path(__file__).resolve().parents[1]
|
|
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
|
|
|
|
STUB = r"""#!/usr/bin/env bash
|
|
set -u
|
|
trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
|
|
mkdir -p "$trace_dir"
|
|
case "${1:-}" in
|
|
version)
|
|
if [ "${2:-}" = "--json" ]; then
|
|
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
|
|
exit 0
|
|
fi
|
|
echo "stub-agent-system 1.0"
|
|
exit 0 ;;
|
|
serve)
|
|
shift
|
|
addr=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
|
|
done
|
|
port="${addr##*:}"; host="${addr%:*}"
|
|
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
|
|
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
|
|
import json, os, sys, threading, time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
|
|
class H(BaseHTTPRequestHandler):
|
|
def _send(self, body):
|
|
raw = body.encode() if isinstance(body, str) else body
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
def do_GET(self):
|
|
path = self.path.split('?', 1)[0]
|
|
if path == '/api/plugins':
|
|
self._send(json.dumps([{"manifest": {"id": "demo"}, "backend": {"mode": "inproc"}}]))
|
|
return
|
|
if path == '/api/registry':
|
|
self._send(json.dumps({"compiled": ["demo"]}))
|
|
return
|
|
if path == '/api/commands':
|
|
enabled = os.path.exists(os.path.join(trace, 'plugin-enabled'))
|
|
self._send(json.dumps([{"id": "demo.ping", "enabled": enabled}]))
|
|
return
|
|
self._send('{"ok":true}')
|
|
def do_HEAD(self):
|
|
self._send(b'')
|
|
def do_POST(self):
|
|
path = self.path.split('?', 1)[0]
|
|
n = int(self.headers.get('Content-Length') or 0)
|
|
self.rfile.read(n)
|
|
if path.endswith('/enabled'):
|
|
open(os.path.join(trace, 'plugin-enabled'), 'w').close()
|
|
open(os.path.join(trace, 'enable-post'), 'w').write(path + '\n')
|
|
self._send('{"ok":true}')
|
|
def log_message(self, *args):
|
|
pass
|
|
ppid = os.getppid()
|
|
def watch():
|
|
while True:
|
|
try:
|
|
if os.getppid() != ppid:
|
|
os._exit(0)
|
|
except Exception:
|
|
os._exit(0)
|
|
time.sleep(0.3)
|
|
threading.Thread(target=watch, daemon=True).start()
|
|
try:
|
|
HTTPServer((host, port), H).serve_forever()
|
|
except Exception:
|
|
os._exit(1)
|
|
PYSRV
|
|
child=$!
|
|
wait "$child"
|
|
;;
|
|
*)
|
|
exit 0 ;;
|
|
esac
|
|
"""
|
|
|
|
|
|
class PluginComposeTests(unittest.TestCase):
|
|
def setUp(self):
|
|
tmp = tempfile.mkdtemp(prefix="svc-plugins-")
|
|
self.addCleanup(shutil.rmtree, tmp, True)
|
|
self.root = Path(tmp)
|
|
(self.root / "scripts").mkdir(parents=True)
|
|
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
|
|
self.trace = self.root / "trace"
|
|
self.stub = self.root / "stub-agent-system"
|
|
self.stub.write_text(STUB)
|
|
self.stub.chmod(0o755)
|
|
(self.root / "plugins").mkdir()
|
|
(self.root / "Makefile").write_text(
|
|
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % self.stub)
|
|
self.plugin = Path(tempfile.mkdtemp(prefix="svc-plugin-src-"))
|
|
self.addCleanup(shutil.rmtree, self.plugin, True)
|
|
self.cache = self.root / "plugin-cache"
|
|
self.rdm_cache = self.root / "rdm-cache"
|
|
self.env = os.environ.copy()
|
|
self.env.update({
|
|
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
|
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
|
|
"GIT_AUTHOR_NAME": "svc plugins", "GIT_COMMITTER_NAME": "svc plugins",
|
|
"GIT_AUTHOR_EMAIL": "svc-plugins@example.invalid",
|
|
"GIT_COMMITTER_EMAIL": "svc-plugins@example.invalid",
|
|
"SVC_STUB_TRACE": str(self.trace),
|
|
"SVC_SRC_DIR": str(self.root / "srccache"),
|
|
"SVC_COMPOSE_STUB": "1",
|
|
"SVC_PLUGIN_SRC_ROOT": str(self.cache),
|
|
"SVC_RDM_SRC_DIR": str(self.rdm_cache),
|
|
"SVC_DEMO_ORIGIN": self.plugin.resolve().as_uri(),
|
|
})
|
|
for key in list(self.env):
|
|
if key.startswith("SVC_") and key not in (
|
|
"SVC_STUB_TRACE", "SVC_SRC_DIR", "SVC_COMPOSE_STUB",
|
|
"SVC_PLUGIN_SRC_ROOT", "SVC_RDM_SRC_DIR", "SVC_DEMO_ORIGIN",
|
|
):
|
|
self.env.pop(key)
|
|
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
|
self.env.pop(key, None)
|
|
self.git(self.plugin, "init", "--initial-branch=main")
|
|
(self.plugin / "README").write_text("demo\n")
|
|
self.git(self.plugin, "add", "README")
|
|
self.git(self.plugin, "commit", "-m", "demo plugin")
|
|
self.plugin_sha = self.git(self.plugin, "rev-parse", "HEAD").stdout.strip()
|
|
self.write_registry([self.plugin_entry()])
|
|
self.git(self.root, "init", "--initial-branch=main", ".")
|
|
self.git(self.root, "add", ".")
|
|
self.git(self.root, "commit", "-m", "fixture")
|
|
|
|
def plugin_entry(self, plugin_id="demo", web=""):
|
|
entry = {
|
|
"id": plugin_id,
|
|
"name": plugin_id,
|
|
"repo": self.plugin.resolve().as_uri(),
|
|
"repoEnv": "SVC_DEMO_ORIGIN",
|
|
"defaultRef": "origin/main",
|
|
"goModule": "example.com/" + plugin_id,
|
|
"entryPackage": "example.com/%s/plugin" % plugin_id,
|
|
"commandPrefix": plugin_id + ".",
|
|
}
|
|
if web:
|
|
entry["web"] = web
|
|
return entry
|
|
|
|
def write_registry(self, plugins):
|
|
import json
|
|
(self.root / "plugins" / "registry.json").write_text(
|
|
json.dumps({"plugins": plugins}, ensure_ascii=False, indent=2) + "\n")
|
|
|
|
def git(self, cwd, *args):
|
|
proc = subprocess.run(
|
|
["git", *args], cwd=cwd, env=self.env, text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
|
|
self.assertEqual(proc.returncode, 0, proc.stdout)
|
|
return proc
|
|
|
|
def tearDown(self):
|
|
runtime = self.root / "runtime"
|
|
if not runtime.is_dir():
|
|
return
|
|
for d in sorted(runtime.iterdir()):
|
|
if (d / "svc.pid").exists():
|
|
subprocess.run(
|
|
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
|
|
cwd=self.root, env=self.env, timeout=60,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
def svc(self, *args, check=True, env=None, input_text=None):
|
|
proc = subprocess.run(
|
|
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
|
|
cwd=self.root, env=env if env is not None else self.env, text=True,
|
|
input=input_text, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
timeout=120)
|
|
if check:
|
|
self.assertEqual(proc.returncode, 0, proc.stdout)
|
|
return proc
|
|
|
|
def kv(self, name, key):
|
|
envfile = self.root / "runtime" / name / "svc.env"
|
|
if not envfile.exists():
|
|
return None
|
|
for line in envfile.read_text().splitlines():
|
|
if line.startswith(key + "="):
|
|
return line[len(key) + 1:]
|
|
return None
|
|
|
|
def pid_alive(self, name):
|
|
pidf = self.root / "runtime" / name / "svc.pid"
|
|
if not pidf.exists():
|
|
return False
|
|
try:
|
|
os.kill(int(pidf.read_text().strip()), 0)
|
|
return True
|
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
return False
|
|
|
|
def test_help_plugins_and_rejects_with_off_deploy(self):
|
|
help_out = self.svc("help", "plugins")
|
|
self.assertIn("registry.json", help_out.stdout)
|
|
self.assertIn("--with", help_out.stdout)
|
|
self.assertIn("allow-source-switch", help_out.stdout)
|
|
rejected = self.svc("pdemo", "start", "--with", "demo", check=False)
|
|
self.assertEqual(rejected.returncode, 2, rejected.stdout)
|
|
self.assertIn("仅用于 deploy", rejected.stdout)
|
|
|
|
def test_dry_run_prints_plugin_sha_without_listening(self):
|
|
result = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
|
|
"--port", "18981", "--dry-run")
|
|
self.assertIn(self.plugin_sha, result.stdout)
|
|
self.assertIn("插件:", result.stdout)
|
|
self.assertFalse((self.root / "runtime" / "pdemo" / "svc.pid").exists())
|
|
|
|
def test_unknown_plugin_and_two_web_and_rdm_are_rejected(self):
|
|
unknown = self.svc("pdemo", "deploy", "--from", "local", "--with", "nope",
|
|
"--dry-run", check=False)
|
|
self.assertNotEqual(unknown.returncode, 0)
|
|
self.assertIn("没有插件", unknown.stdout)
|
|
|
|
self.write_registry([
|
|
self.plugin_entry("a", web="web"),
|
|
self.plugin_entry("b", web="web"),
|
|
])
|
|
two = self.svc("pdemo", "deploy", "--from", "local", "--with", "a,b",
|
|
"--dry-run", check=False)
|
|
self.assertNotEqual(two.returncode, 0)
|
|
self.assertIn("web", two.stdout)
|
|
|
|
rdm = self.svc("pdemo", "deploy", "--from", "rdm", "--with", "demo",
|
|
"--dry-run", check=False)
|
|
self.assertNotEqual(rdm.returncode, 0)
|
|
self.assertIn("不能与 --with", rdm.stdout)
|
|
self.assertFalse(self.rdm_cache.exists())
|
|
|
|
def test_deploy_reuses_selection_and_gates_clearing(self):
|
|
first = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
|
|
"--port", "18981")
|
|
self.assertIn("插件已激活", first.stdout)
|
|
self.assertTrue((self.trace / "enable-post").exists())
|
|
plugins = self.kv("pdemo", "plugins")
|
|
self.assertTrue(plugins and plugins.startswith("demo@" + self.plugin_sha), plugins)
|
|
source = self.kv("pdemo", "source")
|
|
self.assertIn("+demo@", source)
|
|
release = (self.root / "runtime" / "pdemo" / "current" / "release.json").read_text()
|
|
self.assertIn(self.plugin_sha, release)
|
|
self.assertTrue(self.pid_alive("pdemo"))
|
|
|
|
again = self.svc("pdemo", "deploy", "--from", "local", "--port", "18981")
|
|
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
|
|
self.assertIn("+demo@", self.kv("pdemo", "source"))
|
|
self.assertIn("插件已激活", again.stdout)
|
|
pid = int((self.root / "runtime" / "pdemo" / "svc.pid").read_text())
|
|
|
|
blocked = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
|
|
"--port", "18981", check=False)
|
|
self.assertNotEqual(blocked.returncode, 0, blocked.stdout)
|
|
self.assertIn("allow-source-switch", blocked.stdout)
|
|
self.assertTrue(self.pid_alive("pdemo"))
|
|
self.assertEqual(int((self.root / "runtime" / "pdemo" / "svc.pid").read_text()), pid)
|
|
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
|
|
|
|
cleared = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
|
|
"--allow-source-switch", "--port", "18981")
|
|
self.assertIsNone(self.kv("pdemo", "plugins"))
|
|
self.assertNotIn("+demo@", self.kv("pdemo", "source"))
|
|
self.assertIn("插件集合已确认", cleared.stdout)
|
|
self.svc("pdemo", "stop")
|
|
|
|
def test_prompt_selects_numbered_plugin_on_first_deploy(self):
|
|
env = dict(self.env, SVC_FORCE_PLUGIN_PROMPT="1")
|
|
result = self.svc("pprompt", "deploy", "--from", "local", "--port", "18982",
|
|
env=env, input_text="1\n")
|
|
self.assertIn("插件已激活", result.stdout)
|
|
plugins = self.kv("pprompt", "plugins")
|
|
self.assertTrue(plugins and plugins.startswith("demo@"), plugins)
|
|
self.svc("pprompt", "stop")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|