"""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()