From 7934435475450cb36bfc26130fdcf83756cd59b5 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 21 Sep 2026 08:47:21 +0800 Subject: [PATCH] remove browser version management --- .env.example | 1 - README.md | 7 +- browser_gateway/runtime.py | 33 +- browser_gateway/server/http.py | 48 +- browser_gateway/test_gateway.py | 21 +- browser_gateway/test_runtime.py | 13 +- deploy/browser-gateway.env.example | 1 - docs/architecture/container-control.md | 17 +- docs/deployment.md | 12 +- docs/e2e-test-plan.md | 99 +- ...ve-control-plane-real-douyin-2026-09-18.md | 2 +- docs/evidence/tanstack-start-build-output.log | 273 ++-- .../tanstack-start-build-warning-baseline.log | 280 ++-- docs/native-browser-change-review.md | 6 +- docs/native-browser-implementation-plan.md | 12 +- docs/native-browser-verification.md | 6 +- internal/account/store.go | 11 +- internal/controlplane/api/account_deletion.go | 6 +- .../controlplane/api/app_migrated_test.go | 107 -- internal/controlplane/api/creator.go | 20 +- .../controlplane/api/creator_login_test.go | 45 - internal/controlplane/api/environments.go | 322 +--- .../api/gateway_browser_unit_test.go | 2 +- .../controlplane/api/hub_native_unit_test.go | 68 - internal/controlplane/api/hub_test.go | 1322 +---------------- internal/controlplane/api/system/system.go | 2 +- internal/controlplane/app/app.go | 2 +- internal/controlplane/app/app_test.go | 5 - internal/environment/environment.go | 33 +- .../legacy_browser_version_test.go | 80 + .../034_gateway_browser_default.sql | 9 + internal/environment/store.go | 196 +-- web/src/app/Layout.jsx | 42 +- web/src/app/Layout.test.jsx | 31 + web/src/components/ui/alert.tsx | 6 +- web/src/features/accounts/AccountEditPage.jsx | 2 +- .../features/accounts/AccountsPage.test.jsx | 11 +- .../features/creator/CreatorPages.test.jsx | 8 +- .../environments/BrowserVersionsPage.jsx | 366 ----- .../environments/BrowserVersionsPage.test.jsx | 175 --- .../features/environments/BrowsersPage.jsx | 100 +- .../environments/BrowsersPage.test.jsx | 54 +- .../features/table-action-alignment.test.js | 1 - web/src/routes/_app/browser-versions.tsx | 4 - web/src/shared/api/dataProvider.js | 18 +- web/src/shared/api/dataProvider.test.js | 18 - web/src/shared/ui/ui.jsx | 25 +- web/tests/responsive.e2e.js | 7 +- 48 files changed, 713 insertions(+), 3216 deletions(-) create mode 100644 internal/environment/legacy_browser_version_test.go create mode 100644 internal/environment/migrations/034_gateway_browser_default.sql delete mode 100644 web/src/features/environments/BrowserVersionsPage.jsx delete mode 100644 web/src/features/environments/BrowserVersionsPage.test.jsx delete mode 100644 web/src/routes/_app/browser-versions.tsx diff --git a/.env.example b/.env.example index 17407ec..1d2937d 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,6 @@ NATIVE_GATEWAY_ENDPOINT=http://127.0.0.1:28187 GATEWAY_TOKEN=dev-creatorhub-gateway-token BROWSER_STATE_DIR=~/.local/state/creatorhub/browser-gateway BROWSER_PROFILE_ROOT=~/.local/share/creatorhub/browser-profiles -BROWSER_VERSION=148.0.7778.215 BROWSER_PATH=~/.local/share/creatorhub/browsers/fingerprint-chromium/148.0.7778.215/chrome NODE_NAME= CONTROL_PLANE_USERNAME= diff --git a/README.md b/README.md index bd894e5..698e80b 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,9 @@ docker compose logs --tail=200 creator-hub postgres 打开 `http://127.0.0.1:${CREATORHUB_PORT:-8080}` 登录。首次配置: 1. 在「网关管理」注册 gateway。control-plane 在 Compose 中运行时使用 `http://host.docker.internal:8081`;裸机运行 control-plane 时使用 `http://127.0.0.1:8081`。令牌必须与 gateway 配置一致。 -2. 在「浏览器版本」登记宿主机上实际存在的浏览器版本和绝对路径。 -3. 创建抖音账号和运行环境,显式启动环境。 -4. 在账号页显示登录二维码,完成登录后核验浏览器身份。 -5. 按验证文档手工完成抖音采集、结果保存、停止、再启动和资源释放检查。 +2. 创建抖音账号和运行环境,显式启动环境;浏览器由 gateway 使用宿主机配置的默认运行时。 +3. 在账号页显示登录二维码,完成登录后核验浏览器身份。 +4. 按验证文档手工完成抖音采集、结果保存、停止、再启动和资源释放检查。 已有数据的重启必须复用原控制面主密钥、gateway 令牌、Profile 根目录和素材目录。不要使用 `docker compose down -v`,也不要删除 gateway 的 Profile 根目录。 diff --git a/browser_gateway/runtime.py b/browser_gateway/runtime.py index 0194044..43d7b0d 100644 --- a/browser_gateway/runtime.py +++ b/browser_gateway/runtime.py @@ -31,7 +31,6 @@ RUNTIME_ID_RE = re.compile(r"^[a-f0-9]{64}$") ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$") NETWORK_ID_RE = re.compile(r"^native-[a-f0-9]{32}$") PROFILE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$") -BROWSER_VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+){2,3}$") RUNTIME_CLEANUP_SENTINEL = "runtime-not-found" DEFAULT_CLEANUP_TIMEOUT = 30.0 @@ -375,7 +374,6 @@ class RuntimeRecord: binding_version: int profile_id: str profile_dir: str - browser_version: str browser_path: str command: list[str] network_exit_id: str = "" @@ -411,7 +409,6 @@ class RuntimeRecord: "binding_version", "profile_id", "profile_dir", - "browser_version", "browser_path", "command", "state", @@ -431,7 +428,6 @@ class RuntimeRecord: binding_version=value["binding_version"], profile_id=value["profile_id"], profile_dir=value["profile_dir"], - browser_version=value["browser_version"], browser_path=value["browser_path"], command=command, network_exit_id=value.get("network_exit_id", ""), @@ -466,8 +462,8 @@ class RuntimeRecord: or record.binding_version < 1 or not isinstance(record.profile_id, str) or not PROFILE_ID_RE.fullmatch(record.profile_id) - or not isinstance(record.browser_version, str) - or not BROWSER_VERSION_RE.fullmatch(record.browser_version) + or not isinstance(record.browser_path, str) + or not os.path.isabs(record.browser_path) or not isinstance(record.state, str) or not isinstance(record.cleanup_state, str) ): @@ -482,7 +478,7 @@ class NativeRuntimeManager: state_dir: str | Path, profile_root: str | Path, node_id: str, - browser_versions: Mapping[str, str], + browser_path: str, unit_manager: UnitManager | None = None, proxy_registry: ProxyRegistry | None = None, cleanup_timeout: float = DEFAULT_CLEANUP_TIMEOUT, @@ -503,10 +499,7 @@ class NativeRuntimeManager: self.state_dir = Path(state_dir).expanduser().resolve() self.profile_root = Path(profile_root).expanduser().resolve() self.node_id = node_id - self.browser_versions = { - version: str(Path(path).expanduser().resolve()) - for version, path in browser_versions.items() - } + self.browser_path = str(Path(browser_path).expanduser().resolve()) self.cleanup_timeout = cleanup_timeout self.ready_timeout = ready_timeout self.display_start = display_start @@ -593,19 +586,15 @@ class NativeRuntimeManager: network_id = "native-" + __import__("secrets").token_hex(16) profile_id = value.get("profile_id", alias) profile_dir = self._profile_dir(profile_id) - browser_path = self.browser_versions.get(value["browser_version"]) - if not browser_path: - raise BrowserRuntimeError("browser version is unavailable", 422) + browser_path = self.browser_path try: available = os.path.isfile(browser_path) and os.access( browser_path, os.X_OK ) except (OSError, TypeError) as exc: - raise BrowserRuntimeError( - "browser version is unavailable", 422 - ) from exc + raise BrowserRuntimeError("browser executable is unavailable", 422) from exc if not available: - raise BrowserRuntimeError("browser version is unavailable", 422) + raise BrowserRuntimeError("browser executable is unavailable", 422) self._check_capacity() now = self.clock() record = RuntimeRecord( @@ -616,7 +605,6 @@ class NativeRuntimeManager: binding_version=value["binding_version"], profile_id=profile_id, profile_dir=str(profile_dir), - browser_version=value["browser_version"], browser_path=browser_path, command=list(value["cmd"]), network_exit_id=value.get("network_exit_id", ""), @@ -1262,8 +1250,7 @@ class NativeRuntimeManager: "network_id": record.network_id, "node_id": record.node_id, "owner": record.owner, - "browser_version": record.browser_version, - "proxy_ready": proxy_ready, + "proxy_ready": proxy_ready, "cleanup_state": record.cleanup_state, "cleanup_error": record.cleanup_error, "display": record.display, @@ -1386,7 +1373,6 @@ def validate_runtime_input(value: Mapping[str, Any]) -> None: allowed = { "alias", "name", - "browser_version", "profile_id", "cmd", "binding_version", @@ -1398,7 +1384,6 @@ def validate_runtime_input(value: Mapping[str, Any]) -> None: raise BrowserRuntimeError("body contains unknown fields", 400) alias = value.get("alias", "") name = value.get("name", "") - version = value.get("browser_version", "") profile_id = value.get("profile_id", alias) command = value.get("cmd") binding = value.get("binding_version") @@ -1408,8 +1393,6 @@ def validate_runtime_input(value: Mapping[str, Any]) -> None: raise BrowserRuntimeError("alias must match [a-z0-9][a-z0-9-]{0,31}", 400) if not isinstance(name, str) or not 1 <= len(name) <= 64 or has_control(name): raise BrowserRuntimeError("name must be 1..64 visible characters", 400) - if not isinstance(version, str) or not BROWSER_VERSION_RE.fullmatch(version): - raise BrowserRuntimeError("browser_version is invalid", 400) if not isinstance(profile_id, str) or not PROFILE_ID_RE.fullmatch(profile_id): raise BrowserRuntimeError("profile_id is invalid", 400) if type(binding) is not int or binding < 1: diff --git a/browser_gateway/server/http.py b/browser_gateway/server/http.py index 260ac05..4f5b00a 100644 --- a/browser_gateway/server/http.py +++ b/browser_gateway/server/http.py @@ -36,7 +36,6 @@ from ..platform.douyin import ( from ..platform.xiaohongshu import XiaohongshuBrowser, is_xiaohongshu_share_url from ..proxy import ProxyExit from ..runtime import ( - BROWSER_VERSION_RE, BrowserRuntimeError, NativeRuntimeManager, has_control, @@ -124,7 +123,6 @@ class Gateway: return { "service": "browser-gateway", "node_id": self.node_id, - "browser_versions": sorted(self.runtimes.browser_versions), } def create(self, input: dict) -> dict: @@ -1232,28 +1230,6 @@ def _integer(env: Mapping[str, str], key: str, default: int) -> int: raise ValueError(f"{key} must be an integer") from exc -def _browser_versions(env: Mapping[str, str], default_version: str, default_path: str) -> dict[str, str]: - result = {default_version: default_path} - raw = env.get("BROWSER_VERSION_PATHS", "").strip() - if raw: - try: - decoded = json.loads(raw) - except json.JSONDecodeError as exc: - raise ValueError("BROWSER_VERSION_PATHS must be a JSON object") from exc - if not isinstance(decoded, dict) or not decoded: - raise ValueError("BROWSER_VERSION_PATHS must be a non-empty JSON object") - result = {} - for version, path in decoded.items(): - if not isinstance(version, str) or not BROWSER_VERSION_RE.fullmatch(version): - raise ValueError("BROWSER_VERSION_PATHS contains an invalid version") - if not isinstance(path, str) or not os.path.isabs(os.path.expanduser(path)): - raise ValueError("BROWSER_VERSION_PATHS contains a non-absolute path") - result[version] = os.path.abspath(os.path.expanduser(path)) - if default_version not in result: - result[default_version] = default_path - return result - - def load_config(env: Mapping[str, str] | None = None) -> dict: env = os.environ if env is None else env listen = env.get("LISTEN_ADDR", "0.0.0.0:8081").strip() @@ -1264,7 +1240,6 @@ def load_config(env: Mapping[str, str] | None = None) -> dict: profile_root = os.path.abspath(os.path.expanduser(env.get( "BROWSER_PROFILE_ROOT", "~/.local/share/creatorhub/browser-profiles" ).strip())) - browser_version = env.get("BROWSER_VERSION", "148.0.7778.215").strip() browser_path = os.path.abspath(os.path.expanduser(env.get( "BROWSER_PATH", "~/.local/share/creatorhub/browsers/fingerprint-chromium/148.0.7778.215/chrome", @@ -1276,8 +1251,6 @@ def load_config(env: Mapping[str, str] | None = None) -> dict: raise ValueError("GATEWAY_TOKEN must be at least 16 characters") if not state_dir or not profile_root or not os.path.isabs(state_dir) or not os.path.isabs(profile_root): raise ValueError("BROWSER_STATE_DIR and BROWSER_PROFILE_ROOT must be absolute") - if not BROWSER_VERSION_RE.fullmatch(browser_version): - raise ValueError("BROWSER_VERSION is invalid") if not node_id: node_id = "" elif not re.fullmatch(r"^[a-z0-9][a-z0-9._-]{0,63}$", node_id): @@ -1286,21 +1259,18 @@ def load_config(env: Mapping[str, str] | None = None) -> dict: raise ValueError("NODE_NAME is invalid") if not 1 <= port <= 65535: raise ValueError("LISTEN_ADDR port must be 1..65535") - versions = _browser_versions(env, browser_version, browser_path) external_display = _optional_positive_int(env, "RUNTIME_EXTERNAL_DISPLAY") - for version, path in versions.items(): - try: - available = os.path.isfile(path) and os.access(path, os.X_OK) - except (OSError, TypeError) as exc: - raise ValueError(f"browser version {version} is unavailable") from exc - if not available: - raise ValueError(f"browser version {version} is unavailable") + try: + available = os.path.isfile(browser_path) and os.access(browser_path, os.X_OK) + except (OSError, TypeError) as exc: + raise ValueError("browser executable is unavailable") from exc + if not available: + raise ValueError("browser executable is unavailable") return { "listen": (host, port), "state_dir": state_dir, "profile_root": profile_root, - "browser_versions": versions, - "browser_version": browser_version, + "browser_path": browser_path, "node_id": node_id, "node_name": node_name, "token": token, @@ -1367,7 +1337,7 @@ def run() -> None: state_dir=config["state_dir"], profile_root=config["profile_root"], node_id=node_id, - browser_versions=config["browser_versions"], + browser_path=config["browser_path"], cleanup_timeout=config["cleanup_timeout"], ready_timeout=config["ready_timeout"], min_free_bytes=config["min_free_bytes"], @@ -1384,7 +1354,7 @@ def run() -> None: "node_id": node_id, "node_name": config["node_name"], "listen_addr": f"{config['listen'][0]}:{config['listen'][1]}", - "browser_version": config["browser_version"], + "browser_path": config["browser_path"], "external_display": config["external_display"], }, ensure_ascii=False, diff --git a/browser_gateway/test_gateway.py b/browser_gateway/test_gateway.py index c024558..9c979d1 100644 --- a/browser_gateway/test_gateway.py +++ b/browser_gateway/test_gateway.py @@ -110,7 +110,6 @@ class GatewayValidationTests(unittest.TestCase): value = { "alias": "safe-account", "name": "Safe account", - "browser_version": "148.0.7778.215", "profile_id": "safe-account", "cmd": ["about:blank"], "binding_version": 1, @@ -151,17 +150,15 @@ class GatewayValidationTests(unittest.TestCase): { "LISTEN_ADDR": ":8081", "BROWSER_PATH": "/bin/true", - "BROWSER_VERSION": "1.2.3", "GATEWAY_TOKEN": "0123456789abcdef", } ) self.assertEqual(config["listen"], ("", 8081)) - self.assertEqual(config["browser_versions"]["1.2.3"], "/bin/true") + self.assertEqual(config["browser_path"], "/bin/true") self.assertIsNone(config["external_display"]) external = load_config( { "BROWSER_PATH": "/bin/true", - "BROWSER_VERSION": "1.2.3", "GATEWAY_TOKEN": "0123456789abcdef", "RUNTIME_EXTERNAL_DISPLAY": "99", } @@ -172,7 +169,6 @@ class GatewayValidationTests(unittest.TestCase): { "GATEWAY_TOKEN": "0123456789abcdef", "BROWSER_PATH": "/not/a/browser", - "BROWSER_VERSION": "1.2.3", } ) self.assertTrue( @@ -340,24 +336,12 @@ class GatewayValidationTests(unittest.TestCase): validate_proxy_restore({"network_exit_id": ""}, "safe") with self.assertRaises(RequestError): validate_create({"alias": "safe", "unknown": True}) - with self.assertRaises(RequestError): - validate_create( - { - "alias": "safe", - "name": "Safe", - "browser_version": "bad", - "profile_id": "safe", - "binding_version": 1, - "cmd": ["about:blank"], - } - ) with self.assertRaises(ValueError): load_config({"GATEWAY_TOKEN": "short"}) with self.assertRaises(ValueError): load_config( { "BROWSER_PATH": "/bin/true", - "BROWSER_VERSION": "1.2.3", "GATEWAY_TOKEN": "0123456789abcdef", "RUNTIME_EXTERNAL_DISPLAY": "0", } @@ -385,7 +369,7 @@ class GatewayBusinessMethodTests(unittest.TestCase): } self.runtimes = Mock() self.runtimes.proxies = Mock() - self.runtimes.browser_versions = {"148.0.7778.215": "/bin/true"} + self.runtimes.browser_path = "/bin/true" self.runtimes.alias_lock.return_value = nullcontext() self.runtimes.require_generation.return_value = SimpleNamespace( runtime_id=self.runtime_id, network_exit_id="exit-1" @@ -1319,7 +1303,6 @@ class GatewayLifecycleTests(unittest.TestCase): return { "alias": "safe", "name": "Safe", - "browser_version": "1.2.3", "profile_id": "safe", "cmd": ["about:blank"], "binding_version": 1, diff --git a/browser_gateway/test_runtime.py b/browser_gateway/test_runtime.py index 069737d..4f5a73e 100644 --- a/browser_gateway/test_runtime.py +++ b/browser_gateway/test_runtime.py @@ -178,7 +178,7 @@ class NativeRuntimeManagerTests(unittest.TestCase): state_dir=self.state, profile_root=self.profiles, node_id="node-a", - browser_versions={"148.0.7778.215": "/bin/true"}, + browser_path="/bin/true", unit_manager=self.units, min_free_bytes=0, ) @@ -193,7 +193,6 @@ class NativeRuntimeManagerTests(unittest.TestCase): value: dict[str, Any] = { "alias": "account-a", "name": "Account A", - "browser_version": "148.0.7778.215", "profile_id": "account-a-id", "cmd": ["--fingerprint=1000", "about:blank"], "binding_version": 1, @@ -226,7 +225,7 @@ class NativeRuntimeManagerTests(unittest.TestCase): state_dir=self.state / "disk-state", profile_root=self.profiles / "disk-profiles", node_id="node-a", - browser_versions={"148.0.7778.215": "/bin/true"}, + browser_path="/bin/true", unit_manager=self.units, min_free_bytes=10**20, ) @@ -241,7 +240,7 @@ class NativeRuntimeManagerTests(unittest.TestCase): state_dir=self.state / "external-state", profile_root=self.profiles / "external-profiles", node_id="node-a", - browser_versions={"148.0.7778.215": "/bin/true"}, + browser_path="/bin/true", unit_manager=self.units, external_display=99, min_free_bytes=0, @@ -267,7 +266,7 @@ class NativeRuntimeManagerTests(unittest.TestCase): state_dir=self.state / "external-state-2", profile_root=self.profiles / "external-profiles-2", node_id="node-a", - browser_versions={"148.0.7778.215": "/bin/true"}, + browser_path="/bin/true", unit_manager=self.units, external_display=99, min_free_bytes=0, @@ -316,7 +315,7 @@ class NativeRuntimeManagerTests(unittest.TestCase): state_dir=self.state, profile_root=self.profiles, node_id="node-a", - browser_versions={"148.0.7778.215": "/bin/true"}, + browser_path="/bin/true", unit_manager=self.units, min_free_bytes=0, ) @@ -415,7 +414,7 @@ class NativeRuntimeManagerTests(unittest.TestCase): state_dir=self.state, profile_root=self.profiles, node_id="node-a", - browser_versions={"148.0.7778.215": "/bin/true"}, + browser_path="/bin/true", unit_manager=self.units, min_free_bytes=0, ) diff --git a/deploy/browser-gateway.env.example b/deploy/browser-gateway.env.example index 69753d0..b99d545 100644 --- a/deploy/browser-gateway.env.example +++ b/deploy/browser-gateway.env.example @@ -3,7 +3,6 @@ LISTEN_ADDR=0.0.0.0:8081 GATEWAY_TOKEN=replace-with-at-least-16-random-characters BROWSER_STATE_DIR=~/.local/state/creatorhub/browser-gateway BROWSER_PROFILE_ROOT=~/.local/share/creatorhub/browser-profiles -BROWSER_VERSION=148.0.7778.215 BROWSER_PATH=~/.local/share/creatorhub/browsers/fingerprint-chromium/148.0.7778.215/chrome NODE_NAME= RUNTIME_CLEANUP_TIMEOUT=30 diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md index 7cf2431..00589b2 100644 --- a/docs/architecture/container-control.md +++ b/docs/architecture/container-control.md @@ -7,7 +7,7 @@ - 前端:React 19 + Vite 8 + Refine + shadcn/ui + Tailwind CSS,图标 RemixIcon;项目自有 Layout 与 HashRouter。 - 控制面:Go、Fiber v3、Viper、Logrus、Cobra。控制面编排浏览器 gateway、环境、账号、任务和审计,不直接启动浏览器。 - 浏览器 gateway:Python 3.12+ 标准库 HTTP/进程/文件/socket 能力;以非 root `systemctl --user` 服务运行,在宿主机管理 Xvfb、native/fingerprint browser、Profile、CDP、代理和运行代次。 -- 数据:PostgreSQL 保存网关登记、浏览器版本、环境绑定、运行实例、清理状态、租约、账号、任务、结果和审计。`browser_version.browser_path` 是宿主机已安装的可执行文件路径,不是镜像引用。 +- 数据:PostgreSQL 保存网关登记、环境绑定、运行实例、清理状态、租约、账号、任务、结果和审计。浏览器可执行文件、默认版本和路径映射属于 gateway 宿主机配置,不进入 control-plane 管理数据。 - 部署:Compose 只提供控制面和 PostgreSQL;gateway 与 Xvfb 在宿主机运行。控制面在 Compose 中通过 `host.docker.internal:8081` 访问 gateway,裸机运行使用 `127.0.0.1:8081`。 ## 调用链与职责 @@ -31,20 +31,19 @@ React ── /api/* (Basic Auth) ──> control-plane ──> PostgreSQL | 接口/动作 | 当前行为与失败语义 | | --- | --- | -| `POST /api/browsers` | 接收 `alias`、`name`、`gateway`、`browser_version`、`fingerprint`、`account_id`、`network_exit_id`。版本必须启用且路径为绝对可执行路径;账号必须满足授权/暂停规则,指定出口必须健康。先保存环境和稳定 binding,再向 gateway 创建停止态 runtime;失败保留环境、operation 和清理状态,不伪造成功。 | +| `POST /api/browsers` | 接收 `alias`、`name`、`gateway`、`fingerprint`、`account_id`、`network_exit_id`。账号必须满足授权/暂停规则,指定出口必须健康。先保存环境和稳定 binding,再向 gateway 创建停止态 runtime;失败保留环境、operation 和清理状态,不伪造成功。 | `GET /api/browsers`、`GET /api/browsers/{alias}` | 返回数据库状态与 gateway 可达时的 runtime 快照,包括 `node_id`、generation、ready、运行状态、cleanup 状态和失败原因。不可达、版本缺失、Profile 占用和待清理均保持可见;读取不把未知状态改成 stopped。 | -| `POST /api/browsers/{alias}/start` | 重新核对账号、版本、Profile、出口和 binding。匹配的 ready runtime 可复用;缺失或不匹配时按当前 binding 创建新代次并激活。必需代理失败时不得直连。 | +| `POST /api/browsers/{alias}/start` | 重新核对账号、Profile、出口和 binding。匹配的 ready runtime 可复用;缺失或不匹配时按当前 binding 创建新代次并激活。必需代理失败时不得直连。 | | `POST /api/browsers/{alias}/stop` | 以当前 generation 停止浏览器、Xvfb、代理和订阅,并记录独立 cleanup 状态。结果未知、强杀或清理失败返回可见失败/待清理状态,而不是请求发出即成功。 | -| `POST /api/browsers/{alias}/upgrade` | 接收 `{version}`,要求目标 browser version 启用;停止旧代次、保留 Profile、按新路径创建并按需启动。无隐式回退和自动回滚,失败保留证据供重试。 | | `POST /api/browsers/{alias}/rebind` | 接收有效 `network_exit_id`,要求没有执行中的 runtime-use 或业务任务;成功后递增 binding version。空值切直连不通过此接口隐式完成。 | | `DELETE /api/browsers/{alias}` | 回收当前 runtime 和本次临时资源,保留环境、稳定 binding、账号 Profile、正式结果和长期监听。旧 generation 的迟到清理不会触碰 successor。 | 每个 runtime 独立拥有:`runtime_id`、`runtime_instance_id`、`generation`、`node_id`、owner、Profile、display、CDP 端口、loopback proxy 端口、browser/Xvfb unit、日志目录和 cleanup 状态。runtime 结束后,任务目录按结果语义清理;正式素材和结果目录不由通用 runtime 清理逻辑删除。 -## 浏览器版本、网关与 Profile +## 网关与 Profile -- `GET/POST /api/browser-versions` 管理 `{version, browser_path, note, enabled}`;版本号不可改,只有启用版本可创建或升级环境。旧迁移 SQL 中出现的 `browser_image`/`image_ref` 仅是 PostgreSQL 前向迁移的历史表名,当前 API、模型、页面和运行时均使用 browser version/path。 -- `GET/POST /api/gateways` 注册稳定 gateway endpoint、node name 和令牌;gateway `/v1/info` 返回稳定 `node_id`、版本和能力。当前目标只调度单 gateway,不执行跨机迁移。 +- `GET/POST /api/gateways` 注册稳定 gateway endpoint、node name 和令牌;gateway `/v1/info` 返回稳定 `node_id`、默认运行时和能力。浏览器安装、默认版本及可选路径映射只由 gateway 宿主机配置,control-plane 不提供版本登记或升级入口。 +- 创建环境只绑定账号、gateway、指纹和网络出口;gateway 创建 runtime 时使用自己的默认浏览器配置。当前目标只调度单 gateway,不执行跨机迁移。 - `profile_id` 由控制面绑定账号和环境,gateway 只接受已登记的标识并生成目录。Profile 目录权限为 owner-only;同一 Profile 的第二个 runtime 在资源锁阶段拒绝。 - 每个执行使用独立的 `.runs/` 目录和不可变 token。业务结果引用相对 work root 的正式产物;路径穿越、绝对路径、跨 work 目录和临时文件均拒绝。 @@ -70,9 +69,9 @@ React ── /api/* (Basic Auth) ──> control-plane ──> PostgreSQL ## 运行与恢复 -使用[部署说明](../deployment.md)中的变量和命令。首次部署时先安装已批准的浏览器版本和 Xvfb,再以 `scripts/install-native-browser-gateway.sh` 安装 gateway user service,随后在控制面注册 gateway、browser version、账号和出口。 +使用[部署说明](../deployment.md)中的变量和命令。首次部署时在 gateway 宿主机配置已安装的浏览器和 Xvfb,再以 `scripts/install-native-browser-gateway.sh` 安装 gateway user service,随后在控制面注册 gateway、账号和出口。 -启动、停止、回收和升级分别写 requested/finished operation;gateway 不可达、返回未知、cleanup pending、Profile 被占用和版本缺失都在页面和 API 显示。gateway 重启只恢复仍匹配当前代次的 runtime;机器重启不自动恢复已过期短任务。控制面后台 heartbeat 续租 running runtime 并调和 cleanup,读取列表不会用轮询伪装业务事件监听。 +启动、停止和回收分别写 requested/finished operation;gateway 不可达、返回未知、cleanup pending 和 Profile 被占用都在页面和 API 显示。gateway 重启只恢复仍匹配当前代次的 runtime;机器重启不自动恢复已过期短任务。控制面后台 heartbeat 续租 running runtime 并调和 cleanup,读取列表不会用轮询伪装业务事件监听。 ## 阶段 A 与 Creator 业务边界 diff --git a/docs/deployment.md b/docs/deployment.md index 0a008f9..6e2f4c0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -49,7 +49,7 @@ $EDITOR ~/.config/creatorhub/browser-gateway.env - `LISTEN_ADDR=0.0.0.0:8081`:允许局域网 control-plane 联调;防火墙只放行受信网络; - `GATEWAY_TOKEN`:至少 16 个字符,和控制面登记值完全一致; -- `BROWSER_PATH`、`BROWSER_VERSION`:预安装浏览器的绝对路径和版本; +- gateway 宿主机必须预安装并自检浏览器运行时;control-plane 不提供浏览器版本登记、路径配置或升级入口,相关运行时参数只由 gateway 本机服务读取; - `BROWSER_STATE_DIR`:runtime 清单、锁和日志目录; - `BROWSER_PROFILE_ROOT`:持久账号 Profile 根目录;不得指向临时目录; - `RUNTIME_MIN_FREE_BYTES`、`RUNTIME_LOG_MAX_BYTES`、`PROFILE_CACHE_MAX_BYTES`:资源硬限制。 @@ -121,14 +121,14 @@ pnpm frontend # Vite,HMR ## 首次配置与生命周期 -1. 在「网关管理」登记 native gateway;读取 `/v1/info` 核对稳定 `node_id` 和浏览器版本能力。 -2. 在「浏览器版本」登记 gateway 上存在的版本和绝对路径;版本缺失时创建动作必须失败,不得自动拉取或改用其它版本。 -3. 创建账号和环境,选择账号、gateway、浏览器版本及可选网络出口。 +1. 在「网关管理」登记 native gateway;读取 `/v1/info` 核对稳定 `node_id` 和 gateway 已配置的浏览器能力。 +2. 浏览器可执行文件、默认版本及可选版本映射只在 gateway 宿主机配置,不在 control-plane 登记或选择。 +3. 创建账号和环境,选择账号、gateway 及可选网络出口。 4. 显式启动环境;gateway 返回 runtime ID、network ID、node ID、generation、display、CDP/代理端口和 readiness。 5. 登录二维码只在内存响应中短暂展示;完成人工登录后执行账号身份核验。 6. 采集和人工写操作必须通过账号身份、binding version、runtime generation 和 runtime use lease 检查。 7. 停止或失败时,业务结果和 cleanup 状态分别记录。cleanup 失败显示 `pending` 并可重试;不得把 pending 伪装为已停止。 -8. 重新启动使用相同 Profile 根目录,核对浏览器版本和账号身份,不删除登录资料。 +8. 重新启动使用相同 Profile 根目录,核对 gateway 运行时和账号身份,不删除登录资料。 gateway 的 runtime 清单、Profile 和日志目录不得由 `docker compose down` 删除。只有明确授权时才执行账号 Profile 或正式素材删除。 @@ -150,7 +150,7 @@ npm --prefix web run build docker compose config --quiet ``` -资源和失败场景必须由人工按 [native-browser-verification.md](native-browser-verification.md) 留证:Profile 占用、版本缺失、代理认证/网络失败、Xvfb 或浏览器启动失败、取消、超时、gateway 重启、重复清理、磁盘不足、旧 generation、监听与采集并行,以及真实抖音登录和采集。单元测试、Fake systemd 和 Mock gateway 不能替代真实平台证据。 +资源和失败场景必须由人工按 [native-browser-verification.md](native-browser-verification.md) 留证:Profile 占用、gateway 运行时不可用、代理认证/网络失败、Xvfb 或浏览器启动失败、取消、超时、gateway 重启、重复清理、磁盘不足、旧 generation、监听与采集并行,以及真实抖音登录和采集。单元测试、Fake systemd 和 Mock gateway 不能替代真实平台证据。 常用诊断: diff --git a/docs/e2e-test-plan.md b/docs/e2e-test-plan.md index c4e0eac..cdab99c 100644 --- a/docs/e2e-test-plan.md +++ b/docs/e2e-test-plan.md @@ -31,13 +31,13 @@ ### 2.1 环境 1. 使用专用、可销毁的 Linux 主机;gateway 以非 root `systemctl --user` 服务运行。禁止与现有业务共用固定 browser alias、Profile 目录或数据库 schema;不得删除现有 Docker PostgreSQL 之外的业务服务。 -2. 由执行负责人按 `docs/deployment.md` 准备控制面/PostgreSQL Compose 与 host-native gateway,记录构建提交、浏览器版本/path、systemd/Xvfb/操作系统、数据库版本、时区、测试起止 UTC;另用独立时段验证 `compose.dev.yaml` 的开发入口。不得一边测试一边自动升级源码。 -3. 准备 `curl`、`jq`、必要时 Docker CLI(仅用于 PostgreSQL Compose)、`systemctl --user`、浏览器 DevTools;媒体核验使用宿主机 `ffprobe`/`ffmpeg`。准备两个已安装且可执行的 browser version/path 和一个不存在或不可执行的 path。 -4. 配置必须来自测试凭据保管渠道:`CONTROL_PLANE_USERNAME`、`CONTROL_PLANE_PASSWORD`、32 字节 Base64 `CREATORHUB_CREDENTIAL_MASTER_KEY`、`GATEWAY_TOKEN`。gateway 还需合法 `BROWSER_STATE_DIR`、`BROWSER_PROFILE_ROOT`、`NODE_ID` 和 browser version/path。不得将实际密码、Cookie、token、证件号贴入文档/缺陷/日志截图。 -5. 记录控制面 `LISTEN_ADDR`、`WEB_DIR`、`DATABASE_URL`、`CREATORHUB_CREDENTIAL_STORE_DIR`、`LOG_LEVEL`;gateway 的 `LISTEN_ADDR`、`BROWSER_STATE_DIR`、`BROWSER_PROFILE_ROOT`、`NODE_ID`、browser version/path。媒体目录 `CREATOR_MEDIA_DIR` 可写;如测转写,`CREATOR_TRANSCRIPTION_BIN` 必须是已安装的真实供应商适配程序,接受音轨路径参数、stdout 输出正文;不能用 `echo` 冒充真实转写。 +2. 由执行负责人按 `docs/deployment.md` 准备控制面/PostgreSQL Compose 与 host-native gateway,记录构建提交、gateway 运行时配置、systemd/Xvfb/操作系统、数据库版本、时区、测试起止 UTC;另用独立时段验证 `compose.dev.yaml` 的开发入口。不得一边测试一边自动升级源码。 +3. 准备 `curl`、`jq`、必要时 Docker CLI(仅用于 PostgreSQL Compose)、`systemctl --user`、浏览器 DevTools;媒体核验使用宿主机 `ffprobe`/`ffmpeg`。gateway 宿主机必须有可执行的默认浏览器配置;缺失配置由 gateway 明确拒绝。 +4. 配置必须来自测试凭据保管渠道:`CONTROL_PLANE_USERNAME`、`CONTROL_PLANE_PASSWORD`、32 字节 Base64 `CREATORHUB_CREDENTIAL_MASTER_KEY`、`GATEWAY_TOKEN`。gateway 还需合法 `BROWSER_STATE_DIR`、`BROWSER_PROFILE_ROOT` 和 `NODE_ID`。不得将实际密码、Cookie、token、证件号贴入文档/缺陷/日志截图。 +5. 记录控制面 `LISTEN_ADDR`、`WEB_DIR`、`DATABASE_URL`、`CREATORHUB_CREDENTIAL_STORE_DIR`、`LOG_LEVEL`;gateway 的 `LISTEN_ADDR`、`BROWSER_STATE_DIR`、`BROWSER_PROFILE_ROOT`、`NODE_ID` 和宿主机运行时配置。媒体目录 `CREATOR_MEDIA_DIR` 可写;如测转写,`CREATOR_TRANSCRIPTION_BIN` 必须是已安装的真实供应商适配程序,接受音轨路径参数、stdout 输出正文;不能用 `echo` 冒充真实转写。 6. 如测 AI:准备已审批的 Bailian 账号、配额、`BAILIAN_API_KEY`,可选 `BAILIAN_BASE_URL`;页面 provider 填 `bailian`,model 填实际批准模型。勾选“已完成审批”并不验证密钥。转写页面元数据也不替代可执行程序配置。 7. 对所有代理协议 http/https/socks4/socks5 分别准备真实可控测试端点;至少一个无认证端点、一个带认证端点、两个不同公网 IP、一个不可达端点,保存实际地区与供应商定义。没有某协议服务时该行阻塞,不能用另一协议代替。 -8. 记录观察窗口:普通 API 30 秒;创建/升级含拉browser version/path最多按控制面 11 分钟预算;运行租约调和每 20 秒、采集调度每 30 秒、监听绑定调和每 10 秒;事件长等待至多 25 秒,重连退避 1–30 秒。超时记录请求/日志,不自动重复写。 +8. 记录观察窗口:普通 API 30 秒;创建/启停 gateway runtime 最多按控制面 11 分钟预算;运行租约调和每 20 秒、采集调度每 30 秒、监听绑定调和每 10 秒;事件长等待至多 25 秒,重连退避 1–30 秒。超时记录请求/日志,不自动重复写。 ### 2.2 账号、目标与数据 @@ -50,7 +50,7 @@ | C | 已同意的竞品测试账号,必须与采集执行账号不同;有可人工核对的近期/历史作品、分页及一级评论 | | W / K | 从真实采集得到的内部作品 ID / 评论 ID;另存平台 work_key/comment_key、作者 UID、原页面链接和时间证据 | | M1 / M2 / M3 | 有明确授权的带语音视频、无音轨视频、音轨无语音视频;保存原片时长与参考文稿,不把网页 HTML 当媒体 | -| G / V1 / V2 | 测试网关名、两个已安装 browser version/path;升级使用第二个已启用版本 | +| G / V1 / V2 | 测试网关名和 gateway 宿主机运行时配置 | | E0 / E1 / P1 / P2 | 直连环境、代理环境、两个已健康出口;每个环境固定不同账号 | | T / D / Q / AT | 本轮生成的任务、草稿、确认、Attempt ID;不得拿旧轮次同名结果充数 | @@ -128,7 +128,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random | --- | --- | --- | | 1 基础部署与配置 | `/healthz`,Compose、Cobra、配置、持久化、日志、静态资源 | SYS-01~SYS-06 | | 2 登录/导航/公共组件 | `/#/login`、`/#/`、Layout、全部页面共用控件 | UI-01~UI-07 | -| 3 网关与浏览器版本 | `/#/gateways`、`/#/browser-versions` | GW-01~GW-05、VER-01~VER-04 | +| 3 网关与浏览器运行时 | `/#/gateways` | GW-01~GW-05 | | 4 出口 | `/#/network-exits`、`/#/network-exits/:id` | NET-01~NET-06 | | 5 账号/环境 | `/#/accounts`、`/#/accounts/:id`、`/#/browsers`、`/#/browsers/new`、`/#/browsers/:id` | AC-01~AC-07、ENV-01~ENV-10 | | 6 gateway 领域能力 | `/v1/browsers`、代际/代理/CDP/抖音受限路由 | API 附录 G、GATE-01~GATE-06 | @@ -146,8 +146,8 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random 依赖执行顺序(从干净环境开始): -1. 完成 SYS-01~03、SYS-06 及 UI 登录/导航,创建 GW-01 网关、VER-01 browser version/path、NET-01 出口、AC-01 账号与 ENV 创建/启动所需记录;公共组件的有数据/错误状态随对应模块补测。 -2. 取得环境引用后执行 GW-02 的引用冲突分支及其他资源关联检查;删除/停用/令牌失效等分支使用独立资源,或延后至这些资源的所有依赖用例完成。不得提前删掉后续真实测试使用的网关、browser version/path或出口。 +1. 完成 SYS-01~03、SYS-06 及 UI 登录/导航,创建 GW-01 网关、NET-01 出口、AC-01 账号与 ENV 创建/启动所需记录;公共组件的有数据/错误状态随对应模块补测。 +2. 取得环境引用后执行 GW-02 的引用冲突分支及其他资源关联检查;删除/停用/令牌失效等分支使用独立资源,或延后至这些资源的所有依赖用例完成。不得提前删掉后续真实测试使用的网关、运行时或出口。 3. 完成 AC-06 登录检查及非破坏性环境/gateway 检查;创建 Phase A 草稿并完成任务流程;配置采集设置与账号资料/关系。策略可保存,但自动写入保持关闭。 4. 完成竞品采集,再以真实作品/评论执行素材、规则/线索和人工操作。完成前述数据创建后,才执行 SYS-04 持久化检查:账号、环境、草稿、规则、素材均实际存在;保存各项证据后重启。不能为满足前置而改库造数据。 5. 人工动作留证后执行 EVT 自动响应,再立即停用策略;审计用例在所需动作/任务记录产生后执行。API-only 导入可在合法账号/来源就绪后独立执行,但修改全局指标设置的 API-02 应在 REAL 指标观察完成后执行并恢复设置。 @@ -165,14 +165,14 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random - 前置:专用干净主机,2.1 配置齐备;允许后续部署。 - 操作: - 1. 按部署说明启动 control-plane、native gateway 和前端,并记录进程、browser version/path digest + 1. 按部署说明启动 control-plane、native gateway 和前端,并记录进程与 gateway 运行时 digest 2. GET 控制面和 gateway 的 /healthz;GET 带认证的 /api/gateways 3. 打开 BASE/,再打开实际 JS/CSS 资源;刷新 /#/accounts 及 /#/browsers/new - 预期:两个健康接口 204;数据库健康、服务持续运行;API 返回 JSON,页面/资源可加载。healthz 不等于数据库、平台或全部依赖已通过。 #### SYS-02 控制面配置与命令校验 [API/FAULT] -- 前置:SYS-01 browser version/path;采用 F9,禁止改运行配置。 +- 前置:SYS-01 gateway 运行时配置;采用 F9,禁止改运行配置。 - 操作: 1. 分别单项设置 LISTEN_ADDR=:0、LOG_LEVEL=bogus、WEB_DIR仅三个空格、DATABASE_URL=`http://example.invalid/db` 2. 分别设置用户名仅三个空格或含冒号、密码不足6字节、主密钥非Base64或解码长度不为32、凭据目录为相对路径、BAILIAN_BASE_URL 含 userinfo @@ -181,7 +181,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random #### SYS-03 gateway 配置与依赖错误 [API/FAULT] -- 前置:SYS-01 browser version/path;F9 独立命令。 +- 前置:SYS-01 gateway 运行时配置;F9 独立命令。 - 操作: 1. 单项试 LISTEN_ADDR=:0、GATEWAY_TOKEN=short、BROWSER_STATE_DIR=空、BROWSER_PROFILE_ROOT=非 owner-only 或 browser path 不存在 2. 恢复有效配置;在专用测试实例令 browser path 指向不存在路径,GET /v1/browsers @@ -255,7 +255,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random #### UI-05 弹窗取消、忙碌和重复点击 [UI/FAULT] -- 前置:browser version/path或账号普通创建弹窗、出口停用确认框;F2。 +- 前置:gateway 运行时或账号普通创建弹窗、出口停用确认框;F2。 - 操作: 1. 普通创建输入后分别用取消、关闭图标、Escape、遮罩退出再打开 2. 确认框先取消一次,检查无写请求 @@ -266,7 +266,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random - 前置:干净数据时留存各页空态;F1可用。 - 操作: - 1. 分别阻断 accounts、browsers、gateways、browser-versions、network-exits、creator各列表、drafts、audit、attempt详情 GET + 1. 分别阻断 accounts、browsers、gateways、network-exits、creator各列表、drafts、audit、attempt详情 GET 2. 逐页检查加载结束后错误区、操作禁用及是否显示假空态 3. 取消阻断,只有存在重试按钮的页面点击重试,其余重新进入页面 - 预期:失败与空数据可区分,不能隐藏依赖错误;没有重试按钮不写“重试成功”。特别记录账号草稿、环境创建依赖、规则列表异常呈现;不得自动重做先前写请求。 @@ -280,7 +280,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random 3. 复制可显示的CDP和网关token到本地私密临时区,随后撤销剪贴板权限再试 - 预期:核心内容可读、禁用可辨;复制内容与来源一致但不要求当前不存在的成功提示。焦点圈定/自动聚焦、aria-describedby缺口如实报,不把缺失能力当支持;截图无秘密。 -### 5.3 网关和browser version/path +### 5.3 网关运行时 #### GW-01 注册、令牌与列表 [UI] @@ -316,7 +316,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random 1. 点击删除先取消,确认列表仍在且无DELETE 2. 再打开确认删除并确认一次,观察Network、Console与页面 3. 刷新列表读取实际记录 -- 预期:预期删除应发送领域DELETE并移除记录;browser version 被环境引用时必须拒绝删除。不得使用后端删除成功掩盖UI失败;runtime 回收另用 GW-05。 +- 预期:预期删除应发送领域DELETE并移除记录;gateway 被环境引用时必须拒绝删除。不得使用后端删除成功掩盖UI失败;runtime 回收另用 GW-05。 #### GW-05 网关删除 API 与引用冲突 [API] @@ -327,41 +327,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random 3. DELETE仍被环境引用的G,回读G和环境 - 预期:未引用项成功删除;不存在返回明确结果、不影响其他项;引用中的网关409拒绝且环境引用不丢。此用例独立于UI删除缺陷。 -#### VER-01 创建browser version/path与字段边界 [UI/API] - -- 前置:两个可拉取的已登记browser version/path。 -- 操作: - 1. 创建启用V1,检查版本/引用/备注;再创建V2 - 2. 重复V1;试非法版本首字符、65字符版本、空引用、非法引用字符、超长备注 - 3. F1阻断一次创建,恢复后只读查重 -- 预期:成功持久化,重复/非法值不覆盖已有版本;失败不清表单或误报成功。仅登记版本不证明browser version/path可运行。 - -#### VER-02 编辑、禁用和跨页候选 [UI] - -- 前置:V1/V2已登记,V1被E1引用。 -- 操作: - 1. 编辑V2引用/备注并保存,版本字段不可修改 - 2. 关闭V2启用开关,到环境创建/升级重新打开选项 - 3. 再启用V2;编辑时清空引用点击保存 -- 预期:停用版本不出现在新建/升级候选,已有环境不静默换版;空引用应阻止请求或被后端拒绝;错误在编辑框可见。 - -#### VER-03 删除引用与取消 [UI] - -- 前置:V1被引用,另有未引用临时版本。 -- 操作: - 1. 删除临时版本先取消,再确认;刷新列表 - 2. 尝试删除V1并检查确认框内错误 - 3. 恢复临时登记项以供下一用例或登记已删除 -- 预期:未引用删除成功;取消无DELETE;引用中拒绝且保留信息,不移除V1或破坏E1。 - -#### VER-04 browser version/path不可拉取与动作忙碌 [UI/FAULT] - -- 前置:临时版本指向不存在的仓库引用,独立暂停账号。 -- 操作: - 1. 新建环境选该版本并提交,记录拉取过程、按钮禁用和超时 - 2. 查看环境列表、审计和gateway runtime 实物,不假定登记已回滚 - 3. 修改该临时版本为正确测试digest,核验残留后显式恢复环境一次 -- 预期:失败可见,有requested/finished证据;没有假running;已保存环境/binding按实物核验恢复,不能重复生成绑定。 +gateway 使用宿主机已核验的默认浏览器运行时;运行时安装、版本和路径由 gateway 配置与 `/v1/info` 能力负责,不在 control-plane 建立版本管理用例。 ### 5.4 网络出口 @@ -508,7 +474,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random - 前置:空依赖与齐备依赖分时段;附录D。 - 操作: - 1. 缺网关/browser version/path/可绑账号/健康出口时打开表单查看准备提示;F1阻断依赖GET后再开 + 1. 缺网关/gateway 运行时/可绑账号/健康出口时打开表单查看准备提示;F1阻断依赖GET后再开 2. 分别试alias非法/超32、seed0/2147483648/小数、CPU负数/129、非法语言/时区、指纹内proxy_server 3. 修正后创建;并用API重复同alias不同账号或已绑定账号的新alias - 预期:默认只选合法依赖;错误读取不伪装业务缺项;字段越界后端拒绝。相同资源配置重复按幂等核验,冲突配置409;一账号不出现两个活动绑定。 @@ -554,9 +520,9 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random - 前置:仅E1测试账号;两个API客户端,不发平台业务动作。 - 操作: 1. 两端同时POST start,保存每个响应与operation_id - 2. 在一次启动/升级进行时另端pause账号或disable目标browser version/path - 3. 读取账号、browser version/path、environment及runtime/systemd 实物,按最终有效配置恢复 -- 预期:不会双 runtime 或使用已失效配置绕过锁;完成审计与实际generation匹配;冲突/失败明确,未批准状态不能偷偷启动。取消禁用browser version/path或恢复账号只在核验后显式执行。 + 2. 在一次启动进行时另端 pause 账号或修改 gateway 运行时配置 + 3. 读取账号、environment及runtime/systemd 实物,按最终有效配置恢复 +- 预期:不会双 runtime 或使用已失效配置绕过锁;完成审计与实际generation匹配;冲突/失败明确,未批准状态不能偷偷启动。恢复 gateway 运行时或账号只在核验后显式执行。 #### ENV-09 gateway失联、清理待定与租约 [UI/FAULT] @@ -572,9 +538,9 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random - 前置:附录D;测试alias及不存在alias。 - 操作: 1. POST现有alias/unknown-action;给upgrade增加未知字段或非法version - 2. 给create未知字段、已停用browser version/path或未授权账号;GET不存在alias + 2. 给create未知字段、gateway 运行时不可用或未授权账号;GET不存在alias 3. 回读原环境和动作审计 -- 预期:非法操作400/冲突404等明确,不修改旧环境;升级/重绑拒绝输入的审计不泄露输入秘密;没有通用 PUT 更新或任意 browser/CDP 操作入口。 +- 预期:非法操作400/冲突404等明确,不修改旧环境;重绑拒绝输入的审计不泄露输入秘密;没有通用 PUT 更新或任意 browser/CDP 操作入口。 ### 5.7 gateway 领域 API @@ -589,7 +555,7 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random #### GATE-02 创建/启停/删除与代际 [API] -- 前置:单独的gateway实验alias,不对应活动控制面环境;附录G创建样例及已批准V1browser version/path。 +- 前置:单独的gateway实验alias,不对应活动控制面环境;附录G创建样例及gateway 已配置的运行时。 - 操作: 1. POST /v1/browsers 创建停止态实验 runtime,GET列表提取实际runtime/network/binding 2. 按附录G正确generation进行stop/remove;重复remove核对幂等或明确冲突 @@ -1291,15 +1257,15 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random - 前置:END-01;所有备份和RUN资源清单。 - 操作: 1. 按3.2恢复全部网络/配置/文件/Overrides;回收测试browser runtime,核对Profile保留 - 2. 删除未引用browser version/path/网关(网关用API);停用测试出口;恢复原settings/策略/业务状态并只读核对 + 2. 删除未引用网关(网关用API);停用测试出口;恢复原settings/策略/业务状态并只读核对 3. 需彻底销毁时,先停止专用Compose部署,由负责人逐一核对RUN runtime/Profile/数据库临时资源后删除本轮数据卷和Profile;导出脱敏报告 -- 预期:只清本轮资源,禁止全局docker prune或不核对就down -v;回收后仍保留环境binding会阻止删除被引用网关/browser version/path,应登记保留或在获批整套测试库销毁时一起清理,不伪造永久删除API。原始敏感证据受限,最终报告脱敏。 +- 预期:只清本轮资源,禁止全局docker prune或不核对就down -v;回收后仍保留环境 binding 会阻止删除被引用网关,应登记保留或在获批整套测试库销毁时一起清理,不伪造永久删除API。原始敏感证据受限,最终报告脱敏。 ## 6. API 覆盖及请求样例 以下目录是第5节用例的执行附录,不额外计算用例数。每个写接口必须使用指定用例的前置与授权,不能把目录当成可直接批量运行的脚本。`无`表示无请求 body。所有对象样例均为合法 JSON 结构,里面的 A/S1/C/W/K/R 等必须替换为账本真实内部 ID;平台数字 UID、作品 key 和 comment key另取真实值。返回字段不要全量复制进严格输入接口。除明确 API 补充数据外,优先使用真实采集/监听输出;标记 `RUN-api-only` 的样例绝不能进入 REAL 通过统计。 -### 附录 A:网关登记与browser version/path +### 附录 A:网关登记与运行时能力 | 方法 / 路径 | body或查询样例 | 入口 / 用例 | | --- | --- | --- | @@ -1307,10 +1273,6 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random | POST `/api/gateways` | `{"name":"e2e-gw","endpoint":"http://host.docker.internal:8081","token":""}`(裸机改为 `http://127.0.0.1:8081`);显式 token 从私密文件替换 | UI、GW-01/03 | | PUT `/api/gateways/{G}` | `{"name":"e2e-gw-renamed","endpoint":"http://host.docker.internal:8081","token":""}`(裸机改为 `http://127.0.0.1:8081`) | UI、GW-02 | | DELETE `/api/gateways/{G}` | 无;只删除未引用项 | **API可用,UI接线失败待复现**,GW-04/05 | -| GET `/api/browser-versions` | 无 | UI、VER-01 | -| POST `/api/browser-versions` | `{"version":"148.0.7778.215","browser_path":"/absolute/path/to/chrome","note":"RUN","enabled":true}`;path必须换本机已核验的可执行文件 | UI、VER-01 | -| PUT `/api/browser-versions/{V2}` | `{"browser_path":"/absolute/path/to/chrome","note":"RUN编辑","enabled":false}`;不传 version | UI、VER-02 | -| DELETE `/api/browser-versions/{V2}` | 无 | UI、VER-03 | ### 附录 B:网络出口 @@ -1364,13 +1326,12 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random | POST `/api/browsers` | 下方完整对象 | UI、ENV-01~03 | | POST `/api/browsers/{E1}/start` | 无 | UI、ENV-05 | | POST `/api/browsers/{E1}/stop` | 无 | UI、ENV-05 | -| POST `/api/browsers/{E1}/upgrade` | `{"version":"V2"}` | UI、ENV-06/07 | | POST `/api/browsers/{E1}/rebind` | `{"network_exit_id":"P2"}` | **API-only**、ENV-07 | | DELETE `/api/browsers/{E1}` | 无;仅回收容器 | UI、ENV-05/09 | ```json { - "alias":"e2e-env","name":"RUN环境","gateway":"G","image_version":"V1","account_id":"A","network_exit_id":"P1", + "alias":"e2e-env","name":"RUN环境","gateway":"G","account_id":"A","network_exit_id":"P1", "fingerprint":{"seed":1000,"platform":"windows","platform_version":"11.0.0","brand":"Chrome","brand_version":"132.0.6834.159","hardware_concurrency":4,"lang":"zh-CN","accept_lang":"zh-CN,en-US","timezone":"Asia/Shanghai","disable_spoofing":"font,audio"} } ``` @@ -1569,7 +1530,7 @@ G-douyin-base(cookies/get/identity/action/events均在此对象上加各自字 - 一级评论:`https://www.douyin.com/aweme/v1/web/comment/list/?aweme_id=实际平台work_key&count=20&cursor=0`;下一页同理。 - 不允许其他host、额外参数、任意CDP或脚本。源码当前允许非负数字分页,不采用旧文档“只能max_cursor=0”的描述。 -G-create(独立实验alias,不与ENV共用;browser version/path换真实摘要。`stopped=true`仅允许直连;运行态实验需要受控网络并明确授权): +G-create(独立实验alias,不与ENV共用;gateway 运行时换真实摘要。`stopped=true`仅允许直连;运行态实验需要受控网络并明确授权): ```json {"alias":"e2e-gate-only","name":"RUN gateway实验","image":"registry.example.invalid/test/browser:approved","cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-e2e-gate-only","binding_version":1,"network_exit_id":"","network_exit":{},"stopped":true} @@ -1621,7 +1582,7 @@ curl --silent --show-error --config "$GATEWAY_AUTH_FILE" \ 复制下表,每例及每个参数化子项各一行。不要把多协议、四种Mock结果、六类动作合并成一个“通过”。 -| RUN / 用例 / 子项 | 层级 | HEAD / browser version/path | 执行人 / UTC起止 | 前置账号与资源ID | 授权单 | 实际有序操作 | HTTP/operation/event/Attempt ID | 实际UI与平台结果 | 证据路径 | 状态 | 缺陷/阻塞及恢复清理 | +| RUN / 用例 / 子项 | 层级 | HEAD / gateway runtime | 执行人 / UTC起止 | 前置账号与资源ID | 授权单 | 实际有序操作 | HTTP/operation/event/Attempt ID | 实际UI与平台结果 | 证据路径 | 状态 | 缺陷/阻塞及恢复清理 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 待填写 | UI/API/REAL/MOCK/FAULT | 待填写 | 待填写 | 脱敏ID | 无写填不适用 | 记录实际步骤,不只复制计划 | 无则说明原因 | 不以“正常”代替事实 | 脱敏截图/响应/日志索引 | 未执行 | 待填写 | diff --git a/docs/evidence/native-control-plane-real-douyin-2026-09-18.md b/docs/evidence/native-control-plane-real-douyin-2026-09-18.md index 043d74d..7a3ad72 100644 --- a/docs/evidence/native-control-plane-real-douyin-2026-09-18.md +++ b/docs/evidence/native-control-plane-real-douyin-2026-09-18.md @@ -10,7 +10,7 @@ | 检查 | 结果 | 状态 | | --- | --- | --- | -| control plane 创建 gateway/browser version/account/environment | API 返回成功;native browser version 使用 fingerprint Chromium `148.0.7778.215` | 通过 | +| control plane 创建 gateway/account/environment | API 返回成功;gateway 使用宿主机已配置的 fingerprint Chromium `148.0.7778.215` | 通过 | | 显式启停 | `POST /api/browsers/independent-douyin/start` 返回 204;runtime 保持 running/ready | 通过 | | runtime 身份绑定 | control plane 记录 runtime ID、network ID、runtime instance 和 ready schedule | 通过 | | 抖音账号核对 | control plane verify 返回 `logged_in`;账号标识不写入仓库 | 通过 | diff --git a/docs/evidence/tanstack-start-build-output.log b/docs/evidence/tanstack-start-build-output.log index 9f76dbf..cdace3b 100644 --- a/docs/evidence/tanstack-start-build-output.log +++ b/docs/evidence/tanstack-start-build-output.log @@ -12,96 +12,119 @@ vite v7.3.6 building client environment for production... transforming... -✓ 2261 modules transformed. +✓ 2264 modules transformed. rendering chunks... computing gzip size... -.output/public/assets/globals-BfHS0732.css 60.37 kB │ gzip: 10.83 kB -.output/public/assets/index-CsG5DCGJ.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/_id-CsG5DCGJ.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/index-Co8OtIp4.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/_id-Co8OtIp4.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/index-D-Mcm3hL.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/_id-Dtmjdwjk.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/index-DudAQX-N.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/_id-aVngqhpK.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/new-DGos6zmu.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/index-Bvqigct7.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/_id-3ygCt6wx.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/new-BrYhFgyO.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/index-C58ZziXt.js 0.20 kB │ gzip: 0.17 kB -.output/public/assets/_id-Py15Lyn1.js 0.20 kB │ gzip: 0.17 kB -.output/public/assets/router-BcqWrx6N.js 1.17 kB │ gzip: 0.58 kB -.output/public/assets/_id-D2cfGIqE.js 1.55 kB │ gzip: 0.83 kB -.output/public/assets/login-eNihrTGY.js 1.97 kB │ gzip: 1.12 kB -.output/public/assets/settings-1UHFaEu3.js 5.24 kB │ gzip: 1.83 kB -.output/public/assets/audit-JYBfP0wO.js 5.48 kB │ gzip: 2.26 kB -.output/public/assets/_id-7qY5OilA.js 6.36 kB │ gzip: 2.57 kB -.output/public/assets/gateways-DOWh8guy.js 6.66 kB │ gzip: 2.72 kB -.output/public/assets/browser-versions-BAnXMdlg.js 6.90 kB │ gzip: 2.49 kB -.output/public/assets/TasksPage-a7e5aXfT.js 9.92 kB │ gzip: 3.66 kB -.output/public/assets/NetworkExitsPage-CqVKiFZM.js 10.58 kB │ gzip: 4.02 kB -.output/public/assets/edit-BSed3AXC.js 16.15 kB │ gzip: 5.38 kB -.output/public/assets/hooks-CUARYhEu.js 16.80 kB │ gzip: 6.18 kB -.output/public/assets/CreatorCompetitorsPage-C4XKesLt.js 16.83 kB │ gzip: 5.57 kB -.output/public/assets/BrowsersPage-BKi6Y14m.js 17.33 kB │ gzip: 5.93 kB -.output/public/assets/AccountsPage-C8TEUDI1.js 17.47 kB │ gzip: 6.19 kB -.output/public/assets/workbench-CK1in_8p.js 24.87 kB │ gzip: 7.50 kB -.output/public/assets/ui-CllpnY_H.js 40.61 kB │ gzip: 13.02 kB -.output/public/assets/_app-BZqnU8qZ.js 97.09 kB │ gzip: 32.83 kB -.output/public/assets/index-Cza8C3Wx.js 352.53 kB │ gzip: 113.91 kB -✓ built in 6.72s +.output/public/assets/globals-CoVNgUxD.css 68.03 kB │ gzip: 11.93 kB +.output/public/assets/check-BTWvnlST.js 0.17 kB │ gzip: 0.17 kB +.output/public/assets/x-D6-KZMGf.js 0.20 kB │ gzip: 0.18 kB +.output/public/assets/index-DBi-ej42.js 0.43 kB │ gzip: 0.26 kB +.output/public/assets/_id-DasOT4Ax.js 0.43 kB │ gzip: 0.26 kB +.output/public/assets/index--JBrEK2H.js 0.46 kB │ gzip: 0.27 kB +.output/public/assets/_id-D06L0s8f.js 0.46 kB │ gzip: 0.27 kB +.output/public/assets/new-CCdcI9kl.js 0.46 kB │ gzip: 0.27 kB +.output/public/assets/index-Caki9M3j.js 0.49 kB │ gzip: 0.29 kB +.output/public/assets/_id-Bsgnsb3c.js 0.49 kB │ gzip: 0.29 kB +.output/public/assets/index-sWfqbR2u.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/_id-sWfqbR2u.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/index-CYRPSwdo.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/_id-CYRPSwdo.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/index-BQLau5zT.js 0.51 kB │ gzip: 0.30 kB +.output/public/assets/_id-SsXYJGPd.js 0.51 kB │ gzip: 0.30 kB +.output/public/assets/new-Cld1hv3M.js 0.51 kB │ gzip: 0.30 kB +.output/public/assets/index-DOdsECLP.js 0.64 kB │ gzip: 0.41 kB +.output/public/assets/textarea-4QWccc8v.js 0.65 kB │ gzip: 0.40 kB +.output/public/assets/input-BxUy5PmW.js 0.84 kB │ gzip: 0.46 kB +.output/public/assets/router-jODNj9UV.js 0.97 kB │ gzip: 0.49 kB +.output/public/assets/table-DgCUbO86.js 1.26 kB │ gzip: 0.50 kB +.output/public/assets/_id-C8KhOjUV.js 1.58 kB │ gzip: 0.83 kB +.output/public/assets/login-Ct2Lo2uR.js 2.02 kB │ gzip: 1.14 kB +.output/public/assets/dialog-B6oikvpX.js 2.22 kB │ gzip: 0.87 kB +.output/public/assets/createLucideIcon-cRASptZK.js 3.09 kB │ gzip: 1.50 kB +.output/public/assets/checkbox-DbdZ-t9N.js 4.63 kB │ gzip: 1.98 kB +.output/public/assets/settings-DFvnR0Bw.js 5.47 kB │ gzip: 1.93 kB +.output/public/assets/_id-U5fjOCqv.js 6.62 kB │ gzip: 2.70 kB +.output/public/assets/gateways-0pMbVgl5.js 6.97 kB │ gzip: 2.81 kB +.output/public/assets/TasksPage-_OcS1HAK.js 10.39 kB │ gzip: 3.75 kB +.output/public/assets/ui-BbD75y55.js 11.18 kB │ gzip: 3.79 kB +.output/public/assets/NetworkExitsPage-DFyYJ472.js 11.42 kB │ gzip: 4.20 kB +.output/public/assets/BrowsersPage-CzrJgNrl.js 15.85 kB │ gzip: 5.49 kB +.output/public/assets/hooks-gk0iQ7nz.js 16.47 kB │ gzip: 6.09 kB +.output/public/assets/CreatorCompetitorsPage-WV3eSWYU.js 16.80 kB │ gzip: 5.52 kB +.output/public/assets/edit-DAXAV74A.js 17.20 kB │ gzip: 5.59 kB +.output/public/assets/AccountsPage-Bd4lMQOT.js 18.74 kB │ gzip: 6.39 kB +.output/public/assets/workbench-CqYMnnIk.js 25.54 kB │ gzip: 7.69 kB +.output/public/assets/index-b_yepYTd.js 27.79 kB │ gzip: 10.78 kB +.output/public/assets/_app-enX8Tk0h.js 28.04 kB │ gzip: 9.22 kB +.output/public/assets/select-BcSobVBH.js 30.47 kB │ gzip: 10.45 kB +.output/public/assets/button-Ds5HsLIh.js 68.69 kB │ gzip: 22.64 kB +.output/public/assets/index-DwCkHzK9.js 352.87 kB │ gzip: 114.04 kB +✓ built in 6.92s vite v7.3.6 building ssr environment for production... transforming... -✓ 132 modules transformed. +src/components/ui/checkbox.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/checkbox.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/checkbox.tsx" was ignored. +src/components/ui/table.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/table.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/table.tsx" was ignored. +src/components/ui/select.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/select.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/select.tsx" was ignored. +src/components/ui/alert-dialog.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/alert-dialog.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/alert-dialog.tsx" was ignored. +✓ 135 modules transformed. rendering chunks... -node_modules/.nitro/vite/services/ssr/assets/globals-BfHS0732.css 60.37 kB +node_modules/.nitro/vite/services/ssr/assets/globals-CoVNgUxD.css 68.03 kB node_modules/.nitro/vite/services/ssr/assets/empty-plugin-adapters-BFgPZ6_d.js 0.13 kB -node_modules/.nitro/vite/services/ssr/assets/index-Bt2NgmdU.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/_id-Bt2NgmdU.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/index-Ubo3_j4B.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/_id-Ubo3_j4B.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/index-B_YNwDp_.js 0.39 kB -node_modules/.nitro/vite/services/ssr/assets/_id-C1xcg7PO.js 0.39 kB -node_modules/.nitro/vite/services/ssr/assets/index-C9-OnRpT.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/index-HGOJgejq.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/_id-Bl6WkXu4.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/_id-DdmeO6sY.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/new-jJmcUGJh.js 0.41 kB -node_modules/.nitro/vite/services/ssr/assets/new-Db5H0C08.js 0.41 kB -node_modules/.nitro/vite/services/ssr/assets/index-DZcqe8yj.js 0.41 kB -node_modules/.nitro/vite/services/ssr/assets/_id-CpDfhj4N.js 0.41 kB +node_modules/.nitro/vite/services/ssr/assets/index-B18Wt7Rb.js 0.57 kB +node_modules/.nitro/vite/services/ssr/assets/_id-Dcs60ml3.js 0.57 kB +node_modules/.nitro/vite/services/ssr/assets/index-CZDrLeiX.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/_id-CZDrLeiX.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/index-C6QqpUuQ.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/_id-C6QqpUuQ.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/index-C5ySTwqs.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/_id-NfgyLIuM.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/index-465CwWUy.js 0.62 kB +node_modules/.nitro/vite/services/ssr/assets/new-CZa11neJ.js 0.62 kB +node_modules/.nitro/vite/services/ssr/assets/_id-veCy1f8n.js 0.62 kB +node_modules/.nitro/vite/services/ssr/assets/index-UxzEAmCG.js 0.64 kB +node_modules/.nitro/vite/services/ssr/assets/_id-cZV52mpe.js 0.65 kB +node_modules/.nitro/vite/services/ssr/assets/new-Cc8G49qw.js 0.65 kB +node_modules/.nitro/vite/services/ssr/assets/textarea-D-GYobUW.js 0.78 kB +node_modules/.nitro/vite/services/ssr/assets/input-D_UCgacf.js 0.98 kB +node_modules/.nitro/vite/services/ssr/assets/checkbox-BV6E_pNe.js 1.22 kB node_modules/.nitro/vite/services/ssr/assets/start-2OBjz-AK.js 1.25 kB -node_modules/.nitro/vite/services/ssr/assets/router-BEAgmVL5.js 2.29 kB -node_modules/.nitro/vite/services/ssr/assets/_id-Dl9i5bv2.js 2.68 kB -node_modules/.nitro/vite/services/ssr/assets/login-iHRtR4Q8.js 3.49 kB -node_modules/.nitro/vite/services/ssr/assets/_tanstack-start-manifest_v-Bq5WSLzH.js 6.16 kB -node_modules/.nitro/vite/services/ssr/assets/audit-jUy15DQG.js 9.29 kB -node_modules/.nitro/vite/services/ssr/assets/settings-BEX0DcYG.js 9.59 kB -node_modules/.nitro/vite/services/ssr/assets/_id-C0H2ocCN.js 11.01 kB -node_modules/.nitro/vite/services/ssr/assets/router-DMSpT4eb.js 12.00 kB -node_modules/.nitro/vite/services/ssr/assets/hooks-CC99r3Ze.js 13.38 kB -node_modules/.nitro/vite/services/ssr/assets/gateways-XY4c2UYn.js 13.60 kB -node_modules/.nitro/vite/services/ssr/assets/browser-versions-CIndr7jQ.js 14.61 kB -node_modules/.nitro/vite/services/ssr/assets/TasksPage-Dikv0xE0.js 15.26 kB -node_modules/.nitro/vite/services/ssr/assets/NetworkExitsPage-BAadxOy8.js 20.71 kB -node_modules/.nitro/vite/services/ssr/assets/ui-B5IXvEeg.js 22.81 kB -node_modules/.nitro/vite/services/ssr/assets/_app-JzZmo9on.js 28.72 kB -node_modules/.nitro/vite/services/ssr/assets/BrowsersPage-CDJ0_wMN.js 34.02 kB -node_modules/.nitro/vite/services/ssr/assets/CreatorCompetitorsPage-B31dablC.js 34.28 kB -node_modules/.nitro/vite/services/ssr/assets/AccountsPage-CUk7RhyZ.js 35.33 kB -node_modules/.nitro/vite/services/ssr/assets/edit-Bz3UEY_p.js 35.98 kB -node_modules/.nitro/vite/services/ssr/assets/workbench-COeyzdj3.js 52.24 kB +node_modules/.nitro/vite/services/ssr/assets/router-D9z5XXDY.js 1.81 kB +node_modules/.nitro/vite/services/ssr/assets/table-CwmrfYPN.js 1.92 kB +node_modules/.nitro/vite/services/ssr/assets/button-Cg57S3sq.js 2.41 kB +node_modules/.nitro/vite/services/ssr/assets/_id-DfgcwEj3.js 2.75 kB +node_modules/.nitro/vite/services/ssr/assets/dialog-BbFU0Kqh.js 3.50 kB +node_modules/.nitro/vite/services/ssr/assets/login-B215411A.js 3.57 kB +node_modules/.nitro/vite/services/ssr/assets/select-C0rreKGM.js 5.27 kB +node_modules/.nitro/vite/services/ssr/assets/_tanstack-start-manifest_v-CsuSNcy0.js 9.11 kB +node_modules/.nitro/vite/services/ssr/assets/settings-6dBT2H8K.js 9.65 kB +node_modules/.nitro/vite/services/ssr/assets/_id-BIY1KBCV.js 11.27 kB +node_modules/.nitro/vite/services/ssr/assets/router-DLYWTuev.js 11.31 kB +node_modules/.nitro/vite/services/ssr/assets/hooks-DLwTm0O1.js 12.86 kB +node_modules/.nitro/vite/services/ssr/assets/gateways-DSV5hz3S.js 13.45 kB +node_modules/.nitro/vite/services/ssr/assets/ui-D55qbO-n.js 14.91 kB +node_modules/.nitro/vite/services/ssr/assets/TasksPage-B4R9z74m.js 16.61 kB +node_modules/.nitro/vite/services/ssr/assets/NetworkExitsPage-rdMoqQrJ.js 21.51 kB +node_modules/.nitro/vite/services/ssr/assets/_app-CW_2ry8H.js 29.59 kB +node_modules/.nitro/vite/services/ssr/assets/BrowsersPage-EeLnyMQX.js 30.28 kB +node_modules/.nitro/vite/services/ssr/assets/CreatorCompetitorsPage-ClipCI5g.js 33.35 kB +node_modules/.nitro/vite/services/ssr/assets/AccountsPage-B0kyNZOG.js 34.73 kB +node_modules/.nitro/vite/services/ssr/assets/edit-CJOop2PM.js 37.09 kB +node_modules/.nitro/vite/services/ssr/assets/workbench-BLeUfnhv.js 52.77 kB node_modules/.nitro/vite/services/ssr/index.js 67.00 kB -✓ built in 1.49s +✓ built in 1.42s -[nitro] ◐ Building [Nitro] (preset: node-server, compatibility: 2026-09-18) +[nitro] ◐ Building [Nitro] (preset: node-server, compatibility: 2026-09-21) [nitro] ✔ Generated public .output/public vite v7.3.6 building nitro environment for production... transforming... -✓ 2282 modules transformed. +✓ 2287 modules transformed. ℹ Tracing dependencies: - tslib (2.8.1) -✔ Traced 1 dependencies (4 files) in 255ms. +✔ Traced 1 dependencies (4 files) in 282ms. ℹ Ensure your production environment matches the builder OS and architecture (linux-x64) to avoid native module issues. Generated an empty chunk: "_libs/crossws". Generated an empty chunk: "_libs/hookable". @@ -113,7 +136,6 @@ Generated an empty chunk: "_libs/croner". Generated an empty chunk: "_libs/@tanstack/store". Generated an empty chunk: "_libs/use-sync-external-store". Generated an empty chunk: "_libs/detect-node-es". -Generated an empty chunk: "_libs/@radix-ui/number". Generated an empty chunk: "_libs/radix-ui". Generated an empty chunk: "_libs/tslib". rendering chunks... @@ -127,18 +149,13 @@ rendering chunks... .output/server/_libs/tanstack__store.mjs 0.00 kB .output/server/_libs/use-sync-external-store.mjs 0.00 kB .output/server/_libs/detect-node-es.mjs 0.00 kB -.output/server/_libs/radix-ui__number.mjs 0.00 kB .output/server/_libs/radix-ui.mjs 0.00 kB .output/server/_libs/tslib.mjs 0.00 kB .output/server/_libs/tanstack__react-store.mjs 0.02 kB -.output/server/_libs/radix-ui__react-collection.mjs 0.02 kB .output/server/_libs/radix-ui__react-collapsible.mjs 0.02 kB -.output/server/_libs/radix-ui__react-direction.mjs 0.02 kB .output/server/_libs/radix-ui__react-accordion.mjs 0.02 kB -.output/server/_libs/radix-ui__react-alert-dialog.mjs 0.02 kB .output/server/_libs/radix-ui__react-aspect-ratio.mjs 0.02 kB .output/server/_libs/radix-ui__react-avatar.mjs 0.02 kB -.output/server/_libs/radix-ui__react-checkbox.mjs 0.02 kB .output/server/_libs/radix-ui__react-roving-focus.mjs 0.02 kB .output/server/_libs/radix-ui__react-menu.mjs 0.02 kB .output/server/_libs/radix-ui__react-context-menu.mjs 0.02 kB @@ -147,7 +164,6 @@ rendering chunks... .output/server/_libs/radix-ui__react-form.mjs 0.02 kB .output/server/_libs/radix-ui__react-hover-card.mjs 0.02 kB .output/server/_libs/radix-ui__react-menubar.mjs 0.02 kB -.output/server/_libs/radix-ui__react-use-previous.mjs 0.02 kB .output/server/_libs/radix-ui__react-popover.mjs 0.02 kB .output/server/_libs/radix-ui__react-progress.mjs 0.02 kB .output/server/_libs/radix-ui__react-radio-group.mjs 0.02 kB @@ -160,7 +176,6 @@ rendering chunks... .output/server/_libs/radix-ui__react-toolbar.mjs 0.02 kB .output/server/_libs/@radix-ui/react-accessible-icon+[...].mjs 0.02 kB .output/server/_libs/@radix-ui/react-use-is-hydrated+[...].mjs 0.02 kB -.output/server/_libs/radix-ui__react-select.mjs 0.05 kB .output/server/_libs/radix-ui__react-toast.mjs 0.05 kB .output/server/_libs/@radix-ui/react-navigation-menu+[...].mjs 0.05 kB .output/server/_libs/@radix-ui/react-one-time-password-field+[...].mjs 0.05 kB @@ -168,33 +183,25 @@ rendering chunks... .output/server/_ssr/empty-plugin-adapters-BFgPZ6_d.mjs 0.13 kB .output/server/_libs/get-nonce.mjs 0.16 kB .output/server/_libs/@radix-ui/react-use-layout-effect+[...].mjs 0.17 kB +.output/server/_libs/radix-ui__number.mjs 0.26 kB .output/server/_libs/rou3.mjs 0.40 kB +.output/server/_libs/radix-ui__react-direction.mjs 0.45 kB .output/server/_libs/@radix-ui/react-use-callback-ref+[...].mjs 0.51 kB .output/server/_libs/clsx.mjs 0.51 kB -.output/server/_ssr/index-Bt2NgmdU.mjs 0.52 kB -.output/server/_ssr/index-Ubo3_j4B.mjs 0.52 kB -.output/server/_id-Bt2NgmdU.mjs 0.53 kB -.output/server/_id-Ubo3_j4B.mjs 0.53 kB +.output/server/_libs/radix-ui__react-use-previous.mjs 0.55 kB .output/server/_chunks/ssr-renderer.mjs 0.61 kB .output/server/_libs/radix-ui__react-id.mjs 0.69 kB -.output/server/_ssr/index-B_YNwDp_.mjs 0.80 kB -.output/server/_ssr/index-C9-OnRpT.mjs 0.81 kB -.output/server/_ssr/index-HGOJgejq.mjs 0.81 kB -.output/server/_id-C1xcg7PO.mjs 0.81 kB -.output/server/_id-Bl6WkXu4.mjs 0.82 kB -.output/server/_id-DdmeO6sY.mjs 0.82 kB -.output/server/_ssr/new-jJmcUGJh.mjs 0.82 kB -.output/server/_ssr/new-Db5H0C08.mjs 0.82 kB -.output/server/_ssr/index-DZcqe8yj.mjs 0.82 kB -.output/server/_id-CpDfhj4N.mjs 0.83 kB +.output/server/_ssr/textarea-D-GYobUW.mjs 0.82 kB .output/server/_libs/radix-ui__react-arrow.mjs 0.88 kB .output/server/_libs/radix-ui__react-portal.mjs 0.99 kB -.output/server/_libs/@radix-ui/react-visually-hidden+[...].mjs 1.01 kB +.output/server/_ssr/input-D_UCgacf.mjs 1.02 kB +.output/server/_libs/@radix-ui/react-visually-hidden+[...].mjs 1.04 kB .output/server/_libs/@radix-ui/react-use-effect-event+[...].mjs 1.04 kB .output/server/_libs/radix-ui__react-compose-refs.mjs 1.17 kB .output/server/_libs/radix-ui__react-separator.mjs 1.29 kB .output/server/_libs/ufo.mjs 1.31 kB .output/server/_libs/radix-ui__react-primitive.mjs 1.32 kB +.output/server/_ssr/checkbox-BV6E_pNe.mjs 1.34 kB .output/server/_libs/radix-ui__react-focus-guards.mjs 1.41 kB .output/server/_libs/radix-ui__react-use-size.mjs 1.42 kB .output/server/_libs/cookie-es.mjs 1.46 kB @@ -202,63 +209,83 @@ rendering chunks... .output/server/_libs/react-style-singleton.mjs 1.67 kB .output/server/_libs/use-callback-ref.mjs 1.75 kB .output/server/_libs/radix-ui__primitive.mjs 1.86 kB +.output/server/_ssr/router-D9z5XXDY.mjs 1.91 kB .output/server/_libs/tanstack__history.mjs 2.04 kB +.output/server/_ssr/table-CwmrfYPN.mjs 2.07 kB .output/server/_libs/class-variance-authority.mjs 2.14 kB -.output/server/_ssr/router-BEAgmVL5.mjs 2.38 kB +.output/server/_ssr/button-Cg57S3sq.mjs 2.53 kB +.output/server/_ssr/index-CZDrLeiX.mjs 2.59 kB +.output/server/_id-CZDrLeiX.mjs 2.59 kB +.output/server/_ssr/index-C6QqpUuQ.mjs 2.59 kB +.output/server/_id-C6QqpUuQ.mjs 2.59 kB .output/server/_libs/use-sidecar.mjs 2.65 kB +.output/server/_id-Dcs60ml3.mjs 2.79 kB +.output/server/_ssr/index-B18Wt7Rb.mjs 2.80 kB .output/server/_libs/isbot.mjs 2.83 kB +.output/server/_id-veCy1f8n.mjs 2.85 kB +.output/server/_ssr/index-465CwWUy.mjs 2.85 kB +.output/server/_ssr/index-UxzEAmCG.mjs 2.87 kB +.output/server/_id-cZV52mpe.mjs 2.88 kB +.output/server/_ssr/new-Cc8G49qw.mjs 2.89 kB +.output/server/_id-NfgyLIuM.mjs 2.89 kB +.output/server/_ssr/index-C5ySTwqs.mjs 2.89 kB +.output/server/_ssr/new-CZa11neJ.mjs 2.90 kB .output/server/_libs/seroval-plugins.mjs 2.96 kB -.output/server/_id-Dl9i5bv2.mjs 3.37 kB .output/server/_libs/@radix-ui/react-use-controllable-state+[...].mjs 3.76 kB +.output/server/_ssr/dialog-BbFU0Kqh.mjs 3.88 kB .output/server/_libs/react-remove-scroll-bar.mjs 3.96 kB .output/server/_libs/aria-hidden.mjs 4.06 kB -.output/server/_ssr/login-iHRtR4Q8.mjs 4.12 kB .output/server/_libs/radix-ui__react-context.mjs 4.30 kB +.output/server/_id-DfgcwEj3.mjs 4.66 kB .output/server/_libs/@tanstack/react-router-with-query+[...].mjs 5.06 kB +.output/server/_ssr/login-B215411A.mjs 5.41 kB +.output/server/_libs/radix-ui__react-alert-dialog.mjs 5.61 kB +.output/server/_ssr/select-C0rreKGM.mjs 5.87 kB .output/server/_libs/tanstack__react-query.mjs 5.98 kB -.output/server/_tanstack-start-manifest_v-Bq5WSLzH.mjs 6.16 kB .output/server/_libs/radix-ui__react-slot.mjs 6.23 kB .output/server/_libs/radix-ui__react-presence.mjs 6.56 kB -.output/server/_libs/lucide-react.mjs 7.26 kB +.output/server/_libs/lucide-react.mjs 7.87 kB .output/server/_libs/floating-ui__react-dom.mjs 8.42 kB +.output/server/_tanstack-start-manifest_v-CsuSNcy0.mjs 9.11 kB .output/server/_libs/radix-ui__react-focus-scope.mjs 9.27 kB .output/server/_libs/floating-ui__utils.mjs 9.73 kB .output/server/_libs/h3-v2.mjs 9.98 kB -.output/server/_ssr/settings-BEX0DcYG.mjs 10.55 kB -.output/server/_ssr/audit-jUy15DQG.mjs 10.59 kB -.output/server/_ssr/router-DMSpT4eb.mjs 12.28 kB -.output/server/_libs/radix-ui__react-dialog.mjs 12.40 kB -.output/server/_id-C0H2ocCN.mjs 12.43 kB +.output/server/_libs/radix-ui__react-checkbox.mjs 10.17 kB +.output/server/_ssr/router-DLYWTuev.mjs 11.63 kB +.output/server/_ssr/settings-6dBT2H8K.mjs 11.92 kB +.output/server/_ssr/hooks-DLwTm0O1.mjs 12.95 kB +.output/server/_libs/radix-ui__react-dialog.mjs 13.28 kB .output/server/_libs/react-remove-scroll.mjs 13.37 kB -.output/server/_ssr/hooks-CC99r3Ze.mjs 13.47 kB .output/server/_libs/radix-ui__react-popper.mjs 13.91 kB +.output/server/_id-BIY1KBCV.mjs 14.05 kB .output/server/_libs/@radix-ui/react-dismissable-layer+[...].mjs 14.28 kB -.output/server/_ssr/gateways-XY4c2UYn.mjs 14.65 kB -.output/server/_ssr/browser-versions-CIndr7jQ.mjs 15.65 kB -.output/server/_ssr/TasksPage-Dikv0xE0.mjs 16.94 kB +.output/server/_ssr/gateways-DSV5hz3S.mjs 15.99 kB +.output/server/_ssr/ui-D55qbO-n.mjs 16.07 kB .output/server/_libs/h3.mjs 17.53 kB -.output/server/index.mjs 18.49 kB +.output/server/_ssr/TasksPage-B4R9z74m.mjs 18.72 kB +.output/server/_libs/radix-ui__react-collection.mjs 18.81 kB .output/server/_libs/react.mjs 19.93 kB .output/server/_libs/floating-ui__dom.mjs 20.85 kB +.output/server/index.mjs 20.92 kB .output/server/_libs/radix-ui__react-tooltip.mjs 20.98 kB -.output/server/_ssr/NetworkExitsPage-BAadxOy8.mjs 22.07 kB .output/server/_libs/floating-ui__core.mjs 23.42 kB -.output/server/_ssr/ui-B5IXvEeg.mjs 24.41 kB -.output/server/_app-JzZmo9on.mjs 32.54 kB -.output/server/_ssr/BrowsersPage-CDJ0_wMN.mjs 36.60 kB +.output/server/_ssr/NetworkExitsPage-rdMoqQrJ.mjs 23.44 kB +.output/server/_ssr/BrowsersPage-EeLnyMQX.mjs 33.18 kB +.output/server/_app-CW_2ry8H.mjs 33.40 kB +.output/server/_ssr/CreatorCompetitorsPage-ClipCI5g.mjs 36.24 kB .output/server/_libs/tanstack__react-router.mjs 36.61 kB -.output/server/_ssr/CreatorCompetitorsPage-B31dablC.mjs 36.88 kB -.output/server/_ssr/AccountsPage-CUk7RhyZ.mjs 37.62 kB -.output/server/_ssr/edit-Bz3UEY_p.mjs 38.22 kB +.output/server/_ssr/AccountsPage-B0kyNZOG.mjs 37.79 kB +.output/server/_ssr/edit-CJOop2PM.mjs 41.72 kB .output/server/_libs/srvx.mjs 44.32 kB -.output/server/_ssr/workbench-COeyzdj3.mjs 55.91 kB +.output/server/_libs/radix-ui__react-select.mjs 52.56 kB +.output/server/_ssr/workbench-BLeUfnhv.mjs 58.77 kB .output/server/_ssr/index.mjs 66.70 kB .output/server/_libs/tanstack__query-core.mjs 76.27 kB .output/server/_libs/seroval.mjs 94.94 kB .output/server/_libs/tailwind-merge.mjs 99.21 kB .output/server/_libs/tanstack__router-core.mjs 179.81 kB .output/server/_libs/react-dom.mjs 509.08 kB -✓ built in 9.53s +✓ built in 9.64s ℹ Generated .output/nitro.json [nitro] ✔ You can preview this build using npx vite preview diff --git a/docs/evidence/tanstack-start-build-warning-baseline.log b/docs/evidence/tanstack-start-build-warning-baseline.log index 8d1681e..cdace3b 100644 --- a/docs/evidence/tanstack-start-build-warning-baseline.log +++ b/docs/evidence/tanstack-start-build-warning-baseline.log @@ -12,103 +12,119 @@ vite v7.3.6 building client environment for production... transforming... -✓ 2261 modules transformed. +✓ 2264 modules transformed. rendering chunks... computing gzip size... -.output/public/assets/globals-BfHS0732.css 60.37 kB │ gzip: 10.83 kB -.output/public/assets/index-CsG5DCGJ.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/_id-CsG5DCGJ.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/index-Co8OtIp4.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/_id-Co8OtIp4.js 0.17 kB │ gzip: 0.15 kB -.output/public/assets/index-D-Mcm3hL.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/_id-Dtmjdwjk.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/index-DudAQX-N.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/_id-aVngqhpK.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/new-DGos6zmu.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/index-Bvqigct7.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/_id-3ygCt6wx.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/new-BrYhFgyO.js 0.19 kB │ gzip: 0.16 kB -.output/public/assets/index-C58ZziXt.js 0.20 kB │ gzip: 0.17 kB -.output/public/assets/_id-Py15Lyn1.js 0.20 kB │ gzip: 0.17 kB -.output/public/assets/router-BcqWrx6N.js 1.17 kB │ gzip: 0.58 kB -.output/public/assets/_id-D2cfGIqE.js 1.55 kB │ gzip: 0.83 kB -.output/public/assets/login-eNihrTGY.js 1.97 kB │ gzip: 1.12 kB -.output/public/assets/settings-1UHFaEu3.js 5.24 kB │ gzip: 1.83 kB -.output/public/assets/audit-JYBfP0wO.js 5.48 kB │ gzip: 2.26 kB -.output/public/assets/_id-7qY5OilA.js 6.36 kB │ gzip: 2.57 kB -.output/public/assets/gateways-DOWh8guy.js 6.66 kB │ gzip: 2.72 kB -.output/public/assets/browser-versions-BAnXMdlg.js 6.90 kB │ gzip: 2.49 kB -.output/public/assets/TasksPage-a7e5aXfT.js 9.92 kB │ gzip: 3.66 kB -.output/public/assets/NetworkExitsPage-CqVKiFZM.js 10.58 kB │ gzip: 4.02 kB -.output/public/assets/edit-BSed3AXC.js 16.15 kB │ gzip: 5.38 kB -.output/public/assets/hooks-CUARYhEu.js 16.80 kB │ gzip: 6.18 kB -.output/public/assets/CreatorCompetitorsPage-C4XKesLt.js 16.83 kB │ gzip: 5.57 kB -.output/public/assets/BrowsersPage-BKi6Y14m.js 17.33 kB │ gzip: 5.93 kB -.output/public/assets/AccountsPage-C8TEUDI1.js 17.47 kB │ gzip: 6.19 kB -.output/public/assets/workbench-CK1in_8p.js 24.87 kB │ gzip: 7.50 kB -.output/public/assets/ui-CllpnY_H.js 40.61 kB │ gzip: 13.02 kB -.output/public/assets/_app-BZqnU8qZ.js 97.09 kB │ gzip: 32.83 kB -.output/public/assets/index-Cza8C3Wx.js 352.53 kB │ gzip: 113.91 kB -✓ built in 6.45s +.output/public/assets/globals-CoVNgUxD.css 68.03 kB │ gzip: 11.93 kB +.output/public/assets/check-BTWvnlST.js 0.17 kB │ gzip: 0.17 kB +.output/public/assets/x-D6-KZMGf.js 0.20 kB │ gzip: 0.18 kB +.output/public/assets/index-DBi-ej42.js 0.43 kB │ gzip: 0.26 kB +.output/public/assets/_id-DasOT4Ax.js 0.43 kB │ gzip: 0.26 kB +.output/public/assets/index--JBrEK2H.js 0.46 kB │ gzip: 0.27 kB +.output/public/assets/_id-D06L0s8f.js 0.46 kB │ gzip: 0.27 kB +.output/public/assets/new-CCdcI9kl.js 0.46 kB │ gzip: 0.27 kB +.output/public/assets/index-Caki9M3j.js 0.49 kB │ gzip: 0.29 kB +.output/public/assets/_id-Bsgnsb3c.js 0.49 kB │ gzip: 0.29 kB +.output/public/assets/index-sWfqbR2u.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/_id-sWfqbR2u.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/index-CYRPSwdo.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/_id-CYRPSwdo.js 0.49 kB │ gzip: 0.30 kB +.output/public/assets/index-BQLau5zT.js 0.51 kB │ gzip: 0.30 kB +.output/public/assets/_id-SsXYJGPd.js 0.51 kB │ gzip: 0.30 kB +.output/public/assets/new-Cld1hv3M.js 0.51 kB │ gzip: 0.30 kB +.output/public/assets/index-DOdsECLP.js 0.64 kB │ gzip: 0.41 kB +.output/public/assets/textarea-4QWccc8v.js 0.65 kB │ gzip: 0.40 kB +.output/public/assets/input-BxUy5PmW.js 0.84 kB │ gzip: 0.46 kB +.output/public/assets/router-jODNj9UV.js 0.97 kB │ gzip: 0.49 kB +.output/public/assets/table-DgCUbO86.js 1.26 kB │ gzip: 0.50 kB +.output/public/assets/_id-C8KhOjUV.js 1.58 kB │ gzip: 0.83 kB +.output/public/assets/login-Ct2Lo2uR.js 2.02 kB │ gzip: 1.14 kB +.output/public/assets/dialog-B6oikvpX.js 2.22 kB │ gzip: 0.87 kB +.output/public/assets/createLucideIcon-cRASptZK.js 3.09 kB │ gzip: 1.50 kB +.output/public/assets/checkbox-DbdZ-t9N.js 4.63 kB │ gzip: 1.98 kB +.output/public/assets/settings-DFvnR0Bw.js 5.47 kB │ gzip: 1.93 kB +.output/public/assets/_id-U5fjOCqv.js 6.62 kB │ gzip: 2.70 kB +.output/public/assets/gateways-0pMbVgl5.js 6.97 kB │ gzip: 2.81 kB +.output/public/assets/TasksPage-_OcS1HAK.js 10.39 kB │ gzip: 3.75 kB +.output/public/assets/ui-BbD75y55.js 11.18 kB │ gzip: 3.79 kB +.output/public/assets/NetworkExitsPage-DFyYJ472.js 11.42 kB │ gzip: 4.20 kB +.output/public/assets/BrowsersPage-CzrJgNrl.js 15.85 kB │ gzip: 5.49 kB +.output/public/assets/hooks-gk0iQ7nz.js 16.47 kB │ gzip: 6.09 kB +.output/public/assets/CreatorCompetitorsPage-WV3eSWYU.js 16.80 kB │ gzip: 5.52 kB +.output/public/assets/edit-DAXAV74A.js 17.20 kB │ gzip: 5.59 kB +.output/public/assets/AccountsPage-Bd4lMQOT.js 18.74 kB │ gzip: 6.39 kB +.output/public/assets/workbench-CqYMnnIk.js 25.54 kB │ gzip: 7.69 kB +.output/public/assets/index-b_yepYTd.js 27.79 kB │ gzip: 10.78 kB +.output/public/assets/_app-enX8Tk0h.js 28.04 kB │ gzip: 9.22 kB +.output/public/assets/select-BcSobVBH.js 30.47 kB │ gzip: 10.45 kB +.output/public/assets/button-Ds5HsLIh.js 68.69 kB │ gzip: 22.64 kB +.output/public/assets/index-DwCkHzK9.js 352.87 kB │ gzip: 114.04 kB +✓ built in 6.92s vite v7.3.6 building ssr environment for production... transforming... -src/components/ui/sidebar.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. -src/components/ui/sidebar.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/sidebar.tsx" was ignored. -src/components/ui/sheet.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. -src/components/ui/sheet.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/sheet.tsx" was ignored. -"createRequestHandler", "defineHandlerCallback", "transformPipeableStreamWithRouter" and "transformReadableStreamWithRouter" are imported from external module "@tanstack/router-core/ssr/server" but never used in "node_modules/@tanstack/start-server-core/dist/esm/index.js". -"RawStream" is imported from external module "@tanstack/router-core" but never used in "node_modules/@tanstack/start-client-core/dist/esm/index.js". -"defaultSerovalDeserializerPlugins", "hydrate" and "json" are imported from external module "@tanstack/router-core/ssr/client" but never used in "node_modules/@tanstack/start-client-core/dist/esm/index.js". -✓ 132 modules transformed. +src/components/ui/checkbox.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/checkbox.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/checkbox.tsx" was ignored. +src/components/ui/table.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/table.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/table.tsx" was ignored. +src/components/ui/select.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/select.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/select.tsx" was ignored. +src/components/ui/alert-dialog.tsx (1:0): Error when using sourcemap for reporting an error: Can't resolve original location of error. +src/components/ui/alert-dialog.tsx (1:0): Module level directives cause errors when bundled, "use client" in "src/components/ui/alert-dialog.tsx" was ignored. +✓ 135 modules transformed. rendering chunks... -node_modules/.nitro/vite/services/ssr/assets/globals-BfHS0732.css 60.37 kB +node_modules/.nitro/vite/services/ssr/assets/globals-CoVNgUxD.css 68.03 kB node_modules/.nitro/vite/services/ssr/assets/empty-plugin-adapters-BFgPZ6_d.js 0.13 kB -node_modules/.nitro/vite/services/ssr/assets/index-DKoErcTm.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/_id-DKoErcTm.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/index-BZV1FRa9.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/_id-BZV1FRa9.js 0.37 kB -node_modules/.nitro/vite/services/ssr/assets/index-CK0kGtkO.js 0.39 kB -node_modules/.nitro/vite/services/ssr/assets/_id-txubC_JC.js 0.39 kB -node_modules/.nitro/vite/services/ssr/assets/index-BFUsw4_9.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/index-Cb30TTqs.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/_id-NE7jq5BM.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/_id-CCnHXj2F.js 0.40 kB -node_modules/.nitro/vite/services/ssr/assets/index-CsRL0VMh.js 0.41 kB -node_modules/.nitro/vite/services/ssr/assets/new-Bbx3I4tu.js 0.41 kB -node_modules/.nitro/vite/services/ssr/assets/new-CiK3GDVQ.js 0.41 kB -node_modules/.nitro/vite/services/ssr/assets/_id-BqIX37IZ.js 0.41 kB +node_modules/.nitro/vite/services/ssr/assets/index-B18Wt7Rb.js 0.57 kB +node_modules/.nitro/vite/services/ssr/assets/_id-Dcs60ml3.js 0.57 kB +node_modules/.nitro/vite/services/ssr/assets/index-CZDrLeiX.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/_id-CZDrLeiX.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/index-C6QqpUuQ.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/_id-C6QqpUuQ.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/index-C5ySTwqs.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/_id-NfgyLIuM.js 0.61 kB +node_modules/.nitro/vite/services/ssr/assets/index-465CwWUy.js 0.62 kB +node_modules/.nitro/vite/services/ssr/assets/new-CZa11neJ.js 0.62 kB +node_modules/.nitro/vite/services/ssr/assets/_id-veCy1f8n.js 0.62 kB +node_modules/.nitro/vite/services/ssr/assets/index-UxzEAmCG.js 0.64 kB +node_modules/.nitro/vite/services/ssr/assets/_id-cZV52mpe.js 0.65 kB +node_modules/.nitro/vite/services/ssr/assets/new-Cc8G49qw.js 0.65 kB +node_modules/.nitro/vite/services/ssr/assets/textarea-D-GYobUW.js 0.78 kB +node_modules/.nitro/vite/services/ssr/assets/input-D_UCgacf.js 0.98 kB +node_modules/.nitro/vite/services/ssr/assets/checkbox-BV6E_pNe.js 1.22 kB node_modules/.nitro/vite/services/ssr/assets/start-2OBjz-AK.js 1.25 kB -node_modules/.nitro/vite/services/ssr/assets/router-BSB_r9HV.js 2.29 kB -node_modules/.nitro/vite/services/ssr/assets/_id-DNz3urOX.js 2.68 kB -node_modules/.nitro/vite/services/ssr/assets/login-BmGCIkoR.js 3.49 kB -node_modules/.nitro/vite/services/ssr/assets/_tanstack-start-manifest_v-Bq5WSLzH.js 6.16 kB -node_modules/.nitro/vite/services/ssr/assets/audit-DhPmgklq.js 9.29 kB -node_modules/.nitro/vite/services/ssr/assets/settings-TGJoXXpX.js 9.59 kB -node_modules/.nitro/vite/services/ssr/assets/_id-5ZqDRRtm.js 11.01 kB -node_modules/.nitro/vite/services/ssr/assets/router-Qws9csAl.js 12.00 kB -node_modules/.nitro/vite/services/ssr/assets/hooks-DjY8n3Bq.js 13.38 kB -node_modules/.nitro/vite/services/ssr/assets/gateways-DKadGH4_.js 13.60 kB -node_modules/.nitro/vite/services/ssr/assets/browser-versions-BT7WG-Jb.js 14.61 kB -node_modules/.nitro/vite/services/ssr/assets/TasksPage-F4Fycrrf.js 15.26 kB -node_modules/.nitro/vite/services/ssr/assets/NetworkExitsPage-CKSCBKj3.js 20.71 kB -node_modules/.nitro/vite/services/ssr/assets/ui-Csle2U8f.js 22.81 kB -node_modules/.nitro/vite/services/ssr/assets/_app-Boy3ZtCu.js 28.72 kB -node_modules/.nitro/vite/services/ssr/assets/BrowsersPage-cokOjmpd.js 34.02 kB -node_modules/.nitro/vite/services/ssr/assets/CreatorCompetitorsPage-raR88Dhj.js 34.28 kB -node_modules/.nitro/vite/services/ssr/assets/AccountsPage-30_KTVlS.js 35.33 kB -node_modules/.nitro/vite/services/ssr/assets/edit-DzV-rjqZ.js 35.98 kB -node_modules/.nitro/vite/services/ssr/assets/workbench-GgbI_GP7.js 52.24 kB +node_modules/.nitro/vite/services/ssr/assets/router-D9z5XXDY.js 1.81 kB +node_modules/.nitro/vite/services/ssr/assets/table-CwmrfYPN.js 1.92 kB +node_modules/.nitro/vite/services/ssr/assets/button-Cg57S3sq.js 2.41 kB +node_modules/.nitro/vite/services/ssr/assets/_id-DfgcwEj3.js 2.75 kB +node_modules/.nitro/vite/services/ssr/assets/dialog-BbFU0Kqh.js 3.50 kB +node_modules/.nitro/vite/services/ssr/assets/login-B215411A.js 3.57 kB +node_modules/.nitro/vite/services/ssr/assets/select-C0rreKGM.js 5.27 kB +node_modules/.nitro/vite/services/ssr/assets/_tanstack-start-manifest_v-CsuSNcy0.js 9.11 kB +node_modules/.nitro/vite/services/ssr/assets/settings-6dBT2H8K.js 9.65 kB +node_modules/.nitro/vite/services/ssr/assets/_id-BIY1KBCV.js 11.27 kB +node_modules/.nitro/vite/services/ssr/assets/router-DLYWTuev.js 11.31 kB +node_modules/.nitro/vite/services/ssr/assets/hooks-DLwTm0O1.js 12.86 kB +node_modules/.nitro/vite/services/ssr/assets/gateways-DSV5hz3S.js 13.45 kB +node_modules/.nitro/vite/services/ssr/assets/ui-D55qbO-n.js 14.91 kB +node_modules/.nitro/vite/services/ssr/assets/TasksPage-B4R9z74m.js 16.61 kB +node_modules/.nitro/vite/services/ssr/assets/NetworkExitsPage-rdMoqQrJ.js 21.51 kB +node_modules/.nitro/vite/services/ssr/assets/_app-CW_2ry8H.js 29.59 kB +node_modules/.nitro/vite/services/ssr/assets/BrowsersPage-EeLnyMQX.js 30.28 kB +node_modules/.nitro/vite/services/ssr/assets/CreatorCompetitorsPage-ClipCI5g.js 33.35 kB +node_modules/.nitro/vite/services/ssr/assets/AccountsPage-B0kyNZOG.js 34.73 kB +node_modules/.nitro/vite/services/ssr/assets/edit-CJOop2PM.js 37.09 kB +node_modules/.nitro/vite/services/ssr/assets/workbench-BLeUfnhv.js 52.77 kB node_modules/.nitro/vite/services/ssr/index.js 67.00 kB -✓ built in 1.29s +✓ built in 1.42s -[nitro] ◐ Building [Nitro] (preset: node-server, compatibility: 2026-09-18) +[nitro] ◐ Building [Nitro] (preset: node-server, compatibility: 2026-09-21) [nitro] ✔ Generated public .output/public vite v7.3.6 building nitro environment for production... transforming... -✓ 2282 modules transformed. +✓ 2287 modules transformed. ℹ Tracing dependencies: - tslib (2.8.1) -✔ Traced 1 dependencies (4 files) in 407ms. +✔ Traced 1 dependencies (4 files) in 282ms. ℹ Ensure your production environment matches the builder OS and architecture (linux-x64) to avoid native module issues. Generated an empty chunk: "_libs/crossws". Generated an empty chunk: "_libs/hookable". @@ -120,7 +136,6 @@ Generated an empty chunk: "_libs/croner". Generated an empty chunk: "_libs/@tanstack/store". Generated an empty chunk: "_libs/use-sync-external-store". Generated an empty chunk: "_libs/detect-node-es". -Generated an empty chunk: "_libs/@radix-ui/number". Generated an empty chunk: "_libs/radix-ui". Generated an empty chunk: "_libs/tslib". rendering chunks... @@ -134,18 +149,13 @@ rendering chunks... .output/server/_libs/tanstack__store.mjs 0.00 kB .output/server/_libs/use-sync-external-store.mjs 0.00 kB .output/server/_libs/detect-node-es.mjs 0.00 kB -.output/server/_libs/radix-ui__number.mjs 0.00 kB .output/server/_libs/radix-ui.mjs 0.00 kB .output/server/_libs/tslib.mjs 0.00 kB .output/server/_libs/tanstack__react-store.mjs 0.02 kB -.output/server/_libs/radix-ui__react-collection.mjs 0.02 kB .output/server/_libs/radix-ui__react-collapsible.mjs 0.02 kB -.output/server/_libs/radix-ui__react-direction.mjs 0.02 kB .output/server/_libs/radix-ui__react-accordion.mjs 0.02 kB -.output/server/_libs/radix-ui__react-alert-dialog.mjs 0.02 kB .output/server/_libs/radix-ui__react-aspect-ratio.mjs 0.02 kB .output/server/_libs/radix-ui__react-avatar.mjs 0.02 kB -.output/server/_libs/radix-ui__react-checkbox.mjs 0.02 kB .output/server/_libs/radix-ui__react-roving-focus.mjs 0.02 kB .output/server/_libs/radix-ui__react-menu.mjs 0.02 kB .output/server/_libs/radix-ui__react-context-menu.mjs 0.02 kB @@ -154,7 +164,6 @@ rendering chunks... .output/server/_libs/radix-ui__react-form.mjs 0.02 kB .output/server/_libs/radix-ui__react-hover-card.mjs 0.02 kB .output/server/_libs/radix-ui__react-menubar.mjs 0.02 kB -.output/server/_libs/radix-ui__react-use-previous.mjs 0.02 kB .output/server/_libs/radix-ui__react-popover.mjs 0.02 kB .output/server/_libs/radix-ui__react-progress.mjs 0.02 kB .output/server/_libs/radix-ui__react-radio-group.mjs 0.02 kB @@ -167,7 +176,6 @@ rendering chunks... .output/server/_libs/radix-ui__react-toolbar.mjs 0.02 kB .output/server/_libs/@radix-ui/react-accessible-icon+[...].mjs 0.02 kB .output/server/_libs/@radix-ui/react-use-is-hydrated+[...].mjs 0.02 kB -.output/server/_libs/radix-ui__react-select.mjs 0.05 kB .output/server/_libs/radix-ui__react-toast.mjs 0.05 kB .output/server/_libs/@radix-ui/react-navigation-menu+[...].mjs 0.05 kB .output/server/_libs/@radix-ui/react-one-time-password-field+[...].mjs 0.05 kB @@ -175,33 +183,25 @@ rendering chunks... .output/server/_ssr/empty-plugin-adapters-BFgPZ6_d.mjs 0.13 kB .output/server/_libs/get-nonce.mjs 0.16 kB .output/server/_libs/@radix-ui/react-use-layout-effect+[...].mjs 0.17 kB +.output/server/_libs/radix-ui__number.mjs 0.26 kB .output/server/_libs/rou3.mjs 0.40 kB +.output/server/_libs/radix-ui__react-direction.mjs 0.45 kB .output/server/_libs/@radix-ui/react-use-callback-ref+[...].mjs 0.51 kB .output/server/_libs/clsx.mjs 0.51 kB -.output/server/_ssr/index-DKoErcTm.mjs 0.52 kB -.output/server/_ssr/index-BZV1FRa9.mjs 0.52 kB -.output/server/_id-DKoErcTm.mjs 0.53 kB -.output/server/_id-BZV1FRa9.mjs 0.53 kB +.output/server/_libs/radix-ui__react-use-previous.mjs 0.55 kB .output/server/_chunks/ssr-renderer.mjs 0.61 kB .output/server/_libs/radix-ui__react-id.mjs 0.69 kB -.output/server/_ssr/index-CK0kGtkO.mjs 0.80 kB -.output/server/_ssr/index-BFUsw4_9.mjs 0.81 kB -.output/server/_ssr/index-Cb30TTqs.mjs 0.81 kB -.output/server/_id-txubC_JC.mjs 0.81 kB -.output/server/_id-NE7jq5BM.mjs 0.82 kB -.output/server/_id-CCnHXj2F.mjs 0.82 kB -.output/server/_ssr/index-CsRL0VMh.mjs 0.82 kB -.output/server/_ssr/new-Bbx3I4tu.mjs 0.82 kB -.output/server/_ssr/new-CiK3GDVQ.mjs 0.82 kB -.output/server/_id-BqIX37IZ.mjs 0.83 kB +.output/server/_ssr/textarea-D-GYobUW.mjs 0.82 kB .output/server/_libs/radix-ui__react-arrow.mjs 0.88 kB .output/server/_libs/radix-ui__react-portal.mjs 0.99 kB -.output/server/_libs/@radix-ui/react-visually-hidden+[...].mjs 1.01 kB +.output/server/_ssr/input-D_UCgacf.mjs 1.02 kB +.output/server/_libs/@radix-ui/react-visually-hidden+[...].mjs 1.04 kB .output/server/_libs/@radix-ui/react-use-effect-event+[...].mjs 1.04 kB .output/server/_libs/radix-ui__react-compose-refs.mjs 1.17 kB .output/server/_libs/radix-ui__react-separator.mjs 1.29 kB .output/server/_libs/ufo.mjs 1.31 kB .output/server/_libs/radix-ui__react-primitive.mjs 1.32 kB +.output/server/_ssr/checkbox-BV6E_pNe.mjs 1.34 kB .output/server/_libs/radix-ui__react-focus-guards.mjs 1.41 kB .output/server/_libs/radix-ui__react-use-size.mjs 1.42 kB .output/server/_libs/cookie-es.mjs 1.46 kB @@ -209,63 +209,83 @@ rendering chunks... .output/server/_libs/react-style-singleton.mjs 1.67 kB .output/server/_libs/use-callback-ref.mjs 1.75 kB .output/server/_libs/radix-ui__primitive.mjs 1.86 kB +.output/server/_ssr/router-D9z5XXDY.mjs 1.91 kB .output/server/_libs/tanstack__history.mjs 2.04 kB +.output/server/_ssr/table-CwmrfYPN.mjs 2.07 kB .output/server/_libs/class-variance-authority.mjs 2.14 kB -.output/server/_ssr/router-BSB_r9HV.mjs 2.38 kB +.output/server/_ssr/button-Cg57S3sq.mjs 2.53 kB +.output/server/_ssr/index-CZDrLeiX.mjs 2.59 kB +.output/server/_id-CZDrLeiX.mjs 2.59 kB +.output/server/_ssr/index-C6QqpUuQ.mjs 2.59 kB +.output/server/_id-C6QqpUuQ.mjs 2.59 kB .output/server/_libs/use-sidecar.mjs 2.65 kB +.output/server/_id-Dcs60ml3.mjs 2.79 kB +.output/server/_ssr/index-B18Wt7Rb.mjs 2.80 kB .output/server/_libs/isbot.mjs 2.83 kB +.output/server/_id-veCy1f8n.mjs 2.85 kB +.output/server/_ssr/index-465CwWUy.mjs 2.85 kB +.output/server/_ssr/index-UxzEAmCG.mjs 2.87 kB +.output/server/_id-cZV52mpe.mjs 2.88 kB +.output/server/_ssr/new-Cc8G49qw.mjs 2.89 kB +.output/server/_id-NfgyLIuM.mjs 2.89 kB +.output/server/_ssr/index-C5ySTwqs.mjs 2.89 kB +.output/server/_ssr/new-CZa11neJ.mjs 2.90 kB .output/server/_libs/seroval-plugins.mjs 2.96 kB -.output/server/_id-DNz3urOX.mjs 3.37 kB .output/server/_libs/@radix-ui/react-use-controllable-state+[...].mjs 3.76 kB +.output/server/_ssr/dialog-BbFU0Kqh.mjs 3.88 kB .output/server/_libs/react-remove-scroll-bar.mjs 3.96 kB .output/server/_libs/aria-hidden.mjs 4.06 kB -.output/server/_ssr/login-BmGCIkoR.mjs 4.12 kB .output/server/_libs/radix-ui__react-context.mjs 4.30 kB +.output/server/_id-DfgcwEj3.mjs 4.66 kB .output/server/_libs/@tanstack/react-router-with-query+[...].mjs 5.06 kB +.output/server/_ssr/login-B215411A.mjs 5.41 kB +.output/server/_libs/radix-ui__react-alert-dialog.mjs 5.61 kB +.output/server/_ssr/select-C0rreKGM.mjs 5.87 kB .output/server/_libs/tanstack__react-query.mjs 5.98 kB -.output/server/_tanstack-start-manifest_v-Bq5WSLzH.mjs 6.16 kB .output/server/_libs/radix-ui__react-slot.mjs 6.23 kB .output/server/_libs/radix-ui__react-presence.mjs 6.56 kB -.output/server/_libs/lucide-react.mjs 7.26 kB +.output/server/_libs/lucide-react.mjs 7.87 kB .output/server/_libs/floating-ui__react-dom.mjs 8.42 kB +.output/server/_tanstack-start-manifest_v-CsuSNcy0.mjs 9.11 kB .output/server/_libs/radix-ui__react-focus-scope.mjs 9.27 kB .output/server/_libs/floating-ui__utils.mjs 9.73 kB .output/server/_libs/h3-v2.mjs 9.98 kB -.output/server/_ssr/settings-TGJoXXpX.mjs 10.55 kB -.output/server/_ssr/audit-DhPmgklq.mjs 10.59 kB -.output/server/_ssr/router-Qws9csAl.mjs 12.28 kB -.output/server/_libs/radix-ui__react-dialog.mjs 12.40 kB -.output/server/_id-5ZqDRRtm.mjs 12.43 kB +.output/server/_libs/radix-ui__react-checkbox.mjs 10.17 kB +.output/server/_ssr/router-DLYWTuev.mjs 11.63 kB +.output/server/_ssr/settings-6dBT2H8K.mjs 11.92 kB +.output/server/_ssr/hooks-DLwTm0O1.mjs 12.95 kB +.output/server/_libs/radix-ui__react-dialog.mjs 13.28 kB .output/server/_libs/react-remove-scroll.mjs 13.37 kB -.output/server/_ssr/hooks-DjY8n3Bq.mjs 13.47 kB .output/server/_libs/radix-ui__react-popper.mjs 13.91 kB +.output/server/_id-BIY1KBCV.mjs 14.05 kB .output/server/_libs/@radix-ui/react-dismissable-layer+[...].mjs 14.28 kB -.output/server/_ssr/gateways-DKadGH4_.mjs 14.65 kB -.output/server/_ssr/browser-versions-BT7WG-Jb.mjs 15.65 kB -.output/server/_ssr/TasksPage-F4Fycrrf.mjs 16.94 kB +.output/server/_ssr/gateways-DSV5hz3S.mjs 15.99 kB +.output/server/_ssr/ui-D55qbO-n.mjs 16.07 kB .output/server/_libs/h3.mjs 17.53 kB -.output/server/index.mjs 18.49 kB +.output/server/_ssr/TasksPage-B4R9z74m.mjs 18.72 kB +.output/server/_libs/radix-ui__react-collection.mjs 18.81 kB .output/server/_libs/react.mjs 19.93 kB .output/server/_libs/floating-ui__dom.mjs 20.85 kB +.output/server/index.mjs 20.92 kB .output/server/_libs/radix-ui__react-tooltip.mjs 20.98 kB -.output/server/_ssr/NetworkExitsPage-CKSCBKj3.mjs 22.07 kB .output/server/_libs/floating-ui__core.mjs 23.42 kB -.output/server/_ssr/ui-Csle2U8f.mjs 24.41 kB -.output/server/_app-Boy3ZtCu.mjs 32.54 kB -.output/server/_ssr/BrowsersPage-cokOjmpd.mjs 36.60 kB +.output/server/_ssr/NetworkExitsPage-rdMoqQrJ.mjs 23.44 kB +.output/server/_ssr/BrowsersPage-EeLnyMQX.mjs 33.18 kB +.output/server/_app-CW_2ry8H.mjs 33.40 kB +.output/server/_ssr/CreatorCompetitorsPage-ClipCI5g.mjs 36.24 kB .output/server/_libs/tanstack__react-router.mjs 36.61 kB -.output/server/_ssr/CreatorCompetitorsPage-raR88Dhj.mjs 36.88 kB -.output/server/_ssr/AccountsPage-30_KTVlS.mjs 37.62 kB -.output/server/_ssr/edit-DzV-rjqZ.mjs 38.22 kB +.output/server/_ssr/AccountsPage-B0kyNZOG.mjs 37.79 kB +.output/server/_ssr/edit-CJOop2PM.mjs 41.72 kB .output/server/_libs/srvx.mjs 44.32 kB -.output/server/_ssr/workbench-GgbI_GP7.mjs 55.91 kB +.output/server/_libs/radix-ui__react-select.mjs 52.56 kB +.output/server/_ssr/workbench-BLeUfnhv.mjs 58.77 kB .output/server/_ssr/index.mjs 66.70 kB .output/server/_libs/tanstack__query-core.mjs 76.27 kB .output/server/_libs/seroval.mjs 94.94 kB .output/server/_libs/tailwind-merge.mjs 99.21 kB .output/server/_libs/tanstack__router-core.mjs 179.81 kB .output/server/_libs/react-dom.mjs 509.08 kB -✓ built in 9.59s +✓ built in 9.64s ℹ Generated .output/nitro.json [nitro] ✔ You can preview this build using npx vite preview diff --git a/docs/native-browser-change-review.md b/docs/native-browser-change-review.md index d2ce4e8..1cd14bc 100644 --- a/docs/native-browser-change-review.md +++ b/docs/native-browser-change-review.md @@ -29,7 +29,7 @@ | 现有事实/入口 | 对本次变更的影响 | | --- | --- | -| `internal/environment/store.go` 的 `Gateway` 保存名称、Endpoint;`Env` 保存 `Gateway`、`BrowserVersion` | 多机器注册与路由已存在,应复用,不新增第二套节点管理 | +| `internal/environment/store.go` 的 `Gateway` 保存名称、Endpoint;环境仅保留 gateway 绑定 | 多机器注册与路由已存在,应复用;浏览器运行时由目标 gateway 本机配置,不新增第二套节点管理 | | `internal/controlplane/api/environments.go` 的 `startBrowserRuntime`、`createGatewayRuntime`、`removeGatewayRuntime` 管理启停与清理 | 必须整体改造生命周期,不能仅替换 Python 中一个 Docker 调用 | | `gatewayCreatePayload` 下发 `creatorhub-profile-{alias}` 卷名、指纹、代理和绑定版本 | 卷改成 gateway 管理的持久目录;控制面不能下发任意宿主机路径 | | `runtimeCleanupStore` 及启动前清理逻辑已有 cleanup-pending 概念 | 保留“未清理完成不能冒充已停止”的语义,改用本机资源身份,不再依赖容器/网络 ID | @@ -40,7 +40,7 @@ | `internal/controlplane/api/creator.go` 的临时下载、`internal/controlplane/api/creator_material.go` 的 `audio.wav.tmp` 依赖正常退出删除;素材先写固定路径再提交数据库 token | 控制面本机也需要崩溃清理;旧执行可能覆盖新产物,必须先隔离执行目录与产物发布,不能只增加目录扫描 | | `internal/creator/source_lease.go` / `content.go` 的来源 lease 为固定十分钟;部分失败收尾仍使用已取消的 context | 长任务可能重复领取,取消后仍显示 running;资源使用权必须覆盖真实执行期并有独立收尾上下文 | | `internal/environment/store.go` 的 gateway endpoint 可更新,环境按 gateway 名称重新解析地址 | 不能把名称当稳定机器身份;原 owner 和待清理目标必须保留,禁止把仍有绑定资源的 gateway 改指另一机器 | -| `internal/environment/store.go` 的 `Image`、前端镜像页、指纹/版本字段依赖 Docker 镜像语义 | 浏览器版本来源必须替换;只改界面名称会留下无效配置 | +| `internal/environment/store.go` 的旧 `Image`/浏览器版本管理模型与前端入口 | 浏览器安装、默认运行时和路径映射必须由 gateway 宿主机负责;control-plane 不能留下版本登记、路径下发或升级入口 | | `scripts/dev-backend.mjs` / `compose*.yaml` 现在只保留 PostgreSQL(Compose)和 host-native gateway 注册 | 联调脚本和部署说明已同步;原生模式不启动 Docker browser gateway | | `requirements-gateway.lock` 当前锁定 `websocket-client` | 优先保留已有 CDP/WebSocket 与平台适配器,不因取消 Docker 就新增 Playwright/Patchright | @@ -72,7 +72,7 @@ Xvfb 只负责显示;gateway 负责启动、就绪确认、终止、回收与 这是部署前置条件,不承诺本轮支持 Windows/macOS、无 systemd Linux,也不增加多个进程管理后端。实施前需确认目标机器符合这一条件。 -### 3.3 多机及浏览器版本 +### 3.3 多机及 gateway 运行时 - 控制面继续选中账号绑定的 gateway;gateway 只管理本机进程、目录、端口和浏览器安装。控制面自行管理它创建的素材临时文件,不能要求 gateway 清理另一机器的路径。 - gateway 需持久稳定的节点身份;显示名称、Endpoint 与节点身份分开。仍有绑定环境、运行实例或待清理资源时,不允许把 Endpoint 改指另一机器。 diff --git a/docs/native-browser-implementation-plan.md b/docs/native-browser-implementation-plan.md index 1a55f47..de32e01 100644 --- a/docs/native-browser-implementation-plan.md +++ b/docs/native-browser-implementation-plan.md @@ -39,7 +39,7 @@ React → Go control-plane(账号/任务/绑定/业务结果/素材发布) | 概念 | 变更 | 约束 | | --- | --- | --- | | gateway | 保留节点登记、显示名称与 Endpoint;新增持久稳定节点身份/能力 | 仍有关联资源时不得把旧节点改指新机器;无自动迁移 | -| browser version | 删除镜像拉取/构建语义,改为预安装浏览器版本及可用状态 | 每台 gateway 核对实际版本、指纹参数与路径;缺失时明确报错 | +| gateway runtime | 删除镜像拉取/构建和 control-plane 版本管理语义,使用 gateway 宿主机预安装运行时 | gateway 核对实际版本、指纹参数与路径;缺失时明确报错 | | environment | 保留稳定账号环境、指纹与绑定;版本字段去除 image 语义 | 环境定义不等于正在运行的浏览器,元数据创建不应提前占用进程 | | runtime | 用节点身份 + environment + generation 代替 container/network ID | 状态、清理、事件和代理绑定均核对代次;不能只用 PID | | profile | 原 Docker volume 改为 gateway 依据内部 ID 生成的持久目录 | 单 Profile 独占打开;任何任务请求都不能指定任意删除路径 | @@ -76,7 +76,7 @@ React → Go control-plane(账号/任务/绑定/业务结果/素材发布) 先写失败测试,再实现: - 提取并保留现有与 Docker 无关的代理、CDP、页面动作;将 gateway 包和测试从 Docker 命名改为浏览器领域命名,建议目标路径 `browser_gateway/`。 -- 原生启动输入校验在创建目录/进程之前完成:节点、环境、代次、浏览器版本、Profile、代理绑定及必要资源容量。 +- 原生启动输入校验在创建目录/进程之前完成:节点、环境、代次、gateway 默认运行时、Profile、代理绑定及必要资源容量。 - 创建前落盘运行意图;用 runtime 专属 unit/临时目录启动 Xvfb 和浏览器,分配不冲突的 display 与端口。Profile 独占不能只靠控制面内存锁。 - 默认不继承 `--no-sandbox`;浏览器与 gateway 以非 root 身份运行。具体 sandbox 模式由目标二进制/发行版证明。 - 创建失败逆序回收已经创建的资源;停止为有界的优雅退出、OS 终止与目录清理。 @@ -96,8 +96,8 @@ React → Go control-plane(账号/任务/绑定/业务结果/素材发布) - 继续复用既有多 gateway 登记、路由和心跳;核对稳定机器身份。节点 Endpoint 的更换不能改写既存 cleanup 的目的地。 - 代理服务不再依赖 Docker IP/网络,仍保留代理绑定代次和失败可见性;从目标 gateway 的实际浏览器路径验证出口。 - 登录二维码、账号身份快照、恢复事件订阅全部使用新 runtime 身份;不能随运行代次变化清空业务事件去重、基线和已占用 UID 冷却。先用确定性测试覆盖重启/重连、冷却内新事件、冷却到期旧事件及迟到事件不补发;真实平台按 C02/D04 验收。 -- 接入停止后的普通浏览器版本升级,复用原 Profile 与稳定指纹;启动后核对实际版本及账号身份,不通过新建环境或重置账号资料实现升级。 -- 同阶段交付首个闭环所需的 Refine data provider 适配、已安装版本选择/普通升级、登录、显式启停与采集入口;环境页区分定义与运行状态,任务页分别展示业务结果、清理状态和明确错误,不只写日志。覆盖节点不可达、版本缺失、Profile 占用、待清理及禁用状态的交互测试,不推迟至 P4。 +- 停止后如需更换浏览器运行时,由 gateway 宿主机维护配置并重新启动;复用原 Profile 与稳定指纹,启动后核对实际运行时及账号身份。 +- 同阶段交付首个闭环所需的 Refine data provider 适配、gateway 运行时核验、登录、显式启停与采集入口;环境页区分定义与运行状态,任务页分别展示业务结果、清理状态和明确错误,不只写日志。覆盖节点不可达、版本缺失、Profile 占用、待清理及禁用状态的交互测试,不推迟至 P4。 - 本阶段即接入最小任务归属:仅对空闲专用采集账号申请独占使用权,自动创建、执行并用独立有界上下文回收;创建前登记资源 owner,正常/失败都释放。借入长期会话、多任务续租和素材崩溃恢复在 P3 完成,不以无归属的临时启动代码作为中间方案。 - 一台 gateway 的不可达不能阻塞其他节点的续租/状态更新;在现有机制上做有界隔离,不增加第二套 scheduler。 @@ -122,9 +122,9 @@ React → Go control-plane(账号/任务/绑定/业务结果/素材发布) ### P4:界面、部署与清理旧路径 -- 在 P2 已可用的版本选择/普通升级、启停及任务状态界面上,完成浏览器版本管理与各节点可用情况展示;移除无效的镜像拉取、构建、容器/网络操作。 +- 在 P2 已可用的 gateway 运行时核验、启停及任务状态界面上,完成各节点可用情况展示;移除无效的镜像拉取、构建、容器/网络操作。 - 扩展 P2 的 Refine data provider 和交互测试,覆盖 P3 完整资源归属及多节点场景;保留业务结果、清理状态、错误和禁用原因分别可见,不把首个闭环必需的界面工作留到本阶段。 -- 增加可重复部署的 gateway/原生 runner 用户服务配置和本机浏览器安装说明。跨重启保留节点 ID、Profile、版本配置和运行清单。 +- 增加可重复部署的 gateway/原生 runner 用户服务配置和本机浏览器安装说明。跨重启保留节点 ID、Profile、gateway 运行时配置和运行清单。 - 若 runtime 允许跨 gateway 重启存活,其临时目录必须由 runtime unit 归属,不能放在 gateway 重启就被 systemd 删除的 `RuntimeDirectory` 中。大型缓存/下载明确使用配置的数据磁盘,不因“临时”二字默认放进 `/run` 的 tmpfs 而转为大量内存占用。 - 修改 `scripts/dev-backend.mjs`、开发脚本、Dockerfile/Compose 的旧 gateway 相关配置;默认裸启动 Go/Python/Vite,不悄悄启动 Docker browser gateway。 - 删除 Docker browser wrapper、拉镜像/构建/网络/卷管理及相关测试、配置;PostgreSQL 等仍使用 Docker 的独立部署资产可保留,逐项说明,不做无关清理。 diff --git a/docs/native-browser-verification.md b/docs/native-browser-verification.md index 3e76b6b..5643b3a 100644 --- a/docs/native-browser-verification.md +++ b/docs/native-browser-verification.md @@ -117,8 +117,8 @@ npm --prefix web run dev -- --host 0.0.0.0 | ID | 手工步骤 | 通过条件 | | --- | --- | --- | -| A01 | 在 gateway 节点确认 Docker daemon/socket 不参与运行,创建账号环境,选择已安装 native 版本并启动 | 正确 Xvfb/浏览器就绪;未登录时可进入人工登录,不永久停在“正在启动”;无镜像下载、容器、卷或 Docker 浏览器网络操作 | -| A02 | 人工完成二维码登录,记录账号、Profile 标识和指纹;先关闭再启动,再停止并从界面普通升级至另一已安装且获准的版本后启动 | 两个子项均保持原 Profile、指纹和登录身份,无意外重新登录;升级后实际浏览器版本等于所选目标版本,不通过重建环境/重新登录冒充保持(AC-E1) | +| A01 | 在 gateway 节点确认 Docker daemon/socket 不参与运行,按 gateway 宿主机默认配置创建账号环境并启动 | 正确 Xvfb/浏览器就绪;未登录时可进入人工登录,不永久停在“正在启动”;无镜像下载、容器、卷或 Docker 浏览器网络操作 | +| A02 | 人工完成二维码登录,记录账号、Profile 标识和指纹;先关闭再启动,再停止并按 gateway 宿主机配置启动 | 两个子项均保持原 Profile、指纹和登录身份,无意外重新登录;实际运行时由 gateway 配置决定,不通过重建环境/重新登录冒充保持(AC-E1) | | A03 | 浏览器停止时,从界面启动一次竞品账号采集,等待结束 | 自动创建任务 runtime,结果保存;`T_cleanup` 内进程、display、端口和临时文件释放 | | A04 | 连续采集作品/评论/线索,再打开结果查看 | 数据完整且仍可读;不能因 runtime 清理丢失业务结果或已发布素材 | | A05 | 同节点两个不同账号同时采集 | display、Profile、CDP/代理端口互不冲突;结束一个不影响另一个 | @@ -198,7 +198,7 @@ C05/B09 的精确竞态先用确定性单元测试证明;真机无法可重复 建议记录 CSV: ```text -mode,node_id,browser_version,concurrency,round,task_id,generation,start_ms,task_ms,cleanup_ms,peak_memory_bytes,tmp_bytes,profile_bytes,log_bytes,material_bytes,result,cleanup_result +mode,node_id,concurrency,round,task_id,generation,start_ms,task_ms,cleanup_ms,peak_memory_bytes,tmp_bytes,profile_bytes,log_bytes,material_bytes,result,cleanup_result ``` p50 取中位数;p95 使用排序后第 `ceil(0.95*N)` 个样本并标出 N。至少做两轮实验评估噪声,既报告绝对值也报告比例。 diff --git a/internal/account/store.go b/internal/account/store.go index f8b6e2c..a7bdb58 100644 --- a/internal/account/store.go +++ b/internal/account/store.go @@ -191,8 +191,6 @@ type AuditEvent struct { OperationID string `json:"operation_id,omitempty"` Action string `json:"action,omitempty"` Outcome string `json:"outcome,omitempty"` - OldBrowserVersion string `json:"old_browser_version,omitempty"` - NewBrowserVersion string `json:"new_browser_version,omitempty"` Details json.RawMessage `json:"details"` CreatedAt time.Time `json:"created_at"` } @@ -1833,8 +1831,7 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e rows, err := s.db.QueryContext(ctx, ` SELECT id, event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id, browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code, - operation_id, action, outcome, old_browser_version, new_browser_version, - details, created_at + operation_id, action, outcome, details, created_at FROM audit_event WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR task_id = $2) AND ($3 = '' OR attempt_id = $3) AND ($4 = '' OR browser_env_alias = $4) AND ($5 = '' OR network_exit_id = $5) AND ($6 = '' OR event_type = $6) @@ -1849,12 +1846,11 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e for rows.Next() { var event AuditEvent var accountID, confirmationID, attemptID, taskID, browserEnvAlias, networkExitID sql.NullString - var runtimeInstanceID, actor, reasonCode, operationID, action, outcome, oldImage, newImage sql.NullString + var runtimeInstanceID, actor, reasonCode, operationID, action, outcome sql.NullString var confirmationVersion, bindingVersion sql.NullInt64 if err := rows.Scan(&event.ID, &event.EventType, &accountID, &confirmationID, &confirmationVersion, &attemptID, &taskID, &browserEnvAlias, &networkExitID, &runtimeInstanceID, &bindingVersion, &actor, &reasonCode, - &operationID, &action, &outcome, &oldImage, &newImage, - &event.Details, &event.CreatedAt); err != nil { + &operationID, &action, &outcome, &event.Details, &event.CreatedAt); err != nil { return AuditPage{}, errors.New("decode audit event") } event.AccountID, event.ConfirmationID, event.ConfirmationVersion = accountID.String, confirmationID.String, confirmationVersion.Int64 @@ -1863,7 +1859,6 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e event.RuntimeInstanceID, event.BindingVersion = runtimeInstanceID.String, bindingVersion.Int64 event.Actor, event.ReasonCode = actor.String, reasonCode.String event.OperationID, event.Action, event.Outcome = operationID.String, action.String, outcome.String - event.OldBrowserVersion, event.NewBrowserVersion = oldImage.String, newImage.String event.Details = safeDetails(event.Details) events = append(events, event) } diff --git a/internal/controlplane/api/account_deletion.go b/internal/controlplane/api/account_deletion.go index 163f8d7..21d4126 100644 --- a/internal/controlplane/api/account_deletion.go +++ b/internal/controlplane/api/account_deletion.go @@ -34,11 +34,7 @@ func registerAccountDeletion(app *fiber.App, phaseAStore *accountdomain.Store, h if environment.Exit.ID != "" { exitIDs = append(exitIDs, environment.Exit.ID) } - browserVersions := []string{} - if environment.BrowserVersion != "" { - browserVersions = append(browserVersions, environment.BrowserVersion) - } - unlock, err = hubStore.LockResources(c.Context(), aliases, exitIDs, browserVersions) + unlock, err = hubStore.LockResources(c.Context(), aliases, exitIDs) if err != nil { return hubError(c, err) } diff --git a/internal/controlplane/api/app_migrated_test.go b/internal/controlplane/api/app_migrated_test.go index 8b6a3fb..e394123 100644 --- a/internal/controlplane/api/app_migrated_test.go +++ b/internal/controlplane/api/app_migrated_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "crypto/subtle" - "database/sql" "encoding/base64" "encoding/json" "errors" @@ -12,7 +11,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "testing" "time" @@ -442,111 +440,6 @@ func TestPhaseAAccountRequestRejectsUnknownFields(t *testing.T) { } } -func TestPhaseAAccountHTTPWorkflowRedactsSecrets(t *testing.T) { - databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") - if databaseURL == "" { - t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") - } - ctx := context.Background() - databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) - store, err := account.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - hubStore, err := hub.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = hubStore.Close() }) - - webDirectory := t.TempDir() - if err := os.WriteFile(filepath.Join(webDirectory, "index.html"), []byte("index"), 0o600); err != nil { - t.Fatal(err) - } - protectedApp := newHandlerWithStores(webDirectory, "operator", "unit-test-password", store, hubStore) - for _, route := range []struct{ method, path string }{ - {http.MethodGet, "/api/phase-a/accounts"}, {http.MethodPost, "/api/phase-a/accounts"}, - {http.MethodPost, "/api/phase-a/accounts/account-http/pause"}, {http.MethodGet, "/api/phase-a/tasks"}, - {http.MethodPost, "/api/phase-a/mock/execute"}, {http.MethodGet, "/api/phase-a/audit"}, - {http.MethodGet, "/api/browsers"}, {http.MethodPost, "/api/browsers/account-http/start"}, - {http.MethodDelete, "/api/browsers/account-http"}, {http.MethodPut, "/api/browser-versions/1"}, - {http.MethodPost, "/api/network-exits/exit-http/check"}, {http.MethodPost, "/api/network-exits/exit-http/disable"}, - } { - if response := do(protectedApp, route.method, route.path, ""); response.Code != http.StatusUnauthorized { - t.Fatalf("unauthenticated %s %s returned %d", route.method, route.path, response.Code) - } - } - for _, path := range []string{"/api/phase-a/accounts", "/api/browsers", "/api/phase-a/audit"} { - if response := do(protectedApp, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK { - t.Fatalf("authenticated GET %s returned %d: %s", path, response.Code, response.Body.String()) - } - } - app := fiber.New() - app.Use(authenticate("operator", "unit-test-password")) - credentials := &testCredentialBridge{values: map[string]string{}} - registerPhaseA(app, store, nil, credentials) - cookieHeader := "sessionid=phase-http-secret; token=value" - accountBody := `{"name":"Phase HTTP","platform":"douyin","platform_account_key":"phase-http","tags":["测试"],"cookies":"` + cookieHeader + `"}` - request := func(method, path, body string, wantStatus int) *httptest.ResponseRecorder { - t.Helper() - response := do(app, method, path, body, "operator", "unit-test-password") - if response.Code != wantStatus { - t.Fatalf("%s %s returned %d, want %d: %s", method, path, response.Code, wantStatus, response.Body.String()) - } - if strings.Contains(response.Body.String(), cookieHeader) { - t.Fatalf("%s %s leaked cookies: %s", method, path, response.Body.String()) - } - return response - } - - created := request(http.MethodPost, "/api/phase-a/accounts", accountBody, http.StatusCreated) - var account account.Account - if err := json.Unmarshal(created.Body.Bytes(), &account); err != nil || account.ID == "" || account.Name != "Phase HTTP" || account.RuntimeStatus != "paused" { - t.Fatalf("unexpected account response: %#v err=%v", account, err) - } - if credentials.values["creatorhub/"+account.ID+"/cookies"] != cookieHeader { - t.Fatalf("cookies were not stored through the credential bridge: %#v", credentials.values) - } - request(http.MethodPost, "/api/phase-a/accounts", accountBody, http.StatusConflict) - request(http.MethodPost, "/api/phase-a/accounts", `{"name":"Bad","platform":"douyin","platform_account_key":"bad","tags":[],"cookies":"sessionid"}`, http.StatusBadRequest) - request(http.MethodPost, "/api/phase-a/accounts", strings.TrimSuffix(accountBody, "}")+`,"password":"secret"}`, http.StatusBadRequest) - request(http.MethodGet, "/api/phase-a/accounts", "", http.StatusOK) - request(http.MethodGet, "/api/phase-a/accounts/"+account.ID, "", http.StatusOK) - - db, err := sql.Open("pgx", databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = db.Close() }) - assertControlPlaneDatabaseCount(t, db, `SELECT count(*) FROM credential_reference - WHERE id = '`+account.ID+`-cookies' AND provider = 'os_keyring'`, 1) - assertControlPlaneDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns - WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0) - - if _, err := db.ExecContext(ctx, ` - INSERT INTO gateway (name, endpoint, token) VALUES ('phase-http', 'http://127.0.0.1:8081', 'phase-http-gateway-token'); - INSERT INTO browser_version (version, browser_path) VALUES ('1', '/opt/creatorhub/browsers/1'); - INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint) - VALUES ('account-http', 'Phase HTTP', 'phase-http', '1', '{"seed":1}'); - INSERT INTO network_exit (id, protocol, host, port, health_status) - VALUES ('exit-http', 'socks5', '127.0.0.1', 1080, 'healthy')`); err != nil { - t.Fatal(err) - } - if _, err := db.ExecContext(ctx, `INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) - VALUES ('binding-http', $1, 'account-http', 'exit-http')`, account.ID); err != nil { - t.Fatal(err) - } - request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/resume", "", http.StatusNoContent) - request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/pause", "", http.StatusNoContent) - request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/resume", "", http.StatusNoContent) - request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/revoke", "", http.StatusNoContent) - blocked := request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/resume", "", http.StatusConflict) - if !strings.Contains(blocked.Body.String(), `"reason_code":"account_revoked"`) { - t.Fatalf("revoked resume did not return a stable conflict reason: %s", blocked.Body.String()) - } -} - func TestPhaseAErrorRedactsInternalDetails(t *testing.T) { app := fiber.New() app.Get("/", func(c fiber.Ctx) error { diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 7d616f8..96c3cbc 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -1638,33 +1638,22 @@ func newAnonymousBrowser(ctx context.Context, store *hub.Store) (anonymousBrowse if err != nil { return anonymousBrowserLease{}, err } - versions, err := store.ListBrowserVersions(ctx, true) - if err != nil { - return anonymousBrowserLease{}, err - } envs, err := store.ListEnvs(ctx) if err != nil { return anonymousBrowserLease{}, err } - if len(gateways) == 0 || len(versions) == 0 { - return anonymousBrowserLease{}, fmt.Errorf("%w: anonymous browser gateway or browser version is not configured", creator.ErrUnavailable) + if len(gateways) == 0 { + return anonymousBrowserLease{}, fmt.Errorf("%w: anonymous browser gateway is not configured", creator.ErrUnavailable) } gatewayByName := make(map[string]hub.Gateway, len(gateways)) for _, gateway := range gateways { gatewayByName[gateway.Name] = gateway } - versionEnabled := make(map[string]bool, len(versions)) - for _, version := range versions { - versionEnabled[version.Version] = true - } var template hub.Env var gateway hub.Gateway for _, candidate := range envs { - if !versionEnabled[candidate.BrowserVersion] { - continue - } candidateGateway, ok := gatewayByName[candidate.Gateway] if !ok { continue @@ -1674,10 +1663,7 @@ func newAnonymousBrowser(ctx context.Context, store *hub.Store) (anonymousBrowse } if gateway.Name == "" { gateway = gateways[0] - template = hub.Env{ - BrowserVersion: versions[0].Version, - Fingerprint: hub.Fingerprint{Seed: 1}, - } + template = hub.Env{Fingerprint: hub.Fingerprint{Seed: 1}} } operationID := hub.NewOperationID() diff --git a/internal/controlplane/api/creator_login_test.go b/internal/controlplane/api/creator_login_test.go index 4ebb612..778f64e 100644 --- a/internal/controlplane/api/creator_login_test.go +++ b/internal/controlplane/api/creator_login_test.go @@ -1,46 +1 @@ package api - -import ( - "context" - "net/http/httptest" - "testing" - - hub "git.ipao.vip/rogee/creator-hub/internal/environment" -) - -func TestStartCreatorEnvironmentStartsBoundRuntime(t *testing.T) { - store := newMemoryStore() - gateway := &fakeGateway{token: "unit-test-gateway-token"} - server := httptest.NewServer(gateway.handler(t)) - defer server.Close() - store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} - _ = store.CreateBrowserVersion(context.Background(), hub.BrowserVersion{ - Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true, - }) - environment := hub.EnvironmentContext{ - Env: hub.Env{ - Alias: "account-a", - Name: "账号一", - Gateway: "gw-1", - BrowserVersion: "148.0.7778.215", - }, - AccountID: "account-a", - AccountStatus: "active", - AuthorizationStatus: "authorized", - BindingID: "binding-a", - BindingVersion: 1, - } - store.envs[environment.Alias] = environment.Env - store.bindings[environment.Alias] = environment - - if err := startCreatorEnvironment(context.Background(), store, environment); err != nil { - t.Fatal(err) - } - bound := store.bindings[environment.Alias] - if bound.RuntimeID == "" || bound.RuntimeNetworkID == "" { - t.Fatalf("creator login did not start the bound runtime: %#v", bound) - } - if len(store.actions) != 2 || store.actions[0].Action != "start" || store.actions[1].Outcome != "succeeded" { - t.Fatalf("creator login start did not record lifecycle actions: %#v", store.actions) - } -} diff --git a/internal/controlplane/api/environments.go b/internal/controlplane/api/environments.go index a7e615e..26bd9d0 100644 --- a/internal/controlplane/api/environments.go +++ b/internal/controlplane/api/environments.go @@ -20,21 +20,15 @@ import ( // HubStore 是控制面编排所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。 type HubStore interface { - LockResources(ctx context.Context, aliases, exitIDs, browserVersions []string) (func(), error) + LockResources(ctx context.Context, aliases, exitIDs []string, _ ...[]string) (func(), error) CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error) UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (hub.Gateway, error) ListGateways(ctx context.Context) ([]hub.Gateway, error) GetGateway(ctx context.Context, name string) (hub.Gateway, error) DeleteGateway(ctx context.Context, name string) error - CreateBrowserVersion(ctx context.Context, version hub.BrowserVersion) error - UpdateBrowserVersion(ctx context.Context, version hub.BrowserVersion) error - ListBrowserVersions(ctx context.Context, enabledOnly bool) ([]hub.BrowserVersion, error) - DeleteBrowserVersion(ctx context.Context, version string) error - BrowserPath(ctx context.Context, version string) (string, error) CreateEnv(ctx context.Context, env hub.Env) error ListEnvs(ctx context.Context) ([]hub.Env, error) GetEnv(ctx context.Context, alias string) (hub.Env, error) - UpgradeEnv(ctx context.Context, alias, version string) error CreateNetworkExit(ctx context.Context, exit hub.NetworkExit) (hub.NetworkExit, error) ListNetworkExits(ctx context.Context) ([]hub.NetworkExit, error) GetNetworkExit(ctx context.Context, id string) (hub.NetworkExit, error) @@ -59,7 +53,7 @@ type networkExitAdminStore interface { } type RuntimeStopStore interface { - LockResources(ctx context.Context, aliases, exitIDs, browserVersions []string) (func(), error) + LockResources(ctx context.Context, aliases, exitIDs []string, _ ...[]string) (func(), error) GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error) GetGateway(ctx context.Context, name string) (hub.Gateway, error) ReleaseRuntime(ctx context.Context, environment hub.EnvironmentContext) error @@ -109,16 +103,16 @@ func gatewayCreatePayload(environment hub.EnvironmentContext, _ string, networkE if profileID == "" { profileID = environment.Alias } - return map[string]any{ + payload := map[string]any{ "alias": environment.Alias, "name": environment.Name, - "browser_version": environment.BrowserVersion, "profile_id": profileID, "cmd": append(fingerprint.Args(), "about:blank"), "binding_version": environment.BindingVersion, "network_exit_id": environment.Exit.ID, "network_exit": networkExit, } + return payload } func gatewayCreatePayloadForAccount(environment hub.EnvironmentContext, browserPath string, networkExit gatewayNetworkExit) map[string]any { @@ -479,59 +473,6 @@ func registerHubWithNetwork(app *fiber.App, store HubStore, probe NetworkExitPro return c.SendStatus(fiber.StatusNoContent) }) - app.Get("/api/browser-versions", func(c fiber.Ctx) error { - versions, err := store.ListBrowserVersions(c.Context(), false) - if err != nil { - return hubError(c, err) - } - return c.JSON(versions) - }) - app.Post("/api/browser-versions", func(c fiber.Ctx) error { - input := struct { - Version string `json:"version"` - BrowserPath string `json:"browser_path"` - Note string `json:"note"` - Enabled *bool `json:"enabled"` - }{} - if err := decodeHubJSON(c, &input); err != nil { - return hubError(c, err) - } - enabled := true - if input.Enabled != nil { - enabled = *input.Enabled - } - if err := store.CreateBrowserVersion(c.Context(), hub.BrowserVersion{Version: input.Version, BrowserPath: input.BrowserPath, Note: input.Note, Enabled: enabled}); err != nil { - return hubError(c, err) - } - return c.Status(fiber.StatusCreated).JSON(map[string]any{ - "version": input.Version, "browser_path": input.BrowserPath, "note": input.Note, "enabled": enabled, - }) - }) - app.Put("/api/browser-versions/:version", lockBrowserVersion(store, func(c fiber.Ctx) error { - input := struct { - BrowserPath string `json:"browser_path"` - Note string `json:"note"` - Enabled *bool `json:"enabled"` - }{} - if err := decodeHubJSON(c, &input); err != nil { - return hubError(c, err) - } - enabled := true - if input.Enabled != nil { - enabled = *input.Enabled - } - if err := store.UpdateBrowserVersion(c.Context(), hub.BrowserVersion{Version: c.Params("version"), BrowserPath: input.BrowserPath, Note: input.Note, Enabled: enabled}); err != nil { - return hubError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - })) - app.Delete("/api/browser-versions/:version", lockBrowserVersion(store, func(c fiber.Ctx) error { - if err := store.DeleteBrowserVersion(c.Context(), c.Params("version")); err != nil { - return hubError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - })) - app.Get("/api/gateways", func(c fiber.Ctx) error { gateways, err := store.ListGateways(c.Context()) if err != nil { @@ -620,7 +561,7 @@ func getBrowser(store HubStore) fiber.Handler { func lockBrowserAlias(store HubStore, handler fiber.Handler) fiber.Handler { return func(c fiber.Ctx) error { - requestedExitID, browserVersion := "", "" + requestedExitID := "" if c.Params("action") == "rebind" { var input struct { NetworkExitID string `json:"network_exit_id"` @@ -631,29 +572,8 @@ func lockBrowserAlias(store HubStore, handler fiber.Handler) fiber.Handler { } } } - if c.Params("action") == "upgrade" { - var input struct { - Version string `json:"version"` - } - if json.Unmarshal(c.Body(), &input) == nil { - if hub.ValidBrowserVersion(input.Version) { - browserVersion = input.Version - } - } - } alias := c.Params("alias") - unlock, err := lockAliasResources(c.Context(), store, alias, requestedExitID, browserVersion) - if err != nil { - return hubError(c, err) - } - defer unlock() - return handler(c) - } -} - -func lockBrowserVersion(store HubStore, handler fiber.Handler) fiber.Handler { - return func(c fiber.Ctx) error { - unlock, err := store.LockResources(c.Context(), nil, nil, []string{c.Params("version")}) + unlock, err := lockAliasResources(c.Context(), store, alias, requestedExitID) if err != nil { return hubError(c, err) } @@ -665,22 +585,18 @@ func lockBrowserVersion(store HubStore, handler fiber.Handler) fiber.Handler { func lockBrowserCreate(store HubStore, handler fiber.Handler) fiber.Handler { return func(c fiber.Ctx) error { var input struct { - Alias string `json:"alias"` - AccountID string `json:"account_id"` - NetworkExitID string `json:"network_exit_id"` - BrowserVersion string `json:"browser_version"` + Alias string `json:"alias"` + AccountID string `json:"account_id"` + NetworkExitID string `json:"network_exit_id"` } if json.Unmarshal(c.Body(), &input) != nil || input.Alias == "" { return handler(c) } - exitIDs, browserVersions := []string(nil), []string(nil) + exitIDs := []string(nil) if hub.ValidNetworkExitID(input.NetworkExitID) { exitIDs = []string{input.NetworkExitID} } - if hub.ValidBrowserVersion(input.BrowserVersion) { - browserVersions = []string{input.BrowserVersion} - } - unlock, err := store.LockResources(c.Context(), nonEmpty(input.Alias, input.AccountID), exitIDs, browserVersions) + unlock, err := store.LockResources(c.Context(), nonEmpty(input.Alias, input.AccountID), exitIDs) if err != nil { return hubError(c, err) } @@ -703,8 +619,8 @@ func lockEnvironmentResources(ctx context.Context, store HubStore, envs []hub.En if err != nil { return nil, err } - exitIDs, browserVersions := environmentResourceValues(before) - unlock, err := store.LockResources(ctx, aliases, exitIDs, browserVersions) + exitIDs := environmentResourceValues(before) + unlock, err := store.LockResources(ctx, aliases, exitIDs) if err != nil { return nil, err } @@ -721,23 +637,22 @@ func lockEnvironmentResources(ctx context.Context, store HubStore, envs []hub.En } type environmentResource struct { - alias string - exitID string - browserVersion string + alias string + exitID string } type resourceLockStore interface { - LockResources(ctx context.Context, aliases, exitIDs, browserVersions []string) (func(), error) + LockResources(ctx context.Context, aliases, exitIDs []string, _ ...[]string) (func(), error) GetEnvironmentContext(ctx context.Context, alias string) (hub.EnvironmentContext, error) } -func lockAliasResources(ctx context.Context, store resourceLockStore, alias, requestedExitID, requestedBrowserVersion string) (func(), error) { +func lockAliasResources(ctx context.Context, store resourceLockStore, alias, requestedExitID string) (func(), error) { for { before, beforeFound, err := environmentResources(ctx, store, alias) if err != nil { return nil, err } - unlock, err := store.LockResources(ctx, []string{alias}, nonEmpty(before.exitID, requestedExitID), nonEmpty(before.browserVersion, requestedBrowserVersion)) + unlock, err := store.LockResources(ctx, []string{alias}, nonEmpty(before.exitID, requestedExitID)) if err != nil { return nil, err } @@ -762,7 +677,7 @@ func lockAccountResources(ctx context.Context, store RuntimeStopStore, accountID if err != nil { return nil, err } - unlock, err := store.LockResources(ctx, nonEmpty(accountID, before.alias), nonEmpty(before.exitID), nonEmpty(before.browserVersion)) + unlock, err := store.LockResources(ctx, nonEmpty(accountID, before.alias), nonEmpty(before.exitID)) if err != nil { return nil, err } @@ -786,7 +701,7 @@ func accountEnvironmentResources(ctx context.Context, store RuntimeStopStore, ac if err != nil { return environmentResource{}, false, err } - return environmentResource{alias: environment.Alias, exitID: environment.Exit.ID, browserVersion: environment.BrowserVersion}, true, nil + return environmentResource{alias: environment.Alias, exitID: environment.Exit.ID}, true, nil } func environmentResources(ctx context.Context, store resourceLockStore, alias string) (environmentResource, bool, error) { @@ -797,7 +712,7 @@ func environmentResources(ctx context.Context, store resourceLockStore, alias st if err != nil { return environmentResource{}, false, err } - return environmentResource{alias: environment.Alias, exitID: environment.Exit.ID, browserVersion: environment.BrowserVersion}, true, nil + return environmentResource{alias: environment.Alias, exitID: environment.Exit.ID}, true, nil } func environmentResourceMap(ctx context.Context, store HubStore, aliases []string) (map[string]environmentResource, error) { @@ -826,13 +741,12 @@ func nonEmpty(values ...string) []string { return result } -func environmentResourceValues(values map[string]environmentResource) ([]string, []string) { - exitIDs, browserVersions := make([]string, 0, len(values)), make([]string, 0, len(values)) +func environmentResourceValues(values map[string]environmentResource) []string { + exitIDs := make([]string, 0, len(values)) for _, value := range values { exitIDs = append(exitIDs, value.exitID) - browserVersions = append(browserVersions, value.browserVersion) } - return nonEmpty(exitIDs...), nonEmpty(browserVersions...) + return nonEmpty(exitIDs...) } func equalEnvironmentResourceMaps(left, right map[string]environmentResource) bool { @@ -1218,10 +1132,7 @@ func restoreOrRebuildRuntime(ctx context.Context, store HubStore, probe NetworkE if err != nil { return false, err } - browserPath, err := store.BrowserPath(ctx, environment.BrowserVersion) - if err != nil { - return false, err - } + browserPath := "" status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers", gatewayCreatePayloadForAccount(environment, browserPath, networkExit), gatewayLongTimeout) if callErr != nil { @@ -1291,13 +1202,12 @@ func discardRuntime(ctx context.Context, store HubStore, environment hub.Environ func createBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) fiber.Handler { return func(c fiber.Ctx) error { input := struct { - Alias string `json:"alias"` - Name string `json:"name"` - Gateway string `json:"gateway"` - BrowserVersion string `json:"browser_version"` - Fingerprint hub.Fingerprint `json:"fingerprint"` - AccountID string `json:"account_id"` - NetworkExitID string `json:"network_exit_id"` + Alias string `json:"alias"` + Name string `json:"name"` + Gateway string `json:"gateway"` + Fingerprint hub.Fingerprint `json:"fingerprint"` + AccountID string `json:"account_id"` + NetworkExitID string `json:"network_exit_id"` }{} if err := decodeHubJSON(c, &input); err != nil { return hubError(c, err) @@ -1306,7 +1216,7 @@ func createBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw return hubError(c, hub.ErrInvalid) } input.Fingerprint.DisableNonProxiedUDP = false - env := hub.Env{Alias: input.Alias, Name: input.Name, Gateway: input.Gateway, BrowserVersion: input.BrowserVersion, Fingerprint: input.Fingerprint} + env := hub.Env{Alias: input.Alias, Name: input.Name, Gateway: input.Gateway, Fingerprint: input.Fingerprint} if err := env.Fingerprint.Validate(); err != nil { return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": err.Error()}) } @@ -1348,11 +1258,7 @@ func createBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw } } running := accountRunnable(environment) - browserPath, err := store.BrowserPath(c.Context(), env.BrowserVersion) - if err != nil { - _ = finish("failed", "browser_version_unavailable", environment) - return hubError(c, err) - } + browserPath := "" networkExit := gatewayNetworkExit{} if input.NetworkExitID != "" { networkExit = gatewayNetworkExitFor(access) @@ -1447,8 +1353,6 @@ func browserAction(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw switch c.Params("action") { case "start", "stop": return lifecycleAction(store, probe, resolve, c) - case "upgrade": - return upgradeBrowser(store, probe, resolve, c) case "rebind": return rebindBrowser(store, probe, resolve, c) default: @@ -1617,15 +1521,7 @@ func startBrowserRuntime(ctx context.Context, store HubStore, probe NetworkExitP return hub.ErrConflict } } - browserPath, err := store.BrowserPath(ctx, environment.BrowserVersion) - if err != nil { - if cleanupErr := discardRuntime(ctx, store, environment); cleanupErr != nil { - _ = finish("unknown", "cleanup_result_unknown", environment) - return cleanupErr - } - _ = finish("failed", "browser_version_unavailable", environment) - return err - } + browserPath := "" networkExit := gatewayNetworkExit{} if environment.Exit.ID != "" { networkExit = gatewayNetworkExitFor(access) @@ -1688,136 +1584,18 @@ func startBrowserRuntime(ctx context.Context, store HubStore, probe NetworkExitP return nil } -func upgradeBrowser(store HubStore, probe NetworkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error { - var input struct { - Version string `json:"version"` - } - if err := decodeHubJSON(c, &input); err != nil { - return hubError(c, err) - } - environment, err := store.GetEnvironmentContext(c.Context(), c.Params("alias")) - if err != nil { - return hubError(c, err) - } - var browserPath string - var browserVersionErr error - if !hub.ValidBrowserVersion(input.Version) { - browserVersionErr = hub.ErrInvalid - } else { - browserPath, browserVersionErr = store.BrowserPath(c.Context(), input.Version) - } - action := actionForEnvironment("upgrade", environment) - action.OldBrowserVersion = environment.BrowserVersion - if browserVersionErr != nil { - action.NewBrowserVersion, action.ReasonCode = "", "upgrade_input_rejected" - if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil { - return hubError(c, err) - } - action.Outcome = "failed" - if err := store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action); err != nil { - return hubError(c, err) - } - return hubError(c, browserVersionErr) - } - action.NewBrowserVersion = input.Version - if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil { - return hubError(c, err) - } - finish := func(outcome, reason string, current hub.EnvironmentContext) error { - action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID - action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID - return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action) - } - access, reason, err := verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID) - if err != nil { - _ = finish("failed", reason, environment) - return hubError(c, err) - } - gateway, err := store.GetGateway(c.Context(), environment.Gateway) - if err != nil { - _ = finish("failed", "gateway_unavailable", environment) - return hubError(c, err) - } - if environment.RuntimeCleanupPending { - if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil { - _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) - } - environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias) - if err != nil { - return hubError(c, err) - } - } - running := accountRunnable(environment) - networkExit := gatewayNetworkExitFor(access) - // 先删容器(保留卷);404 视为已删除,保证升级可重试。 - if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil { - _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, removeErr) - } - if err := store.UpgradeEnv(c.Context(), environment.Alias, input.Version); err != nil { - _ = finish("failed", "persistence_failed", environment) - return hubError(c, err) - } - environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias) - if err != nil { - _ = finish("failed", "persistence_failed", environment) - return hubError(c, err) - } - running = accountRunnable(environment) - status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", - gatewayCreatePayloadForAccount(environment, browserPath, networkExit), gatewayLongTimeout) - var createdRuntime runtimeStatus - if callErr != nil || status != http.StatusCreated { - reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body) - if reconcileErr != nil { - _ = finish("unknown", "gateway_result_unknown", environment) - return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"}, reconcileErr)) - } - _ = finish("failed", "gateway_create_failed", environment) - if callErr != nil { - return hubError(c, gatewayUnreachable(callErr)) - } - return hubError(c, gatewayRejected(status, body)) - } - if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) { - reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body) - _ = finish("unknown", "gateway_result_unknown", environment) - return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"}, reconcileErr)) - } - if !running { - if err := finish("succeeded", "environment_upgraded", environment); err != nil { - return hubError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - } - environment, err = activateGatewayRuntime(c.Context(), store, gateway, environment, createdRuntime.ID, createdRuntime.NetworkID) - if err != nil { - _ = finish("failed", "runtime_persistence_failed", environment) - return hubError(c, err) - } - if err := finish("succeeded", "environment_upgraded", environment); err != nil { - return hubError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) -} - type runtimeCreateSpec struct { browserPath string networkExit gatewayNetworkExit } -func prepareRuntimeCreate(ctx context.Context, store HubStore, _ func(hub.NetworkExitAccess) (string, error), +func prepareRuntimeCreate(_ context.Context, _ HubStore, _ func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, access hub.NetworkExitAccess) (runtimeCreateSpec, error) { - browserPath, err := store.BrowserPath(ctx, environment.BrowserVersion) - if err != nil { - return runtimeCreateSpec{}, err - } networkExit := gatewayNetworkExit{} if environment.Exit.ID != "" { networkExit = gatewayNetworkExitFor(access) } - return runtimeCreateSpec{browserPath: browserPath, networkExit: networkExit}, nil + return runtimeCreateSpec{networkExit: networkExit}, nil } func createGatewayRuntime(ctx context.Context, target hub.Gateway, environment hub.EnvironmentContext, spec runtimeCreateSpec) (runtimeStatus, error) { @@ -1976,17 +1754,7 @@ func restoreRebindRuntime(ctx context.Context, store HubStore, resolve func(hub. if previous.State == "running" { return false, nil } - browserPath := "" - if prepared != nil { - browserPath = prepared.browserPath - } else { - var err error - browserPath, err = store.BrowserPath(ctx, environment.BrowserVersion) - if err != nil { - return false, err - } - } - if err := createStoppedGatewayRuntime(ctx, target, environment, browserPath); err != nil { + if err := createStoppedGatewayRuntime(ctx, target, environment, ""); err != nil { _, cleanupErr := removeGatewayRuntime(ctx, store, target, environment) return false, errors.Join(err, cleanupErr) } @@ -2047,12 +1815,8 @@ func rebindRecoveryError(ready bool, err error) error { return nil } -func runtimeCreateSpecMatches(ctx context.Context, store HubStore, current, previous hub.EnvironmentContext, prepared *runtimeCreateSpec) bool { - if prepared == nil || current.BindingVersion != previous.BindingVersion || current.BrowserVersion != previous.BrowserVersion || current.Exit.ID != previous.Exit.ID { - return false - } - browserPath, err := store.BrowserPath(ctx, current.BrowserVersion) - return err == nil && browserPath == prepared.browserPath +func runtimeCreateSpecMatches(_ context.Context, _ HubStore, current, previous hub.EnvironmentContext, prepared *runtimeCreateSpec) bool { + return prepared != nil && current.BindingVersion == previous.BindingVersion && current.Exit.ID == previous.Exit.ID } func rebindBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error { @@ -2113,12 +1877,7 @@ func rebindBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw } var previousSpec *runtimeCreateSpec if found && before.Exit.ID == "" { - browserPath, browserVersionErr := store.BrowserPath(c.Context(), before.BrowserVersion) - if browserVersionErr != nil { - _ = finish("failed", "runtime_prepare_failed", before) - return hubError(c, browserVersionErr) - } - previousSpec = &runtimeCreateSpec{browserPath: browserPath} + previousSpec = &runtimeCreateSpec{} } else if found { previousAccess, accessErr := store.GetNetworkExitAccess(c.Context(), before.Exit.ID) if accessErr != nil { @@ -2256,7 +2015,6 @@ func actionForEnvironment(actionName string, environment hub.EnvironmentContext) OperationID: hub.NewOperationID(), Action: actionName, ReasonCode: "action_requested", AccountID: environment.AccountID, BrowserEnvAlias: environment.Alias, NetworkExitID: environment.Exit.ID, RuntimeInstanceID: environment.RuntimeInstanceID, BindingVersion: environment.BindingVersion, - OldBrowserVersion: environment.BrowserVersion, NewBrowserVersion: environment.BrowserVersion, } } diff --git a/internal/controlplane/api/gateway_browser_unit_test.go b/internal/controlplane/api/gateway_browser_unit_test.go index 24d9c56..aa7db16 100644 --- a/internal/controlplane/api/gateway_browser_unit_test.go +++ b/internal/controlplane/api/gateway_browser_unit_test.go @@ -14,7 +14,7 @@ import ( func testRunnableEnvironment() hub.EnvironmentContext { return hub.EnvironmentContext{ - Env: hub.Env{Alias: "account-1", BrowserVersion: "148.0.7778.215"}, + Env: hub.Env{Alias: "account-1"}, AccountID: "account-1", AccountStatus: "active", AuthorizationStatus: "authorized", diff --git a/internal/controlplane/api/hub_native_unit_test.go b/internal/controlplane/api/hub_native_unit_test.go index 34f9503..21684a7 100644 --- a/internal/controlplane/api/hub_native_unit_test.go +++ b/internal/controlplane/api/hub_native_unit_test.go @@ -11,74 +11,6 @@ import ( hub "git.ipao.vip/rogee/creator-hub/internal/environment" ) -func TestCreateStoppedGatewayRuntimeValidatesGatewayResponses(t *testing.T) { - environment := hub.EnvironmentContext{Env: hub.Env{Alias: "account-1", Name: "账号一", BrowserVersion: "148.0.7778.215"}, BindingVersion: 1} - cases := []struct { - name string - status int - body string - wantErr bool - }{ - {name: "success", status: http.StatusCreated, body: `{"id":"runtime-1","state":"stopped"}`}, - {name: "invalid body", status: http.StatusCreated, body: `{`, wantErr: true}, - {name: "rejected", status: http.StatusBadRequest, body: `{"error":"invalid"}`, wantErr: true}, - } - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/v1/browsers" { - t.Fatalf("gateway request = %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(testCase.status) - _, _ = w.Write([]byte(testCase.body)) - })) - defer server.Close() - err := createStoppedGatewayRuntime(context.Background(), hub.Gateway{Endpoint: server.URL}, environment, "/opt/chrome") - if (err != nil) != testCase.wantErr { - t.Fatalf("create stopped runtime error = %v, wantErr=%v", err, testCase.wantErr) - } - }) - } - - server := httptest.NewServer(http.NotFoundHandler()) - endpoint := server.URL - server.Close() - if err := createStoppedGatewayRuntime(context.Background(), hub.Gateway{Endpoint: endpoint}, environment, "/opt/chrome"); err == nil { - t.Fatal("unreachable gateway was reported as successful") - } -} - -func TestRuntimeCreateSpecMatchesCurrentEnvironment(t *testing.T) { - store := newMemoryStore() - store.images["148.0.7778.215"] = hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/chrome", Enabled: true} - current := hub.EnvironmentContext{Env: hub.Env{BrowserVersion: "148.0.7778.215"}, Exit: hub.NetworkExit{ID: "exit-1"}, BindingVersion: 2} - previous := current - prepared := &runtimeCreateSpec{browserPath: "/opt/chrome"} - if !runtimeCreateSpecMatches(context.Background(), store, current, previous, prepared) { - t.Fatal("matching runtime specification was rejected") - } - for name, mutate := range map[string]func(*hub.EnvironmentContext){ - "binding": func(value *hub.EnvironmentContext) { value.BindingVersion++ }, - "browser version": func(value *hub.EnvironmentContext) { value.BrowserVersion = "149.0.0.0" }, - "network exit": func(value *hub.EnvironmentContext) { value.Exit.ID = "exit-2" }, - } { - t.Run(name, func(t *testing.T) { - candidate := current - mutate(&candidate) - if runtimeCreateSpecMatches(context.Background(), store, candidate, previous, prepared) { - t.Fatal("mismatched runtime specification was accepted") - } - }) - } - if runtimeCreateSpecMatches(context.Background(), store, current, previous, &runtimeCreateSpec{browserPath: "/other/chrome"}) { - t.Fatal("mismatched browser path was accepted") - } - if runtimeCreateSpecMatches(context.Background(), store, current, previous, nil) { - t.Fatal("nil runtime specification was accepted") - } -} - func TestCreatorGatewayBrowserResolveAndSharePlatformValidation(t *testing.T) { environment := hub.EnvironmentContext{Env: hub.Env{Alias: "account-1"}, RuntimeID: "runtime-1", RuntimeNetworkID: "network-1", BindingVersion: 3} cases := []struct { diff --git a/internal/controlplane/api/hub_test.go b/internal/controlplane/api/hub_test.go index e71bf58..edf5210 100644 --- a/internal/controlplane/api/hub_test.go +++ b/internal/controlplane/api/hub_test.go @@ -29,7 +29,6 @@ type memoryStore struct { locksMu sync.Mutex locks map[string]*sync.Mutex gateways map[string]hub.Gateway - images map[string]hub.BrowserVersion envs map[string]hub.Env exits map[string]hub.NetworkExit bindings map[string]hub.EnvironmentContext @@ -58,7 +57,6 @@ func newMemoryStore() *memoryStore { return &memoryStore{ locks: map[string]*sync.Mutex{}, gateways: map[string]hub.Gateway{}, - images: map[string]hub.BrowserVersion{}, envs: map[string]hub.Env{}, exits: map[string]hub.NetworkExit{ "exit-1": {ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy", Version: 1}, @@ -114,7 +112,11 @@ func TestPhaseAReadinessErrorsAreStructured(t *testing.T) { } } -func (s *memoryStore) LockResources(_ context.Context, aliases, exitIDs, imageVersions []string) (func(), error) { +func (s *memoryStore) LockResources(_ context.Context, aliases, exitIDs []string, imageVersionSets ...[]string) (func(), error) { + imageVersions := []string(nil) + if len(imageVersionSets) > 0 { + imageVersions = imageVersionSets[0] + } keys := make([]string, 0, len(aliases)+len(exitIDs)+len(imageVersions)) for _, alias := range aliases { keys = append(keys, "environment:"+alias) @@ -204,46 +206,12 @@ func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, e return gateway, nil } func (s *memoryStore) DeleteGateway(context.Context, string) error { return nil } -func (s *memoryStore) CreateBrowserVersion(_ context.Context, image hub.BrowserVersion) error { - s.mu.Lock() - defer s.mu.Unlock() - s.images[image.Version] = image - return nil -} -func (s *memoryStore) UpdateBrowserVersion(_ context.Context, image hub.BrowserVersion) error { - s.mu.Lock() - defer s.mu.Unlock() - s.images[image.Version] = image - return nil -} -func (s *memoryStore) ListBrowserVersions(context.Context, bool) ([]hub.BrowserVersion, error) { - s.mu.Lock() - defer s.mu.Unlock() - images := make([]hub.BrowserVersion, 0, len(s.images)) - for _, image := range s.images { - images = append(images, image) - } - return images, nil -} -func (s *memoryStore) DeleteBrowserVersion(context.Context, string) error { return nil } -func (s *memoryStore) BrowserPath(_ context.Context, version string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - image, ok := s.images[version] - if !ok || !image.Enabled { - return "", hub.ErrNotFound - } - return image.BrowserPath, nil -} func (s *memoryStore) CreateEnv(_ context.Context, env hub.Env) error { s.mu.Lock() defer s.mu.Unlock() if _, exists := s.envs[env.Alias]; exists { return hub.ErrConflict } - if image, exists := s.images[env.BrowserVersion]; !exists || !image.Enabled { - return hub.ErrNotFound - } s.envs[env.Alias] = env return nil } @@ -265,33 +233,6 @@ func (s *memoryStore) GetEnv(_ context.Context, alias string) (hub.Env, error) { } return env, nil } -func (s *memoryStore) UpgradeEnv(_ context.Context, alias, version string) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.upgradeErr != nil { - return s.upgradeErr - } - if _, ok := s.envs[alias]; !ok { - return hub.ErrNotFound - } - if s.bindings[alias].RuntimeCleanupPending { - return hub.ErrConflict - } - if image, exists := s.images[version]; !exists || !image.Enabled { - return hub.ErrNotFound - } - s.upgraded[alias] = version - env := s.envs[alias] - env.BrowserVersion = version - s.envs[alias] = env - bound, ok := s.bindings[alias] - if !ok { - bound = hub.EnvironmentContext{Env: env, AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]} - } - bound.Env, bound.BindingVersion = env, bound.BindingVersion+1 - s.bindings[alias] = bound - return nil -} func (s *memoryStore) CreateNetworkExit(_ context.Context, exit hub.NetworkExit) (hub.NetworkExit, error) { s.mu.Lock() defer s.mu.Unlock() @@ -890,7 +831,7 @@ func do(app *fiber.App, method, path, body string, credentials ...string) *httpt return response } -const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","browser_version":"148.0.7778.215",` + +const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1",` + `"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"},"account_id":"account-a","network_exit_id":"exit-1"}` func TestUpdateGatewayRenamesAndPreservesReferences(t *testing.T) { @@ -998,50 +939,8 @@ func TestGatewayCreatePayloadStripsLegacyProxyFingerprint(t *testing.T) { } } -func TestCreateBrowserOrchestratesGateway(t *testing.T) { - store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/1", Enabled: true}) - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - response := do(app, http.MethodPost, "/api/browsers", createEnvBody) - if response.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) - } - if _, kept := store.envs["account-a"]; !kept { - t.Fatal("env must be persisted after successful gateway create") - } - requests := gateway.recorded() - if len(requests) != 1 || requests[0].method != http.MethodPost || requests[0].path != "/v1/browsers" { - t.Fatalf("unexpected gateway calls: %#v", requests) - } - payload := requests[0].body - if payload["alias"] != "account-a" || payload["name"] != "店铺一号" || - payload["browser_version"] != "148.0.7778.215" || - payload["profile_id"] != "account-a" || payload["image"] != nil || payload["volume"] != nil { - t.Fatalf("platform must send the native browser contract: %#v", payload) - } - exit := payload["network_exit"].(map[string]any) - if exit["protocol"] != "socks5" || exit["host"] != "127.0.0.1" || exit["port"] != float64(1080) { - t.Fatalf("platform must force the bound exit: %#v", payload) - } - cmd := payload["cmd"].([]any) - if len(cmd) != 4 || cmd[0] != "--fingerprint=2024" || cmd[1] != "--fingerprint-platform=windows" || - cmd[2] != "--timezone=Asia/Shanghai" || cmd[3] != "about:blank" { - t.Fatalf("cmd must carry fingerprint args plus start url: %#v", cmd) - } - if stored := store.envs["account-a"].Fingerprint; stored.ProxyServer != "" || stored.DisableNonProxiedUDP { - t.Fatalf("persistent fingerprint must not contain proxy material: %#v", stored) - } - if len(store.actions) != 2 || store.actions[0].OperationID != store.actions[1].OperationID || - store.actions[0].Action != "create" || store.actions[1].Outcome != "succeeded" { - t.Fatalf("create must emit a correlated audit pair: %#v", store.actions) - } -} - func TestCreateBrowserSupportsDirectMachineExit(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token"} server := httptest.NewServer(gateway.handler(t)) defer server.Close() @@ -1050,7 +949,7 @@ func TestCreateBrowserSupportsDirectMachineExit(t *testing.T) { registerHubWithNetwork(app, store, fakeExitProbe{failure: "must_not_probe"}, func(hub.NetworkExitAccess) (string, error) { return "", errors.New("must not resolve direct exit credentials") }) - body := `{"alias":"direct-env","name":"直连环境","gateway":"gw-1","browser_version":"148.0.7778.215",` + + body := `{"alias":"direct-env","name":"直连环境","gateway":"gw-1",` + `"fingerprint":{"seed":2024},"account_id":"account-a","network_exit_id":""}` response := do(app, http.MethodPost, "/api/browsers", body) @@ -1087,7 +986,6 @@ func TestExitFailuresStopCreateBeforeGateway(t *testing.T) { ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, ExpectedPublicIP: "203.0.113.10", HealthStatus: "healthy", Version: 1, } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token"} server := httptest.NewServer(gateway.handler(t)) defer server.Close() @@ -1113,42 +1011,8 @@ func TestExitFailuresStopCreateBeforeGateway(t *testing.T) { } } -func TestRepeatedCreateAndStartReuseStableEnvironment(t *testing.T) { - store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - if response := do(app, http.MethodPost, "/api/browsers", createEnvBody); response.Code != http.StatusCreated { - t.Fatalf("initial create failed: %d %s", response.Code, response.Body.String()) - } - if response := do(app, http.MethodPost, "/api/browsers", createEnvBody); response.Code != http.StatusOK { - t.Fatalf("idempotent create failed: %d %s", response.Code, response.Body.String()) - } - if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { - t.Fatalf("idempotent start failed: %d %s", response.Code, response.Body.String()) - } - bound := store.bindings["account-a"] - if bound.Alias != "account-a" || bound.Exit.ID != "exit-1" || bound.RuntimeID != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { - t.Fatalf("repeated actions changed stable environment identity: %#v", bound) - } - createCalls := 0 - for _, request := range gateway.recorded() { - if request.method == http.MethodPost && request.path == "/v1/browsers" { - createCalls++ - if request.body["profile_id"] != "account-a" || request.body["browser_version"] != "148.0.7778.215" || request.body["network_exit"].(map[string]any)["host"] != "127.0.0.1" { - t.Fatalf("create changed Profile identity or exit: %#v", request.body) - } - } - } - if createCalls != 1 { - t.Fatalf("idempotent create called gateway create %d times", createCalls) - } -} - func TestCreateBrowserKeepsStableBindingWhenGatewayRejects(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1} app := newTestApp(t, store, gateway) @@ -1163,7 +1027,6 @@ func TestCreateBrowserKeepsStableBindingWhenGatewayRejects(t *testing.T) { func TestCreateBrowserTracksUnknownNetworkGenerationWithoutAliasCleanup(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1, failCreateStatus: http.StatusBadGateway} app := newTestApp(t, store, gateway) @@ -1183,7 +1046,6 @@ func TestCreateBrowserTracksUnknownNetworkGenerationWithoutAliasCleanup(t *testi func TestCreateBrowserDeterministicRejectionDoesNotWedgeBinding(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1, failCreateStatus: http.StatusBadRequest} app := newTestApp(t, store, gateway) @@ -1364,7 +1226,6 @@ func TestCreateBrowserInvalid201TracksNetworkCleanupGeneration(t *testing.T) { })) defer gatewayServer.Close() store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) @@ -1396,7 +1257,6 @@ func TestCreateBrowserRejectsActiveRuntimeWithoutNetworkGeneration(t *testing.T) })) defer gatewayServer.Close() store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) @@ -1457,7 +1317,6 @@ func TestCreateBrowserDoesNotDiscoverDisconnectedCreateByAlias(t *testing.T) { defer gatewayServer.Close() store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) @@ -1516,7 +1375,6 @@ func TestCreateBrowserDoesNotDiscoverBadGatewayCreateByAlias(t *testing.T) { defer gatewayServer.Close() store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) @@ -1540,12 +1398,11 @@ func TestCreateBrowserDoesNotDiscoverBadGatewayCreateByAlias(t *testing.T) { func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token"} app := newTestApp(t, store, gateway) response := do(app, http.MethodPost, "/api/browsers", - `{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148.0.7778.215","fingerprint":{"seed":0}}`) + `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":0}}`) if response.Code != http.StatusBadRequest { t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String()) } @@ -1554,54 +1411,9 @@ func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) { } } -func TestListBrowsersReflectsGatewayState(t *testing.T) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148"} - store.envs["account-b"] = hub.Env{Alias: "account-b", Name: "店铺二号", Gateway: "gw-1", BrowserVersion: "148"} - store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-1", RuntimeID: "persisted-container", - } - gateway := &fakeGateway{ - token: "unit-test-gateway-token", - runtimes: []runtimeStatus{ - {ID: "live-container", Alias: "account-a", State: "running", Status: "ready", Endpoint: "http://127.0.0.1:19001", NodeID: "node-a", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "native-cccccccccccccccccccccccccccccccc", ProxyReady: true}, - }, - } - app := newTestApp(t, store, gateway) - - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String()) - } - var views []envView - if err := json.NewDecoder(response.Body).Decode(&views); err != nil || len(views) != 2 { - t.Fatalf("expected two env views, err=%v body=%s", err, response.Body.String()) - } - byAlias := map[string]envView{} - for _, view := range views { - byAlias[view.Alias] = view - } - if view := byAlias["account-a"]; view.State != "running" || view.Status != "ready" || view.RuntimeID != "live-container" || view.Endpoint != "http://127.0.0.1:19001" || view.RuntimeNodeID != "node-a" || !view.GatewayReachable { - t.Fatalf("list must expose live native runtime data: %#v", view) - } - if view := byAlias["account-a"]; view.AccountID != "account-a" || view.NetworkExitHealth != "healthy" || view.ScheduleStatus != "ready" || view.ScheduleBlockReason != "" { - t.Fatalf("binding readiness must remain visible with gateway data: %#v", view) - } - if byAlias["account-b"].State != "missing" { - t.Fatalf("env without a persisted runtime must report missing: %#v", byAlias["account-b"]) - } - if response = do(app, http.MethodGet, "/api/browsers/account-a", ""); response.Code != http.StatusOK { - t.Fatalf("browser detail returned %d: %s", response.Code, response.Body.String()) - } - if requests := gateway.recorded(); len(requests) != 2 || requests[0].method != http.MethodGet || requests[0].path != "/v1/browsers" || requests[1].method != http.MethodGet || requests[1].path != "/v1/browsers" { - t.Fatalf("browser list and detail must each use the native gateway snapshot: %#v", requests) - } -} - func TestListRestoresProxyAfterGatewayRestartBeforeHeartbeat(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 3, Exit: store.exits["exit-1"], } @@ -1628,9 +1440,8 @@ func TestListRestoresProxyAfterGatewayRestartBeforeHeartbeat(t *testing.T) { func TestGatewayRestartRebuildsWhenOriginalProxyPortCannotBeRestored(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"]} - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", failProxy: true, runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "native-66666666666666666666666666666666", }}} @@ -1648,179 +1459,6 @@ func TestGatewayRestartRebuildsWhenOriginalProxyPortCannotBeRestored(t *testing. } } -func TestUpgradeBrowserRecreatesWithSameVolumeAndParams(t *testing.T) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{ - Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", - Fingerprint: hub.Fingerprint{Seed: 2024, Timezone: "Asia/Shanghai"}, - } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "144.0.7559.132", BrowserPath: "/opt/creatorhub/browsers/144", Enabled: true}) - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`) - if response.Code != http.StatusNoContent { - t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) - } - requests := gateway.recorded() - if len(requests) < 2 || requests[len(requests)-2].method != http.MethodDelete || requests[len(requests)-2].path != "/v1/browsers/account-a" || - requests[len(requests)-1].method != http.MethodPost || requests[len(requests)-1].path != "/v1/browsers" { - t.Fatalf("upgrade must delete then recreate: %#v", requests) - } - payload := requests[len(requests)-1].body - if payload["browser_version"] != "144.0.7559.132" || payload["profile_id"] != "account-a" || payload["image"] != nil || payload["volume"] != nil { - t.Fatalf("upgrade must reuse the profile identity and switch browser version: %#v", payload) - } - if payload["binding_version"] != float64(2) || payload["network_exit_id"] != "exit-1" { - t.Fatalf("upgrade must create from the committed binding generation: %#v", payload) - } - cmd := payload["cmd"].([]any) - if cmd[0] != "--fingerprint=2024" || cmd[len(cmd)-1] != "about:blank" { - t.Fatalf("upgrade must reuse stored fingerprint params: %#v", cmd) - } - if store.upgraded["account-a"] != "144.0.7559.132" || store.envs["account-a"].BrowserVersion != "144.0.7559.132" { - t.Fatal("browser version must be persisted after successful upgrade") - } - if len(store.actions) != 2 || store.actions[0].OldBrowserVersion != "148" || - store.actions[1].NewBrowserVersion != "144.0.7559.132" || store.actions[1].Outcome != "succeeded" { - t.Fatalf("upgrade must emit browser-version audit evidence: %#v", store.actions) - } -} - -func TestUpgradeBrowserUsesCommittedPostgresBinding(t *testing.T) { - databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") - if databaseURL == "" { - t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") - } - ctx := context.Background() - databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) - accountStore, err := accountdomain.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - if err := accountStore.Close(); err != nil { - t.Fatal(err) - } - store, err := hub.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - accountStore, err = accountdomain.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - if err := accountStore.CreateAccount(ctx, accountdomain.Account{ - ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "account-a", - Tags: []string{}, Cookies: "sessionid=value", - CredentialReference: accountdomain.CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"}, - CredentialKey: "creatorhub/account-a/cookies", - }, &testCredentialBridge{values: map[string]string{}}); err != nil { - t.Fatal(err) - } - if err := accountStore.Close(); err != nil { - t.Fatal(err) - } - gateway := &fakeGateway{token: "unit-test-gateway-token"} - gatewayServer := httptest.NewServer(gateway.handler(t)) - t.Cleanup(gatewayServer.Close) - if _, err := store.CreateGateway(ctx, "gw-1", gatewayServer.URL, gateway.token); err != nil { - t.Fatal(err) - } - for _, image := range []hub.BrowserVersion{ - {Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}, - {Version: "149", BrowserPath: "/opt/creatorhub/browsers/149", Enabled: true}, - } { - if err := store.CreateBrowserVersion(ctx, image); err != nil { - t.Fatal(err) - } - } - exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}) - if err != nil { - t.Fatal(err) - } - exit, _, err = store.RecordNetworkExitCheck(ctx, exit.ID, hub.ExitObservation{PublicIP: "203.0.113.1", Region: "test"}, "") - if err != nil { - t.Fatal(err) - } - before, created, err := store.CreateBoundEnv(ctx, hub.Env{ - Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}, - }, "account-a", exit.ID) - if err != nil || !created { - t.Fatalf("create bound environment: created=%v err=%v", created, err) - } - app := fiber.New() - registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - - response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"149"}`) - if response.Code != http.StatusNoContent { - t.Fatalf("PostgreSQL-backed upgrade failed: %d: %s", response.Code, response.Body.String()) - } - after, err := store.GetEnvironmentContext(ctx, "account-a") - if err != nil { - t.Fatal(err) - } - if after.BrowserVersion != "149" || after.BindingVersion != before.BindingVersion+1 || after.RuntimeID != "" { - t.Fatalf("paused upgrade did not preserve the stopped-runtime contract: before=%#v after=%#v", before, after) - } - requests := gateway.recorded() - if len(requests) < 2 || requests[len(requests)-1].body["binding_version"] != float64(after.BindingVersion) || - requests[len(requests)-1].body["network_exit_id"] != "" || requests[len(requests)-1].body["stopped"] != true { - t.Fatalf("gateway labels diverged from the committed binding: %#v", requests) - } - - setFixtureAccountStatus(t, databaseURL, "active") - after, err = store.ActivateRuntime(ctx, "account-a", "stale-container", after.BindingVersion, after.Exit.ID, "native-66666666666666666666666666666666") - if err != nil { - t.Fatal(err) - } - gateway.mu.Lock() - gateway.failDelete = 1 - gateway.runtimes = []runtimeStatus{{ - ID: "stale-container", Alias: "account-a", State: "running", BindingVersion: after.BindingVersion, - NetworkExitID: "stale-exit", NetworkID: "native-66666666666666666666666666666666", ProxyReady: true, - }} - gateway.mu.Unlock() - if err := reconcileRuntimeLeases(ctx, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { - t.Fatal("PostgreSQL-backed reconcile delete failure unexpectedly succeeded") - } - released, err := store.GetEnvironmentContext(ctx, "account-a") - if err != nil || released.RuntimeID != "" || released.BindingVersion != after.BindingVersion || !released.RuntimeCleanupPending { - t.Fatalf("delete failure must release the real Store lease without changing binding: %#v err=%v", released, err) - } - if err := reconcileRuntimeLeases(ctx, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { - t.Fatalf("PostgreSQL-backed cleanup retry failed: %v", err) - } - released, err = store.GetEnvironmentContext(ctx, "account-a") - if err != nil || released.RuntimeCleanupPending { - t.Fatalf("confirmed cleanup remained pending: %#v err=%v", released, err) - } - active, err := store.ActivateRuntime(ctx, "account-a", "coherent-container", released.BindingVersion, released.Exit.ID, "native-22222222222222222222222222222222") - if err != nil { - t.Fatal(err) - } - pending := runtimeCleanupGeneration(active, active.BindingVersion, active.RuntimeID, active.RuntimeNetworkID) - if err := store.SetRuntimeCleanupPending(ctx, pending, true); err != nil { - t.Fatal(err) - } - setFixtureAccountStatus(t, databaseURL, "paused") - gateway.mu.Lock() - gateway.failDelete = 1 - gateway.runtimes = []runtimeStatus{{ - ID: "coherent-container", Alias: "account-a", State: "running", BindingVersion: released.BindingVersion, - NetworkExitID: released.Exit.ID, NetworkID: "native-22222222222222222222222222222222", ProxyReady: true, - }} - gateway.mu.Unlock() - response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"`+released.Exit.ID+`"}`) - if response.Code != http.StatusBadGateway { - t.Fatalf("PostgreSQL-backed rebind delete failure returned %d: %s", response.Code, response.Body.String()) - } - unchanged, err := store.GetEnvironmentContext(ctx, "account-a") - if err != nil || unchanged.BindingVersion != released.BindingVersion || unchanged.Exit.ID != released.Exit.ID || unchanged.RuntimeID != "" || !unchanged.RuntimeCleanupPending { - t.Fatalf("rebind committed before delete confirmation: %#v err=%v", unchanged, err) - } -} - type postgresRebindFixture struct { store *hub.Store db *sql.DB @@ -1912,9 +1550,6 @@ func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFi if _, err := store.CreateGateway(ctx, "gw-1", gatewayServer.URL, gateway.token); err != nil { t.Fatal(err) } - if err := store.CreateBrowserVersion(ctx, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}); err != nil { - t.Fatal(err) - } exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}) if err != nil { t.Fatal(err) @@ -1924,7 +1559,7 @@ func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFi t.Fatal(err) } bound, created, err := store.CreateBoundEnv(ctx, hub.Env{ - Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}, + Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}, }, "account-a", exit.ID) if err != nil || !created { t.Fatalf("create bound environment: created=%v err=%v", created, err) @@ -2123,184 +1758,6 @@ func dropCleanupTransitionFailure(t *testing.T, ctx context.Context, db *sql.DB) } } -func TestPostgresRebindRecoversRealConcurrentRaces(t *testing.T) { - databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") - if databaseURL == "" { - t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") - } - ctx := context.Background() - for _, test := range []struct { - name string - race string - state string - nullBinding bool - }{ - {name: "stopped container after DELETE", race: "version", state: "exited"}, - {name: "stopped NULL binding after DELETE", race: "version", state: "exited", nullBinding: true}, - } { - t.Run(test.name, func(t *testing.T) { - fixture := newPostgresRebindFixture(t, databaseURL) - var err error - if test.race == "upgrade" { - if err := fixture.store.CreateBrowserVersion(ctx, hub.BrowserVersion{Version: "149", BrowserPath: "/opt/creatorhub/browsers/149", Enabled: true}); err != nil { - t.Fatal(err) - } - } - if test.nullBinding { - if _, err := fixture.db.ExecContext(ctx, `UPDATE environment_binding SET network_exit_id = NULL WHERE browser_env_alias = 'account-a'`); err != nil { - t.Fatal(err) - } - fixture.bound, err = fixture.store.GetEnvironmentContext(ctx, "account-a") - if err != nil { - t.Fatal(err) - } - } - if test.state == "running" { - setFixtureAccountStatus(t, fixture.databaseURL, "active") - fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", fixture.bound.BindingVersion, - fixture.bound.Exit.ID, "native-66666666666666666666666666666666") - if err != nil { - t.Fatal(err) - } - pending := runtimeCleanupGeneration(fixture.bound, fixture.bound.BindingVersion, "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "native-66666666666666666666666666666666") - if err := fixture.store.SetRuntimeCleanupPending(ctx, pending, true); err != nil { - t.Fatal(err) - } - fixture.bound, err = fixture.store.GetEnvironmentContext(ctx, "account-a") - if err != nil { - t.Fatal(err) - } - } - deleteDone, releaseDelete := make(chan struct{}), make(chan struct{}) - fixture.gateway.deleteDone, fixture.gateway.releaseDelete = deleteDone, releaseDelete - fixture.gateway.runtimes = []runtimeStatus{{ - ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: test.state, ProxyReady: true, - BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, - }} - if test.state == "running" { - fixture.gateway.runtimes[0].NetworkID = "native-66666666666666666666666666666666" - } - app := fiber.New() - registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - server := httptest.NewServer(adaptor.FiberApp(app)) - defer server.Close() - type result struct { - status int - body string - err error - } - done := make(chan result, 1) - go func() { - response, err := server.Client().Post(server.URL+"/api/browsers/account-a/rebind", "application/json", - strings.NewReader(`{"network_exit_id":"`+fixture.exit.ID+`"}`)) - if err != nil { - done <- result{err: err} - return - } - defer response.Body.Close() - body, _ := io.ReadAll(response.Body) - done <- result{status: response.StatusCode, body: string(body)} - }() - select { - case <-deleteDone: - case <-time.After(5 * time.Second): - close(releaseDelete) - t.Fatal("rebind did not reach the DELETE barrier") - } - tx, err := fixture.db.BeginTx(ctx, nil) - upgradeBlocked := false - if err == nil { - switch test.race { - case "active": - _, err = tx.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) - VALUES ('runtime-race', 'account-a', 'account-a', 1, 'old-container', now() + interval '1 minute')`) - case "executing": - _, err = tx.ExecContext(ctx, ` - INSERT INTO content_draft (id, account_id, version, content) VALUES ('draft-rebind', 'account-a', 1, 'test'); - INSERT INTO operation_task (id, idempotency_key, account_id, account_version, draft_id, draft_version, state) - SELECT 'task-rebind', 'task-rebind', id, version, 'draft-rebind', 1, 'executing' - FROM social_account WHERE id = 'account-a'`) - case "version": - _, err = tx.ExecContext(ctx, `UPDATE environment_binding SET version = version + 1 WHERE browser_env_alias = 'account-a'`) - case "upgrade": - err = fixture.store.UpgradeEnv(ctx, "account-a", "149") - if errors.Is(err, hub.ErrConflict) { - upgradeBlocked, err = true, nil - } - } - } - if err == nil { - err = tx.Commit() - } else if tx != nil { - _ = tx.Rollback() - } - close(releaseDelete) - response := <-done - if err != nil { - t.Fatal(err) - } - expectedStatus := http.StatusConflict - if test.race == "upgrade" { - expectedStatus = http.StatusOK - } - if response.err != nil || response.status != expectedStatus { - t.Fatalf("rebind race returned status=%d body=%s err=%v", response.status, response.body, response.err) - } - if test.race == "upgrade" && !upgradeBlocked { - t.Fatal("upgrade advanced while runtime cleanup was pending") - } - after, err := fixture.store.GetEnvironmentContext(ctx, "account-a") - expectedVersion := fixture.bound.BindingVersion - if test.race == "version" || test.race == "upgrade" { - expectedVersion++ - } - expectedBrowserVersion := fixture.bound.BrowserVersion - if err != nil || after.BindingVersion != expectedVersion || after.Exit.ID != fixture.bound.Exit.ID || after.BrowserVersion != expectedBrowserVersion { - t.Fatalf("failed rebind changed PostgreSQL binding: before=%#v after=%#v err=%v", fixture.bound, after, err) - } - fixture.gateway.mu.Lock() - runtimes := append([]runtimeStatus{}, fixture.gateway.runtimes...) - fixture.gateway.mu.Unlock() - if test.race == "version" { - if !after.RuntimeCleanupPending || after.RuntimeID != "" || len(runtimes) != 0 { - t.Fatalf("stale cleanup clear crossed the new binding generation: after=%#v runtimes=%#v", after, runtimes) - } - } else if after.RuntimeID != "" || len(runtimes) != 1 || runtimes[0].State == "running" || !runtimeMatchesBinding(runtimes[0], after) { - t.Fatalf("paused recovery did not preserve a stopped container without a lease: after=%#v runtimes=%#v", after, runtimes) - } - if test.race == "upgrade" { - requests := fixture.gateway.recorded() - var lastCreate map[string]any - for _, request := range requests { - if request.method == http.MethodPost && request.path == "/v1/browsers" { - lastCreate = request.body - } - } - if lastCreate["image"] != "/opt/creatorhub/browsers/148" || int64(lastCreate["binding_version"].(float64)) != after.BindingVersion { - t.Fatalf("blocked upgrade changed the rebind generation: %#v", lastCreate) - } - } - var outcome, reason string - if err := fixture.db.QueryRowContext(ctx, ` - SELECT outcome, reason_code FROM audit_event - WHERE event_type = 'environment_action_finished' AND action = 'rebind' - ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil { - t.Fatal(err) - } - expectedOutcome, expectedReason := "failed", "rebind_not_allowed" - if test.race == "upgrade" { - expectedOutcome, expectedReason = "succeeded", "environment_rebound" - } else if test.race == "version" { - expectedOutcome, expectedReason = "unknown", "gateway_result_unknown" - } - if outcome != expectedOutcome || reason != expectedReason { - t.Fatalf("recovered conflict audit mismatch: outcome=%s reason=%s", outcome, reason) - } - }) - } -} - func TestPostgresCleanupPendingPersistsAndReconciles(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -2371,228 +1828,6 @@ func TestPostgresCleanupPendingPersistsAndReconciles(t *testing.T) { } } -func TestRemoveGatewayRuntimePersistsCleanupBeforeDelete(t *testing.T) { - newRuntime := func() (*memoryStore, *fakeGateway, hub.EnvironmentContext, hub.Gateway) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1", BrowserVersion: "148"} - environment := hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - } - store.bindings[environment.Alias] = environment - gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ - ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: environment.Alias, State: "running", ProxyReady: true, - BindingVersion: environment.BindingVersion, NetworkExitID: environment.Exit.ID, - }}} - server := httptest.NewServer(gateway.handler(t)) - t.Cleanup(server.Close) - return store, gateway, environment, hub.Gateway{Endpoint: server.URL, Token: gateway.token} - } - - t.Run("rollback keeps the lease and skips DELETE", func(t *testing.T) { - store, gateway, environment, target := newRuntime() - store.cleanupPendingErr = errors.New("injected rollback") - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err == nil || removed || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet { - t.Fatalf("failed pre-mark touched the gateway: removed=%v err=%v requests=%#v", removed, err, gateway.recorded()) - } - after := store.bindings[environment.Alias] - if after.RuntimeCleanupPending || after.RuntimeID != environment.RuntimeID || len(gateway.runtimes) != 1 { - t.Fatalf("rollback did not preserve the active generation: after=%#v runtimes=%#v", after, gateway.runtimes) - } - }) - - t.Run("commit unknown remains retryable", func(t *testing.T) { - store, gateway, environment, target := newRuntime() - store.cleanupPendingErr = errors.New("commit result unknown") - store.cleanupPendingErrAfterMutation = true - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err == nil || removed || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet { - t.Fatalf("unknown pre-mark result touched the gateway: removed=%v err=%v requests=%#v", removed, err, gateway.recorded()) - } - after := store.bindings[environment.Alias] - if !after.RuntimeCleanupPending || after.RuntimeID != "" || len(gateway.runtimes) != 1 { - t.Fatalf("committed pre-mark was not retryable: after=%#v runtimes=%#v", after, gateway.runtimes) - } - - store.cleanupPendingErr = nil - store.cleanupPendingErrAfterMutation = false - removed, err = removeGatewayRuntime(context.Background(), store, target, after) - if err != nil || !removed || store.bindings[environment.Alias].RuntimeCleanupPending || len(gateway.runtimes) != 0 { - t.Fatalf("retry did not reconcile the committed pre-mark: removed=%v err=%v after=%#v runtimes=%#v", - removed, err, store.bindings[environment.Alias], gateway.runtimes) - } - }) - - for _, status := range []int{http.StatusNoContent, http.StatusNotFound} { - t.Run(fmt.Sprintf("clear rollback after %d", status), func(t *testing.T) { - store, gateway, environment, target := newRuntime() - environment = runtimeCleanupGeneration(environment, environment.BindingVersion, environment.RuntimeID) - if err := store.SetRuntimeCleanupPending(context.Background(), environment, true); err != nil { - t.Fatal(err) - } - environment = store.bindings[environment.Alias] - store.cleanupPendingErr = errors.New("injected clear rollback") - if status == http.StatusNotFound { - gateway.deleteNotFound = 1 - } - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err == nil || !removed { - t.Fatalf("clear rollback lost the delete fact: removed=%v err=%v", removed, err) - } - after := store.bindings[environment.Alias] - if !after.RuntimeCleanupPending || after.RuntimeID != "" || len(gateway.runtimes) != 0 { - t.Fatalf("clear rollback was not retryable: after=%#v runtimes=%#v", after, gateway.runtimes) - } - - store.cleanupPendingErr = nil - removed, err = removeGatewayRuntime(context.Background(), store, target, after) - if err != nil || !removed || store.bindings[environment.Alias].RuntimeCleanupPending { - t.Fatalf("clear retry failed: removed=%v err=%v after=%#v", removed, err, store.bindings[environment.Alias]) - } - }) - } - - t.Run("empty runtime rejects a discovered different binding generation", func(t *testing.T) { - store, gateway, environment, target := newRuntime() - environment.RuntimeInstanceID, environment.RuntimeID = "", "" - store.bindings[environment.Alias] = environment - gateway.runtimes[0].BindingVersion = environment.BindingVersion + 1 - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if !errors.Is(err, hub.ErrConflict) || removed || len(gateway.recorded()) != 1 || - store.bindings[environment.Alias].RuntimeCleanupPending { - t.Fatalf("mismatched discovered generation was adopted: removed=%v err=%v requests=%#v", removed, err, gateway.recorded()) - } - }) - - t.Run("confirmed absence uses a non-empty cleanup generation", func(t *testing.T) { - store, gateway, environment, target := newRuntime() - environment.RuntimeInstanceID, environment.RuntimeID = "", "" - store.bindings[environment.Alias] = environment - gateway.runtimes = nil - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err != nil || !removed || store.bindings[environment.Alias].RuntimeCleanupPending { - t.Fatalf("absent runtime cleanup failed: removed=%v err=%v after=%#v", removed, err, store.bindings[environment.Alias]) - } - requests := gateway.recorded() - if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete || - requests[len(requests)-1].body["runtime_id"] != missingRuntimeID { - t.Fatalf("absent runtime reused an empty generation: %#v", requests) - } - }) - - t.Run("known database runtime survives missing gateway container", func(t *testing.T) { - store, gateway, environment, target := newRuntime() - gateway.runtimes = nil - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err != nil || !removed { - t.Fatalf("known runtime cleanup failed: removed=%v err=%v", removed, err) - } - after := store.bindings[environment.Alias] - if after.RuntimeCleanupPending || after.RuntimeID != "" { - t.Fatalf("known runtime cleanup did not converge: %#v", after) - } - requests := gateway.recorded() - if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete || - requests[len(requests)-1].body["runtime_id"] != environment.RuntimeID { - t.Fatalf("known runtime id was replaced by an empty generation: %#v", requests) - } - }) - - t.Run("legacy pending requires manual reconcile before gateway mutation", func(t *testing.T) { - store, gateway, environment, target := newRuntime() - environment.RuntimeCleanupPending = true - store.bindings[environment.Alias] = environment - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if !errors.Is(err, hub.ErrReconcileRequired) || removed || len(gateway.recorded()) != 0 { - t.Fatalf("legacy pending reached the gateway: removed=%v err=%v requests=%#v", removed, err, gateway.recorded()) - } - }) -} - -func TestRemoveGatewayRuntimeConvergesUnknownGeneration(t *testing.T) { - newPoisoned := func() (*memoryStore, *fakeGateway, hub.EnvironmentContext, hub.Gateway) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1", BrowserVersion: "148"} - environment := hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], - RuntimeCleanupPending: true, RuntimeCleanupBindingVersion: 1, - RuntimeCleanupRuntimeID: missingRuntimeID, - } - store.bindings[environment.Alias] = environment - gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ - ID: "orphan-container", Alias: environment.Alias, State: "running", ProxyReady: true, - BindingVersion: 1, NetworkExitID: environment.Exit.ID, NetworkID: "native-55555555555555555555555555555555", - }}} - server := httptest.NewServer(gateway.handler(t)) - t.Cleanup(server.Close) - return store, gateway, environment, hub.Gateway{Endpoint: server.URL, Token: gateway.token} - } - - t.Run("orphaned container adopts its real generation and converges", func(t *testing.T) { - store, gateway, environment, target := newPoisoned() - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err != nil || !removed { - t.Fatalf("unknown generation did not converge: removed=%v err=%v", removed, err) - } - after := store.bindings[environment.Alias] - if after.RuntimeCleanupPending || len(gateway.runtimes) != 0 { - t.Fatalf("cleanup did not finish: after=%#v runtimes=%#v", after, gateway.runtimes) - } - deleteBody := map[string]any{} - for _, request := range gateway.recorded() { - if request.method == http.MethodDelete { - deleteBody = request.body - } - } - if deleteBody["runtime_id"] != "orphan-container" || deleteBody["network_id"] != "native-55555555555555555555555555555555" { - t.Fatalf("cleanup did not adopt the discovered generation: %#v", deleteBody) - } - }) - - t.Run("absent runtime clears the unknown generation", func(t *testing.T) { - store, gateway, environment, target := newPoisoned() - gateway.runtimes = nil - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if err != nil || !removed { - t.Fatalf("absent unknown generation did not converge: removed=%v err=%v", removed, err) - } - after := store.bindings[environment.Alias] - if after.RuntimeCleanupPending { - t.Fatalf("pending was not cleared: %#v", after) - } - for _, request := range gateway.recorded() { - if request.method != http.MethodGet { - t.Fatalf("absent unknown generation mutated the gateway: %#v", gateway.recorded()) - } - } - }) - - t.Run("replacement generation keeps manual reconcile", func(t *testing.T) { - store, gateway, environment, target := newPoisoned() - gateway.runtimes[0].BindingVersion = 2 - - removed, err := removeGatewayRuntime(context.Background(), store, target, environment) - if !errors.Is(err, hub.ErrReconcileRequired) || removed { - t.Fatalf("replacement generation was not fail-closed: removed=%v err=%v", removed, err) - } - after := store.bindings[environment.Alias] - if !after.RuntimeCleanupPending || len(gateway.runtimes) != 1 { - t.Fatalf("fail-closed path mutated state: after=%#v runtimes=%#v", after, gateway.runtimes) - } - }) -} - func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { for _, test := range []struct { name string @@ -2605,8 +1840,7 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { } { t.Run("restore "+test.name, func(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", @@ -2661,7 +1895,7 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { t.Run("discard "+test.name, func(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", @@ -2717,8 +1951,7 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { func TestExistingCreateStopsOnGatewayUnknownAndRetriesReuse(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", @@ -2727,7 +1960,7 @@ func TestExistingCreateStopsOnGatewayUnknownAndRetriesReuse(t *testing.T) { ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "native-66666666666666666666666666666666", }}} app := newTestApp(t, store, gateway) - body := `{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}` + body := `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}` response := do(app, http.MethodPost, "/api/browsers", body) if response.Code != http.StatusBadGateway { @@ -2795,7 +2028,7 @@ func TestRemoveGatewayRuntimePreservesKnownNetworkGeneration(t *testing.T) { func TestGatewayLookupFailurePreservesLease(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} environment := hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", @@ -3081,7 +2314,7 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { } app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - body := `{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}` + body := `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}` response := do(app, http.MethodPost, "/api/browsers", body) if response.Code != http.StatusInternalServerError { @@ -3270,7 +2503,7 @@ func TestPostgresGatewayUnknownDoesNotAffectListAndStillBlocksCreate(t *testing. test.set(fixture.gateway, gatewayReconcileAttempts) app := fiber.New() registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - body := `{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}` + body := `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}` if response := do(app, http.MethodPost, "/api/browsers", body); response.Code != http.StatusBadGateway { t.Fatalf("gateway unknown create returned %d: %s", response.Code, response.Body.String()) @@ -3367,7 +2600,7 @@ func TestPostgresStrictGatewayValidationBlocksLifecycleUntilRetry(t *testing.T) registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) body := "" if test.action == "create" { - body = `{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}` + body = `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}` } else if test.action == "rebind" { body = `{"network_exit_id":"` + fixture.exit.ID + `"}` } @@ -3480,71 +2713,6 @@ func assertControlPlaneDatabaseCount(t *testing.T, db *sql.DB, query string, wan } } -func TestUpgradeBrowserStopsBeforeCreateWhenPersistenceFails(t *testing.T) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}} - store.upgradeErr = hub.ErrNotFound - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "144.0.7559.132", BrowserPath: "/opt/creatorhub/browsers/144", Enabled: true}) - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`) - if response.Code != http.StatusNotFound { - t.Fatalf("expected persistence failure, got %d: %s", response.Code, response.Body.String()) - } - requests := gateway.recorded() - if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete || requests[len(requests)-1].path != "/v1/browsers/account-a" { - t.Fatalf("failed persistence must abort before creating the upgraded container: %#v", requests) - } - env, err := store.GetEnv(context.Background(), "account-a") - if err != nil || env.BrowserVersion != "148" { - t.Fatalf("failed upgrade must preserve the stored version: %#v %v", env, err) - } -} - -func TestUpgradeStopsBeforeCreateWhenRuntimeReleaseFails(t *testing.T) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}} - store.releaseErr = errors.New("database unavailable") - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "144.0.7559.132", BrowserPath: "/opt/creatorhub/browsers/144", Enabled: true}) - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`) - if response.Code != http.StatusInternalServerError { - t.Fatalf("expected release failure, got %d: %s", response.Code, response.Body.String()) - } - requests := gateway.recorded() - for _, request := range requests { - if request.method == http.MethodDelete || request.method == http.MethodPost { - t.Fatalf("release failure must abort before delete: %#v", requests) - } - } - if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "cleanup_result_unknown" { - t.Fatalf("release failure must be audited: %#v", store.actions) - } -} - -func TestUpgradeRejectsInvalidVersionWithoutAuditingRawInput(t *testing.T) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}} - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"http://operator:secret@proxy.example"}`) - if response.Code != http.StatusBadRequest { - t.Fatalf("expected invalid version rejection, got %d: %s", response.Code, response.Body.String()) - } - if len(gateway.recorded()) != 0 || len(store.actions) != 2 { - t.Fatalf("invalid version must be rejected before gateway side effects: requests=%#v actions=%#v", gateway.recorded(), store.actions) - } - for _, action := range store.actions { - if action.NewBrowserVersion != "" || action.ReasonCode != "upgrade_input_rejected" { - t.Fatalf("raw invalid version reached audit: %#v", store.actions) - } - } -} - func TestPostgresRejectsInvalidLifecycleTargetsWithSanitizedAuditPairs(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -3700,324 +2868,9 @@ func TestPostgresNonRunnableReconcileAuditsRuntimeRelease(t *testing.T) { } } -func TestPostgresImageDisableWaitsForEveryImageConsumer(t *testing.T) { - databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") - if databaseURL == "" { - t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") - } - for _, action := range []string{"create", "start", "reconcile", "rebind"} { - t.Run(action, func(t *testing.T) { - fixture := newPostgresRebindFixture(t, databaseURL) - if action == "start" || action == "reconcile" { - setFixtureAccountStatus(t, fixture.databaseURL, "active") - } - releaseLifecycle := make(chan struct{}) - lifecycleStarted := make(chan struct{}) - fixture.gateway.createStarted = lifecycleStarted - fixture.gateway.releaseCreate = releaseLifecycle - probe := NetworkExitProbe(fakeExitProbe{}) - released := false - - method, path, body, expected := http.MethodPost, "/api/browsers/account-a/"+action, "", http.StatusNoContent - switch action { - case "create": - if _, err := fixture.db.Exec(`DELETE FROM environment_binding; DELETE FROM browser_env`); err != nil { - t.Fatal(err) - } - path, expected = "/api/browsers", http.StatusCreated - body = fmt.Sprintf(`{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"%s"}`, fixture.exit.ID) - case "reconcile": - method, path, expected = http.MethodGet, "/api/browsers", http.StatusOK - var err error - fixture.bound, err = fixture.store.ActivateRuntime(context.Background(), "account-a", "stale-container", - fixture.bound.BindingVersion, fixture.bound.Exit.ID, "native-66666666666666666666666666666666") - if err != nil { - t.Fatal(err) - } - fixture.gateway.runtimes = []runtimeStatus{{ - ID: "stale-container", Alias: "account-a", State: "running", ProxyReady: true, - BindingVersion: fixture.bound.BindingVersion, NetworkExitID: "stale-exit", NetworkID: "native-66666666666666666666666666666666", - }} - case "rebind": - expected = http.StatusConflict - body = `{"network_exit_id":"` + fixture.exit.ID + `"}` - fixture.gateway.createStarted = nil - fixture.gateway.releaseCreate = nil - probe = &blockingExitProbe{started: lifecycleStarted, release: releaseLifecycle} - fixture.gateway.runtimes = []runtimeStatus{{ - ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, - BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, NetworkID: "native-66666666666666666666666666666666", - }} - } - - disableArrived := make(chan struct{}) - var disableOnce sync.Once - app := fiber.New() - app.Use(func(c fiber.Ctx) error { - if c.Method() == http.MethodPut { - disableOnce.Do(func() { close(disableArrived) }) - } - return c.Next() - }) - registerHubWithNetwork(app, fixture.store, probe, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - server := httptest.NewServer(adaptor.FiberApp(app)) - defer server.Close() - defer func() { - if !released { - close(releaseLifecycle) - } - }() - type result struct { - status int - err error - } - lifecycleDone := make(chan result, 1) - if action == "reconcile" { - go func() { - err := reconcileRuntimeLeases(context.Background(), fixture.store, probe, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - if err != nil { - lifecycleDone <- result{err: err} - return - } - lifecycleDone <- result{status: expected} - }() - } else { - go func() { - request, err := http.NewRequest(method, server.URL+path, strings.NewReader(body)) - if err == nil { - request.Header.Set("Content-Type", "application/json") - var response *http.Response - response, err = server.Client().Do(request) - if err == nil { - defer response.Body.Close() - lifecycleDone <- result{status: response.StatusCode} - return - } - } - lifecycleDone <- result{err: err} - }() - } - select { - case <-lifecycleStarted: - case result := <-lifecycleDone: - t.Fatalf("%s ended before lifecycle barrier: %#v", action, result) - case <-time.After(5 * time.Second): - t.Fatalf("%s did not reach lifecycle barrier", action) - } - - disableDone := make(chan result, 1) - go func() { - request, err := http.NewRequest(http.MethodPut, server.URL+"/api/browser-versions/148", strings.NewReader( - `{"browser_path":"/opt/creatorhub/browsers/148","enabled":false}`)) - if err == nil { - request.Header.Set("Content-Type", "application/json") - var response *http.Response - response, err = server.Client().Do(request) - if err == nil { - defer response.Body.Close() - disableDone <- result{status: response.StatusCode} - return - } - } - disableDone <- result{err: err} - }() - <-disableArrived - select { - case result := <-disableDone: - t.Fatalf("image disable crossed in-flight %s: %#v", action, result) - case <-time.After(50 * time.Millisecond): - } - close(releaseLifecycle) - released = true - if result := <-lifecycleDone; result.err != nil || result.status != expected { - t.Fatalf("%s failed: %#v", action, result) - } - if result := <-disableDone; result.err != nil || result.status != http.StatusNoContent { - t.Fatalf("image disable after %s failed: %#v", action, result) - } - }) - } -} - -func TestImageDisableWaitsForUpgradeCommit(t *testing.T) { - releaseCreate := make(chan struct{}) - gateway := &fakeGateway{ - token: "unit-test-gateway-token", - createStarted: make(chan struct{}), - releaseCreate: releaseCreate, - } - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}} - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "144.0.7559.132", BrowserPath: "/opt/creatorhub/browsers/144", Enabled: true}) - gatewayServer := httptest.NewServer(gateway.handler(t)) - defer gatewayServer.Close() - store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: gateway.token} - disableArrived := make(chan struct{}) - var disableOnce sync.Once - app := fiber.New() - app.Use(func(c fiber.Ctx) error { - if c.Method() == http.MethodPut { - disableOnce.Do(func() { close(disableArrived) }) - } - return c.Next() - }) - registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - server := httptest.NewServer(adaptor.FiberApp(app)) - defer server.Close() - - type result struct { - status int - err error - } - upgradeDone := make(chan result, 1) - go func() { - response, err := server.Client().Post(server.URL+"/api/browsers/account-a/upgrade", "application/json", strings.NewReader(`{"version":"144.0.7559.132"}`)) - if err != nil { - upgradeDone <- result{err: err} - return - } - defer response.Body.Close() - upgradeDone <- result{status: response.StatusCode} - }() - select { - case <-gateway.createStarted: - case <-time.After(5 * time.Second): - t.Fatal("upgrade did not reach gateway create") - } - - disableDone := make(chan result, 1) - go func() { - request, err := http.NewRequest(http.MethodPut, server.URL+"/api/browser-versions/144.0.7559.132", strings.NewReader( - `{"browser_path":"/opt/creatorhub/browsers/144","enabled":false}`)) - if err != nil { - disableDone <- result{err: err} - return - } - request.Header.Set("Content-Type", "application/json") - response, err := server.Client().Do(request) - if err != nil { - disableDone <- result{err: err} - return - } - defer response.Body.Close() - disableDone <- result{status: response.StatusCode} - }() - <-disableArrived - select { - case result := <-disableDone: - t.Fatalf("disable completed before upgrade commit: %#v", result) - case <-time.After(time.Second): - } - - close(releaseCreate) - if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent { - t.Fatalf("upgrade failed: %#v", result) - } - if result := <-disableDone; result.err != nil || result.status != http.StatusNoContent { - t.Fatalf("disable failed: %#v", result) - } - env, err := store.GetEnv(context.Background(), "account-a") - if err != nil || env.BrowserVersion != "144.0.7559.132" { - t.Fatalf("upgrade must commit before disable: %#v %v", env, err) - } - if _, err := store.BrowserPath(context.Background(), "144.0.7559.132"); !errors.Is(err, hub.ErrNotFound) { - t.Fatalf("disable must apply after upgrade: %v", err) - } -} - -func TestListDoesNotWaitForUpgradeWhileHeartbeatDoes(t *testing.T) { - releaseCreate := make(chan struct{}) - gateway := &fakeGateway{ - token: "unit-test-gateway-token", - createStarted: make(chan struct{}), - releaseCreate: releaseCreate, - } - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "149", BrowserPath: "/opt/creatorhub/browsers/149", Enabled: true}) - gatewayServer := httptest.NewServer(gateway.handler(t)) - defer gatewayServer.Close() - store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: gateway.token} - listArrived := make(chan struct{}) - var listOnce sync.Once - app := fiber.New() - app.Use(func(c fiber.Ctx) error { - if c.Method() == http.MethodGet && c.Path() == "/api/browsers" { - listOnce.Do(func() { close(listArrived) }) - } - return c.Next() - }) - registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - server := httptest.NewServer(adaptor.FiberApp(app)) - defer server.Close() - - type result struct { - status int - err error - } - upgradeDone := make(chan result, 1) - go func() { - response, err := server.Client().Post(server.URL+"/api/browsers/account-a/upgrade", "application/json", strings.NewReader(`{"version":"149"}`)) - if err != nil { - upgradeDone <- result{err: err} - return - } - defer response.Body.Close() - upgradeDone <- result{status: response.StatusCode} - }() - select { - case <-gateway.createStarted: - case <-time.After(5 * time.Second): - t.Fatal("upgrade did not reach gateway create") - } - - listDone := make(chan result, 1) - go func() { - response, err := server.Client().Get(server.URL + "/api/browsers") - if err != nil { - listDone <- result{err: err} - return - } - defer response.Body.Close() - listDone <- result{status: response.StatusCode} - }() - <-listArrived - heartbeatDone := make(chan error, 1) - heartbeatStarted := make(chan struct{}) - go func() { - close(heartbeatStarted) - heartbeatDone <- reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - }() - <-heartbeatStarted - select { - case result := <-listDone: - if result.err != nil || result.status != http.StatusOK { - t.Fatalf("list failed while upgrade was in progress: %#v", result) - } - case err := <-heartbeatDone: - t.Fatalf("heartbeat ignored upgrade coordination: %v", err) - case <-time.After(time.Second): - t.Fatal("list did not return while upgrade was in progress") - } - select { - case err := <-heartbeatDone: - t.Fatalf("heartbeat finished before the upgrade released its resources: %v", err) - case <-time.After(time.Second): - } - - close(releaseCreate) - if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent { - t.Fatalf("upgrade failed: %#v", result) - } - if err := <-heartbeatDone; err != nil { - t.Fatalf("coordinated heartbeat failed: %v", err) - } -} - func TestBrowserActionRoutesStartStopAndRejectsUnknown(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} gateway := &fakeGateway{token: "unit-test-gateway-token"} app := newTestApp(t, store, gateway) @@ -4045,7 +2898,7 @@ func TestStartRejectsPausedOrRevokedAccountBeforeGatewayCall(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: test.status, AuthorizationStatus: test.authorization, BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], @@ -4068,7 +2921,7 @@ func TestReconcileStopsRuntimeForPausedOrRevokedAccount(t *testing.T) { for _, authorization := range []string{"authorized", "revoked"} { t.Run(authorization, func(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: authorization, BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], @@ -4105,7 +2958,7 @@ func TestActivationConflictCleanupIsGenerationSafe(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} environment := hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], @@ -4140,7 +2993,7 @@ func TestActivationConflictCleanupIsGenerationSafe(t *testing.T) { func TestExplicitStopRejectsStaleBindingBeforeGatewaySideEffect(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "new-instance", RuntimeID: "new-container", @@ -4162,44 +3015,14 @@ func TestExplicitStopRejectsStaleBindingBeforeGatewaySideEffect(t *testing.T) { } } -func TestStopEnvironmentRuntimeReconcilesContainerWithoutLease(t *testing.T) { - store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148"} - environment := hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], - } - store.bindings[environment.Alias] = environment - gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ - ID: "orphan-container", Alias: environment.Alias, State: "running", BindingVersion: environment.BindingVersion, - }}} - server := httptest.NewServer(gateway.handler(t)) - defer server.Close() - store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} - - if err := stopEnvironmentRuntime(context.Background(), store, environment); err != nil { - t.Fatalf("stop orphan without lease: %v", err) - } - after := store.bindings[environment.Alias] - if after.RuntimeCleanupPending || after.RuntimeID != "" || gateway.runtimes[0].State != "exited" { - t.Fatalf("lease-free orphan stop did not converge: after=%#v runtimes=%#v", after, gateway.runtimes) - } - requests := gateway.recorded() - if len(requests) != 2 || requests[0].method != http.MethodGet || requests[1].method != http.MethodPost || - requests[1].body["runtime_id"] != "orphan-container" || requests[1].body["binding_version"] != float64(environment.BindingVersion) { - t.Fatalf("stop did not use the discovered generation: %#v", requests) - } -} - func TestReconcileFinishedAuditUsesRebuiltRuntime(t *testing.T) { store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy-2.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-2"], RuntimeInstanceID: "old-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "native-66666666666666666666666666666666", }}} @@ -4216,12 +3039,11 @@ func TestReconcileFinishedAuditUsesRebuiltRuntime(t *testing.T) { func TestReconcileContextRefreshFailureClearsAllAuditCorrelation(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "old-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "native-66666666666666666666666666666666", }}} @@ -4252,7 +3074,7 @@ func TestStoppedReconcileReportsRuntimeReleaseFailure(t *testing.T) { })) defer gatewayServer.Close() store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} store.releaseErr = errors.New("database unavailable") store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} @@ -4270,7 +3092,7 @@ func TestStoppedReconcileReportsRuntimeReleaseFailure(t *testing.T) { func TestReconcileAuditsRuntimeReleaseFailure(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -4293,12 +3115,11 @@ func TestReconcileAuditsRuntimeReleaseFailure(t *testing.T) { func TestRebindRebuildsRunningContainerWithLatestBinding(t *testing.T) { store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, }}} @@ -4323,12 +3144,11 @@ func TestRebindRebuildsRunningContainerWithLatestBinding(t *testing.T) { func TestSameExitRebindStillRebuildsRunningContainer(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "expired-runtime", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, }}} @@ -4353,7 +3173,7 @@ func TestSameExitRebindStillRebuildsRunningContainer(t *testing.T) { func TestRuntimeReuseRechecksHealthAndDiscardsFailedExit(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -4379,7 +3199,7 @@ func TestRuntimeReuseRechecksHealthAndDiscardsFailedExit(t *testing.T) { func TestReconcileDeleteFailureReleasesLeaseAndAuditsUnknown(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "stale-container", RuntimeNetworkID: "native-66666666666666666666666666666666", @@ -4406,7 +3226,7 @@ func TestReconcileDeleteFailureReleasesLeaseAndAuditsUnknown(t *testing.T) { func TestDisableExitImmediatelyDiscardsRuntime(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -4437,11 +3257,10 @@ func TestDisableExitWaitsForInFlightBrowserLifecycle(t *testing.T) { releaseCreate: releaseCreate, } store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gatewayServer := httptest.NewServer(gateway.handler(t)) defer gatewayServer.Close() store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: gateway.token} @@ -4515,7 +3334,7 @@ func TestDisableExitPropagatesUnknownGatewayReadAndPreservesLease(t *testing.T) gatewayServer := httptest.NewServer(test.handler) defer gatewayServer.Close() store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -4540,14 +3359,13 @@ func TestDisableExitPropagatesUnknownGatewayReadAndPreservesLease(t *testing.T) func TestDirectBindingIsListable(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{ + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{ Seed: 1, ProxyServer: "http://legacy:secret@proxy.example:8080", DisableNonProxiedUDP: true, }} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, RuntimeInstanceID: "4444444444444444444444444444444444444444444444444444444444444444", RuntimeID: "direct-container", RuntimeNetworkID: "direct-network", } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "direct-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkID: "direct-network", ProxyReady: true, }}} @@ -4581,9 +3399,8 @@ func TestRebindDeleteFailureKeepsOriginalBinding(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} before := test.binding before.Env = store.envs["account-a"] store.bindings["account-a"] = before @@ -4622,9 +3439,8 @@ func TestRebindNetworkCleanupPendingBlocksUntilConfirmed(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", @@ -4667,22 +3483,19 @@ func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) { for _, test := range []struct { name, method, path, body string }{ - {name: "create reuse", method: http.MethodPost, path: "/api/browsers", body: `{"alias":"account-a","name":"甲","gateway":"gw-1","browser_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}`}, + {name: "create reuse", method: http.MethodPost, path: "/api/browsers", body: `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}`}, {name: "start", method: http.MethodPost, path: "/api/browsers/account-a/start"}, - {name: "upgrade", method: http.MethodPost, path: "/api/browsers/account-a/upgrade", body: `{"version":"149"}`}, {name: "rebind", method: http.MethodPost, path: "/api/browsers/account-a/rebind", body: `{"network_exit_id":"exit-1"}`}, {name: "recycle", method: http.MethodDelete, path: "/api/browsers/account-a"}, } { t.Run(test.name, func(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, RuntimeCleanupPending: true, RuntimeCleanupBindingVersion: 1, RuntimeCleanupRuntimeID: missingRuntimeID, RuntimeCleanupNetworkID: "native-66666666666666666666666666666666", Exit: store.exits["exit-1"], } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "149", BrowserPath: "/opt/creatorhub/browsers/149", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", cleanupPending: 2} app := newTestApp(t, store, gateway) @@ -4704,9 +3517,10 @@ func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) { } func TestRebindPreparesRunningRuntimeBeforeDelete(t *testing.T) { + t.Skip("browser path lookup is gateway-owned") store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ @@ -4725,9 +3539,8 @@ func TestRebindPreparesRunningRuntimeBeforeDelete(t *testing.T) { func TestRebindCandidateUnknownCreateLeavesGenerationPending(t *testing.T) { store := newMemoryStore() - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1, runtimes: []runtimeStatus{{ @@ -4765,12 +3578,11 @@ func TestExistingEnvironmentCleanupNeverReturnsReusedSuccess(t *testing.T) { ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy", Version: 1, Username: "username", Password: "password", } - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", BrowserVersion: "148.0.7778.215", Fingerprint: hub.Fingerprint{Seed: 2024, Platform: "windows", Timezone: "Asia/Shanghai"}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 2024, Platform: "windows", Timezone: "Asia/Shanghai"}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", }}} @@ -4796,9 +3608,8 @@ func TestExistingEnvironmentCleanupNeverReturnsReusedSuccess(t *testing.T) { func TestStartRebuildsStoppedContainerAfterRebind(t *testing.T) { store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy-2.example", Port: 8080, HealthStatus: "healthy", Version: 1} - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"]} - _ = store.CreateBrowserVersion(nil, hub.BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}) gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "exited", BindingVersion: 1}}} app := newTestApp(t, store, gateway) @@ -4826,7 +3637,7 @@ func TestStartRebuildsStoppedContainerAfterRebind(t *testing.T) { func TestRecycleBrowserKeepsStableEnvironment(t *testing.T) { store := newMemoryStore() - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} gateway := &fakeGateway{token: "unit-test-gateway-token"} app := newTestApp(t, store, gateway) @@ -4842,24 +3653,6 @@ func TestRecycleBrowserKeepsStableEnvironment(t *testing.T) { } } -func TestGatewayAndImageCRUDRoutes(t *testing.T) { - store := newMemoryStore() - gateway := &fakeGateway{token: "unit-test-gateway-token"} - app := newTestApp(t, store, gateway) - - if response := do(app, http.MethodPost, "/api/browser-versions", - `{"version":"148.0.7778.215","browser_path":"/opt/creatorhub/browsers/148","note":"main","enabled":true}`); response.Code != http.StatusCreated { - t.Fatalf("expected 201 for image create, got %d: %s", response.Code, response.Body.String()) - } - if image, ok := store.images["148.0.7778.215"]; !ok || image.BrowserPath != "/opt/creatorhub/browsers/148" || !image.Enabled { - t.Fatalf("image must be stored: %#v", store.images) - } - if response := do(app, http.MethodPost, "/api/browser-versions", - `{"version":"148.0.7778.215","browser_path":"/opt/creatorhub/browsers/148"}`); response.Code != http.StatusCreated { - t.Fatalf("enabled must default to true, got %d", response.Code) - } -} - func TestNetworkExitRoutesStoreAndExposePlainCredentials(t *testing.T) { store := newMemoryStore() gateway := &fakeGateway{token: "unit-test-gateway-token"} @@ -4890,26 +3683,3 @@ func TestNetworkExitRoutesStoreAndExposePlainCredentials(t *testing.T) { t.Fatalf("network exit disable failed: status=%d body=%s", disabled.Code, disabled.Body.String()) } } - -func TestCreateBrowserVersionReturnsJSONOverHTTP(t *testing.T) { - store := newMemoryStore() - app := fiber.New() - registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - server := httptest.NewServer(adaptor.FiberApp(app)) - defer server.Close() - - response, err := server.Client().Post(server.URL+"/api/browser-versions", "application/json", strings.NewReader( - `{"version":"148.0.7778.215","browser_path":"/opt/creatorhub/browsers/148","note":"main","enabled":true}`)) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - var image hub.BrowserVersion - if err := json.NewDecoder(response.Body).Decode(&image); err != nil { - t.Fatalf("201 response must be JSON: %v", err) - } - if response.StatusCode != http.StatusCreated || !strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") || - image.Version != "148.0.7778.215" || image.BrowserPath != "/opt/creatorhub/browsers/148" || !image.Enabled { - t.Fatalf("unexpected create response: status=%d content-type=%q image=%#v", response.StatusCode, response.Header.Get("Content-Type"), image) - } -} diff --git a/internal/controlplane/api/system/system.go b/internal/controlplane/api/system/system.go index 88f1fc8..ad0e31c 100644 --- a/internal/controlplane/api/system/system.go +++ b/internal/controlplane/api/system/system.go @@ -65,7 +65,7 @@ func Authenticate(username, password string) fiber.Handler { } func IsAPIPath(path string) bool { - for _, prefix := range []string{"/api", "/phase-a", "/gateways", "/browser-versions", "/browsers", "/network-exits"} { + for _, prefix := range []string{"/api", "/phase-a", "/gateways", "/browsers", "/network-exits"} { if path == prefix || strings.HasPrefix(path, prefix+"/") { return true } diff --git a/internal/controlplane/app/app.go b/internal/controlplane/app/app.go index 8680453..e03823b 100644 --- a/internal/controlplane/app/app.go +++ b/internal/controlplane/app/app.go @@ -304,7 +304,7 @@ func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAS } func isControlPlaneAPIPath(path string) bool { - for _, prefix := range []string{"/api", "/phase-a", "/gateways", "/browser-versions", "/browsers", "/network-exits"} { + for _, prefix := range []string{"/api", "/phase-a", "/gateways", "/browsers", "/network-exits"} { if path == prefix || strings.HasPrefix(path, prefix+"/") { return true } diff --git a/internal/controlplane/app/app_test.go b/internal/controlplane/app/app_test.go index 2b74363..d1c0ba7 100644 --- a/internal/controlplane/app/app_test.go +++ b/internal/controlplane/app/app_test.go @@ -353,11 +353,6 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase { {http.MethodPost, "/api/network-exits/:id/enable", "/api/network-exits/missing/enable", "", http.StatusNotFound}, {http.MethodDelete, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound}, - {http.MethodGet, "/api/browser-versions", "/api/browser-versions", "", http.StatusOK}, - {http.MethodPost, "/api/browser-versions", "/api/browser-versions", "", http.StatusBadRequest}, - {http.MethodPut, "/api/browser-versions/:version", "/api/browser-versions/999.0.0", `{"browser_path":"/opt/creatorhub/browsers/missing"}`, http.StatusNotFound}, - {http.MethodDelete, "/api/browser-versions/:version", "/api/browser-versions/999.0.0", "", http.StatusNotFound}, - {http.MethodGet, "/api/gateways", "/api/gateways", "", http.StatusOK}, {http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest}, {http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound}, diff --git a/internal/environment/environment.go b/internal/environment/environment.go index 813b9ca..6b3c64a 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -441,7 +441,7 @@ func invalidateAccountsForExit(ctx context.Context, tx *sql.Tx, exitID string) ( func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID string) (EnvironmentContext, bool, error) { env.Alias, env.Name = strings.TrimSpace(env.Alias), strings.TrimSpace(env.Name) if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || !aliasPattern.MatchString(accountID) || - (exitID != "" && !exitIDPattern.MatchString(exitID)) || !gatewayNamePattern.MatchString(env.Gateway) || !browserVersionPattern.MatchString(env.BrowserVersion) || + (exitID != "" && !exitIDPattern.MatchString(exitID)) || !gatewayNamePattern.MatchString(env.Gateway) || env.Fingerprint.ProxyServer != "" { return EnvironmentContext{}, false, ErrInvalid } @@ -465,7 +465,7 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s return EnvironmentContext{}, false, errors.New("commit existing environment lookup") } context, err := s.GetEnvironmentContext(ctx, env.Alias) - if err != nil || context.Name != env.Name || context.Gateway != env.Gateway || context.BrowserVersion != env.BrowserVersion || context.Fingerprint != env.Fingerprint { + if err != nil || context.Name != env.Name || context.Gateway != env.Gateway || context.Fingerprint != env.Fingerprint { return EnvironmentContext{}, false, ErrConflict } return context, false, nil @@ -475,13 +475,13 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s } var created string if err := tx.QueryRowContext(ctx, ` - INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint) - SELECT $1, $2, $3, image.version, $5 - FROM browser_version image, social_account account - WHERE image.version = $4 AND image.enabled AND account.id = $6 AND account.status = 'paused' + INSERT INTO browser_env (alias, name, gateway_name, fingerprint) + SELECT $1, $2, $3, $4 + FROM social_account account + WHERE account.id = $5 AND account.status = 'paused' AND account.authorization_status = 'authorized' - AND ($7 = '' OR EXISTS (SELECT 1 FROM network_exit WHERE id = $7 AND health_status = 'healthy')) - RETURNING alias`, env.Alias, env.Name, env.Gateway, env.BrowserVersion, encoded, accountID, exitID).Scan(&created); err != nil { + AND ($6 = '' OR EXISTS (SELECT 1 FROM network_exit WHERE id = $6 AND health_status = 'healthy')) + RETURNING alias`, env.Alias, env.Name, env.Gateway, encoded, accountID, exitID).Scan(&created); err != nil { return EnvironmentContext{}, false, rowError(err) } if _, err := tx.ExecContext(ctx, ` @@ -515,7 +515,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro var runtimeInstanceID, runtimeID, runtimeNetworkID, cleanupInstanceID, cleanupRuntimeID, cleanupNetworkID sql.NullString var cleanupBindingVersion sql.NullInt64 err = tx.QueryRowContext(ctx, ` - SELECT environment.alias, environment.name, environment.gateway_name, environment.browser_version, + SELECT environment.alias, environment.name, environment.gateway_name, environment.fingerprint, environment.created_at, binding.account_id, account.status, account.authorization_status, binding.id, binding.version, binding.runtime_cleanup_pending, binding.runtime_cleanup_binding_version, @@ -533,7 +533,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro LEFT JOIN network_exit network ON network.id = binding.network_exit_id LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL WHERE environment.alias = $1`, alias). - Scan(&result.Alias, &result.Name, &result.Gateway, &result.BrowserVersion, &encoded, &result.CreatedAt, + Scan(&result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt, &result.AccountID, &result.AccountStatus, &result.AuthorizationStatus, &result.BindingID, &result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion, &cleanupInstanceID, &cleanupRuntimeID, &cleanupNetworkID, @@ -1052,20 +1052,17 @@ func appendRuntimeAudit(ctx context.Context, tx *sql.Tx, eventType, accountID, a func (s *Store) AppendEnvironmentAction(ctx context.Context, eventType string, action EnvironmentAction) error { if (eventType != "environment_action_requested" && eventType != "environment_action_finished") || !exitIDPattern.MatchString(action.OperationID) || action.Action == "" || action.ReasonCode == "" || - (action.OldBrowserVersion != "" && !browserVersionPattern.MatchString(action.OldBrowserVersion)) || - (action.NewBrowserVersion != "" && !browserVersionPattern.MatchString(action.NewBrowserVersion)) || (eventType == "environment_action_finished" && action.Outcome != "succeeded" && action.Outcome != "failed" && action.Outcome != "unknown") { return ErrInvalid } _, err := s.db.ExecContext(ctx, ` INSERT INTO audit_event - (event_type, account_id, browser_env_alias, network_exit_id, runtime_instance_id, - binding_version, actor, reason_code, operation_id, action, outcome, old_browser_version, new_browser_version) - VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), - NULLIF($6, 0), 'local-user', $7, $8, $9, NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''))`, + (event_type, account_id, browser_env_alias, network_exit_id, runtime_instance_id, + binding_version, actor, reason_code, operation_id, action, outcome) + VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), + NULLIF($6, 0), 'local-user', $7, $8, $9, NULLIF($10, ''))`, eventType, action.AccountID, action.BrowserEnvAlias, action.NetworkExitID, action.RuntimeInstanceID, - action.BindingVersion, action.ReasonCode, action.OperationID, action.Action, action.Outcome, - action.OldBrowserVersion, action.NewBrowserVersion) + action.BindingVersion, action.ReasonCode, action.OperationID, action.Action, action.Outcome) if err != nil { return errors.New("append environment action") } diff --git a/internal/environment/legacy_browser_version_test.go b/internal/environment/legacy_browser_version_test.go new file mode 100644 index 0000000..6177d68 --- /dev/null +++ b/internal/environment/legacy_browser_version_test.go @@ -0,0 +1,80 @@ +package environment + +import ( + "context" + "regexp" + "strings" + "unicode/utf8" +) + +// Legacy database tests still exercise migrations created before gateway-owned +// browser selection. These helpers are test-only; production has no such model +// or management methods. +type BrowserVersion struct { + Version string + BrowserPath string + Note string + Enabled bool +} + +var legacyBrowserVersionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+~-]{0,63}$`) + +func (s *Store) CreateBrowserVersion(ctx context.Context, version BrowserVersion) error { + version.Version, version.BrowserPath, version.Note = strings.TrimSpace(version.Version), strings.TrimSpace(version.BrowserPath), strings.TrimSpace(version.Note) + if !legacyBrowserVersionPattern.MatchString(version.Version) || !strings.HasPrefix(version.BrowserPath, "/") || utf8.RuneCountInString(version.Note) > 200 { + return ErrInvalid + } + _, err := s.db.ExecContext(ctx, `INSERT INTO browser_version (version, browser_path, note, enabled) VALUES ($1, $2, $3, $4)`, version.Version, version.BrowserPath, version.Note, version.Enabled) + return publicDatabaseError(err) +} + +func (s *Store) UpdateBrowserVersion(ctx context.Context, version BrowserVersion) error { + version.BrowserPath, version.Note = strings.TrimSpace(version.BrowserPath), strings.TrimSpace(version.Note) + if !legacyBrowserVersionPattern.MatchString(version.Version) || !strings.HasPrefix(version.BrowserPath, "/") || utf8.RuneCountInString(version.Note) > 200 { + return ErrInvalid + } + var updated string + err := s.db.QueryRowContext(ctx, `UPDATE browser_version SET browser_path = $2, note = $3, enabled = $4, updated_at = now() WHERE version = $1 RETURNING version`, version.Version, version.BrowserPath, version.Note, version.Enabled).Scan(&updated) + return rowError(err) +} + +func (s *Store) BrowserPath(ctx context.Context, version string) (string, error) { + if !legacyBrowserVersionPattern.MatchString(version) { + return "", ErrInvalid + } + var path string + if err := s.db.QueryRowContext(ctx, `SELECT browser_path FROM browser_version WHERE version = $1 AND enabled`, version).Scan(&path); err != nil { + return "", rowError(err) + } + return path, nil +} + +func (s *Store) DeleteBrowserVersion(ctx context.Context, version string) error { + if !legacyBrowserVersionPattern.MatchString(version) { + return ErrInvalid + } + var deleted string + if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_version WHERE version = $1 RETURNING version`, version).Scan(&deleted); err != nil { + return rowError(err) + } + return nil +} + +func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error { + if !legacyBrowserVersionPattern.MatchString(version) { + return ErrInvalid + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var updated string + if err := tx.QueryRowContext(ctx, `UPDATE browser_env SET browser_version = $2, version = version + 1 WHERE alias = $1 RETURNING alias`, alias, version).Scan(&updated); err != nil { + return rowError(err) + } + if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET version = version + 1, updated_at = now() WHERE browser_env_alias = $1`, alias); err != nil { + return err + } + return commitHub(tx) +} diff --git a/internal/environment/migrations/034_gateway_browser_default.sql b/internal/environment/migrations/034_gateway_browser_default.sql new file mode 100644 index 0000000..53af7b2 --- /dev/null +++ b/internal/environment/migrations/034_gateway_browser_default.sql @@ -0,0 +1,9 @@ +-- Browser selection is gateway-owned; remove the obsolete control-plane schema. +ALTER TABLE browser_env + DROP CONSTRAINT IF EXISTS browser_env_browser_version_fkey; +ALTER TABLE browser_env + DROP COLUMN IF EXISTS browser_version; +ALTER TABLE audit_event + DROP COLUMN IF EXISTS old_browser_version, + DROP COLUMN IF EXISTS new_browser_version; +DROP TABLE IF EXISTS browser_version; diff --git a/internal/environment/store.go b/internal/environment/store.go index b1b2868..d8cfa9c 100644 --- a/internal/environment/store.go +++ b/internal/environment/store.go @@ -72,6 +72,9 @@ var migration017 string //go:embed migrations/033_unique_fingerprint_seed.sql var migration033 string +//go:embed migrations/034_gateway_browser_default.sql +var migration034 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid environment input") @@ -79,16 +82,12 @@ var ( ErrReconcileRequired = errors.New("runtime cleanup generation is unknown; manual reconciliation required") ) -func ValidBrowserVersion(version string) bool { return browserVersionPattern.MatchString(version) } - func ValidNetworkExitID(id string) bool { return exitIDPattern.MatchString(id) } var ( - aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) - gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) - tokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$`) - browserVersionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+~-]{0,63}$`) - browserPathPattern = regexp.MustCompile(`^/[A-Za-z0-9._+~/-]+$`) + aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + tokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$`) ) type Store struct { @@ -106,22 +105,12 @@ type Gateway struct { UpdatedAt time.Time `json:"updated_at"` } -// BrowserVersion 是页面维护的可用预安装浏览器版本。 -type BrowserVersion struct { - Version string `json:"version"` - BrowserPath string `json:"browser_path"` - Note string `json:"note"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Env 是一个浏览器环境;Fingerprint 为全量启动参数,创建后随请求整体下发 gateway。 +// Env 是一个浏览器环境;浏览器安装和默认运行时由 gateway 宿主机配置。 type Env struct { Alias string `json:"alias"` Name string `json:"name"` Gateway string `json:"gateway"` - BrowserVersion string `json:"browser_version"` + BrowserVersion string `json:"-"` // legacy database test snapshot; runtime selection is gateway-owned Fingerprint Fingerprint `json:"fingerprint"` CreatedAt time.Time `json:"created_at"` } @@ -165,7 +154,7 @@ func (s *Store) notifyTransitions(transitions []taskstate.Transition) { // LockResources serializes lifecycle state across control-plane replicas. The // transaction carries no data changes; rolling it back only releases the locks. -func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, browserVersions []string) (func(), error) { +func (s *Store) LockResources(ctx context.Context, aliases, exitIDs []string, _ ...[]string) (func(), error) { for _, alias := range aliases { if !aliasPattern.MatchString(alias) { return nil, ErrInvalid @@ -176,12 +165,7 @@ func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, browserVers return nil, ErrInvalid } } - for _, version := range browserVersions { - if !browserVersionPattern.MatchString(version) { - return nil, ErrInvalid - } - } - if len(aliases)+len(exitIDs)+len(browserVersions) == 0 { + if len(aliases)+len(exitIDs) == 0 { return func() {}, nil } select { @@ -197,7 +181,7 @@ func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, browserVers resources := []struct { namespace int keys []string - }{{1542738013, aliases}, {1542738015, exitIDs}, {1542738014, browserVersions}} + }{{1542738013, aliases}, {1542738015, exitIDs}} for _, resource := range resources { keys := append([]string(nil), resource.keys...) sort.Strings(keys) @@ -230,7 +214,7 @@ func (s *Store) migrate(ctx context.Context) error { for _, migration := range []struct { version int sql string - }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}, {17, migration017}, {33, migration033}} { + }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}, {17, migration017}, {33, migration033}, {34, migration034}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read environment schema migration state") @@ -340,86 +324,11 @@ func (s *Store) DeleteGateway(ctx context.Context, name string) error { return nil } -func (s *Store) CreateBrowserVersion(ctx context.Context, version BrowserVersion) error { - version.Version = strings.TrimSpace(version.Version) - version.BrowserPath = strings.TrimSpace(version.BrowserPath) - version.Note = strings.TrimSpace(version.Note) - if !browserVersionPattern.MatchString(version.Version) || !validBrowserPath(version.BrowserPath) || - utf8.RuneCountInString(version.Note) > 200 { - return ErrInvalid - } - _, err := s.db.ExecContext(ctx, ` - INSERT INTO browser_version (version, browser_path, note, enabled) VALUES ($1, $2, $3, $4)`, - version.Version, version.BrowserPath, version.Note, version.Enabled) - return publicDatabaseError(err) -} - -func (s *Store) UpdateBrowserVersion(ctx context.Context, version BrowserVersion) error { - version.BrowserPath = strings.TrimSpace(version.BrowserPath) - version.Note = strings.TrimSpace(version.Note) - if !browserVersionPattern.MatchString(version.Version) || !validBrowserPath(version.BrowserPath) || - utf8.RuneCountInString(version.Note) > 200 { - return ErrInvalid - } - var updated string - err := s.db.QueryRowContext(ctx, ` - UPDATE browser_version SET browser_path = $2, note = $3, enabled = $4, updated_at = now() - WHERE version = $1 RETURNING version`, version.Version, version.BrowserPath, version.Note, version.Enabled). - Scan(&updated) - return rowError(err) -} - -func (s *Store) ListBrowserVersions(ctx context.Context, enabledOnly bool) ([]BrowserVersion, error) { - query := `SELECT version, browser_path, note, enabled, created_at, updated_at FROM browser_version` - if enabledOnly { - query += ` WHERE enabled` - } - query += ` ORDER BY created_at DESC, version` - rows, err := s.db.QueryContext(ctx, query) - if err != nil { - return nil, errors.New("read browser versions") - } - defer rows.Close() - versions := []BrowserVersion{} - for rows.Next() { - var version BrowserVersion - if err := rows.Scan(&version.Version, &version.BrowserPath, &version.Note, &version.Enabled, &version.CreatedAt, &version.UpdatedAt); err != nil { - return nil, errors.New("decode browser version") - } - versions = append(versions, version) - } - return versions, rows.Err() -} - -func (s *Store) DeleteBrowserVersion(ctx context.Context, version string) error { - if !browserVersionPattern.MatchString(version) { - return ErrInvalid - } - var deleted string - if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_version WHERE version = $1 RETURNING version`, version).Scan(&deleted); err != nil { - return rowError(err) - } - return nil -} - -// BrowserPath 返回可用(存在且启用)版本的宿主机浏览器路径;缺失或禁用均视为不可用。 -func (s *Store) BrowserPath(ctx context.Context, version string) (string, error) { - if !browserVersionPattern.MatchString(version) { - return "", ErrInvalid - } - var ref string - if err := s.db.QueryRowContext(ctx, `SELECT browser_path FROM browser_version WHERE version = $1 AND enabled`, version).Scan(&ref); err != nil { - return "", rowError(err) - } - return ref, nil -} - func (s *Store) CreateEnv(ctx context.Context, env Env) error { env.Alias = strings.TrimSpace(env.Alias) env.Name = strings.TrimSpace(env.Name) if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || - !gatewayNamePattern.MatchString(env.Gateway) || !browserVersionPattern.MatchString(env.BrowserVersion) || - env.Fingerprint.ProxyServer != "" { + !gatewayNamePattern.MatchString(env.Gateway) || env.Fingerprint.ProxyServer != "" { return ErrInvalid } if err := env.Fingerprint.Validate(); err != nil { @@ -431,15 +340,15 @@ func (s *Store) CreateEnv(ctx context.Context, env Env) error { } var created string err = s.db.QueryRowContext(ctx, ` - INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint) - SELECT $1, $2, $3, version, $5 FROM browser_version WHERE version = $4 AND enabled - RETURNING alias`, env.Alias, env.Name, env.Gateway, env.BrowserVersion, encoded).Scan(&created) + INSERT INTO browser_env (alias, name, gateway_name, fingerprint) + VALUES ($1, $2, $3, $4) + RETURNING alias`, env.Alias, env.Name, env.Gateway, encoded).Scan(&created) return rowError(err) } func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT alias, name, gateway_name, browser_version, fingerprint, created_at + SELECT alias, name, gateway_name, fingerprint, created_at FROM browser_env ORDER BY created_at, alias`) if err != nil { return nil, errors.New("read browser envs") @@ -461,7 +370,7 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) { return Env{}, ErrInvalid } rows, err := s.db.QueryContext(ctx, ` - SELECT alias, name, gateway_name, browser_version, fingerprint, created_at + SELECT alias, name, gateway_name, fingerprint, created_at FROM browser_env WHERE alias = $1`, alias) if err != nil { return Env{}, errors.New("read browser env") @@ -477,69 +386,6 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) { return env, rows.Err() } -// UpgradeEnv 将环境切换到指定可用镜像版本;参数与卷不变,容器重建由控制面编排网关完成。 -func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error { - if !aliasPattern.MatchString(alias) || !browserVersionPattern.MatchString(version) { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin environment upgrade") - } - defer tx.Rollback() - var cleanupPending bool - if err := tx.QueryRowContext(ctx, ` - SELECT runtime_cleanup_pending FROM environment_binding - WHERE browser_env_alias = $1 FOR UPDATE`, alias).Scan(&cleanupPending); err != nil && !errors.Is(err, sql.ErrNoRows) { - return publicDatabaseError(err) - } - if cleanupPending { - return ErrConflict - } - var updated string - if err := tx.QueryRowContext(ctx, ` - UPDATE browser_env SET browser_version = $2, version = version + 1 - WHERE alias = $1 AND EXISTS (SELECT 1 FROM browser_version WHERE version = $2 AND enabled) - RETURNING alias`, alias, version).Scan(&updated); err != nil { - return rowError(err) - } - if _, err := tx.ExecContext(ctx, ` - UPDATE environment_binding SET version = version + 1, updated_at = now() - WHERE browser_env_alias = $1`, alias); err != nil { - return errors.New("version upgraded environment binding") - } - rows, err := tx.QueryContext(ctx, ` - WITH changed AS ( - UPDATE social_account account - SET status = 'paused', paused_at = now(), version = account.version + 1, updated_at = now() - FROM environment_binding binding - WHERE binding.browser_env_alias = $1 AND binding.account_id = account.id - RETURNING account.id - ), held AS ( - UPDATE operation_task task SET - state = CASE task.state WHEN 'executing' THEN 'needs_confirmation' ELSE 'policy_hold' END, - hold_reason = CASE task.state WHEN 'executing' THEN 'task_result_uncertain' ELSE 'binding_version_changed' END, - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - FROM changed WHERE task.account_id = changed.id AND task.state IN ('queued', 'executing') - RETURNING task.current_attempt_id, task.state, task.hold_reason, task.account_id, task.id - ), finished AS ( - UPDATE execution_attempt attempt SET finished_at = now(), outcome = 'uncertain' - FROM held WHERE held.state = 'needs_confirmation' AND attempt.id = held.current_attempt_id - AND attempt.finished_at IS NULL - RETURNING attempt.id - ) - SELECT state, hold_reason, account_id, id FROM held`, alias) - if err != nil { - return errors.New("invalidate upgraded environment account") - } - transitions, err := scanTransitions(rows, "read upgraded environment tasks") - if err != nil { - return err - } - return s.commitTransitions(tx, transitions) -} - func (s *Store) DeleteEnv(ctx context.Context, alias string) error { if !aliasPattern.MatchString(alias) { return ErrInvalid @@ -584,7 +430,7 @@ func (s *Store) DeleteAccountEnvironment(ctx context.Context, accountID string) func scanEnv(rows *sql.Rows) (Env, error) { var env Env var encoded []byte - if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &env.BrowserVersion, &encoded, &env.CreatedAt); err != nil { + if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil { return Env{}, errors.New("decode browser env") } if len(encoded) > 0 { @@ -597,10 +443,6 @@ func scanEnv(rows *sql.Rows) (Env, error) { return env, nil } -func validBrowserPath(path string) bool { - return len(path) >= 2 && len(path) <= 4096 && browserPathPattern.MatchString(path) -} - func validDisplayName(name string) bool { if name == "" || utf8.RuneCountInString(name) > 64 { return false diff --git a/web/src/app/Layout.jsx b/web/src/app/Layout.jsx index bcf6fa7..d872db2 100644 --- a/web/src/app/Layout.jsx +++ b/web/src/app/Layout.jsx @@ -35,7 +35,6 @@ const menu = [ items: [ { to: '/browsers', icon: 'ri-hard-drive-3-line', label: '运行环境' }, { to: '/network-exits', icon: 'ri-earth-line', label: '网络出口' }, - { to: '/browser-versions', icon: 'ri-window-line', label: '浏览器版本' }, { to: '/gateways', icon: 'ri-router-line', label: '网关管理' }, ], }, @@ -51,7 +50,7 @@ function MenuLink({ item, pathname }) { { if (isMobile) setOpenMobile(false) }}> -