diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go
index 8635119..b3bb478 100644
--- a/cmd/control-plane/hub.go
+++ b/cmd/control-plane/hub.go
@@ -14,6 +14,7 @@ import (
"git.ipao.vip/rogee/creator-hub/internal/hub"
"github.com/gofiber/fiber/v3"
+ "github.com/sirupsen/logrus"
)
// hubStore 是控制面编排所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。
@@ -166,13 +167,40 @@ func gatewayNetworkID(body []byte) string {
}
func reconcileGatewayCreate(ctx context.Context, store runtimeCleanupStore, target hub.Gateway,
- environment hub.EnvironmentContext, body []byte) error {
- return reconcileGatewayCreateGeneration(ctx, store, target, environment, environment.BindingVersion, gatewayNetworkID(body))
+ environment hub.EnvironmentContext, status int, callErr error, body []byte) error {
+ // 网关 4xx 拒绝且未产生 network 代:创建在网关侧任何 Docker 变更之前就被确定性拒绝,
+ // 无需清理 fence;其余情况(5xx、断连、无效 201)结果未知,仍走 fail-closed。
+ deterministicRejection := callErr == nil && status >= 400 && status < 500
+ logEntry := logrus.WithFields(logrus.Fields{
+ "service": "control-plane",
+ "event_type": "runtime_create_reconciliation",
+ "alias": environment.Alias,
+ "gateway": target.Name,
+ "binding_version": environment.BindingVersion,
+ "gateway_status": status,
+ "network_id_known": gatewayNetworkID(body) != "",
+ "deterministic": deterministicRejection,
+ })
+ if callErr != nil {
+ logEntry = logEntry.WithError(callErr)
+ }
+ if deterministicRejection {
+ logEntry.Info("gateway rejected runtime create before Docker side effects")
+ } else {
+ logEntry.Warn("gateway runtime create result requires reconciliation")
+ }
+ return reconcileGatewayCreateGeneration(ctx, store, target, environment, environment.BindingVersion,
+ gatewayNetworkID(body), deterministicRejection)
}
func reconcileGatewayCreateGeneration(ctx context.Context, store runtimeCleanupStore, target hub.Gateway,
- environment hub.EnvironmentContext, bindingVersion int64, networkID string) error {
+ environment hub.EnvironmentContext, bindingVersion int64, networkID string, deterministicRejection bool) error {
if networkID == "" {
+ if deterministicRejection {
+ // 网关明确拒绝:创建未发生、无副作用,不写 pending,让调用方透传真实失败原因,
+ // 绑定保持可重试(否则会把可判定的失败写成永远无法自动清理的“未知代”毒状态)。
+ return nil
+ }
pending := runtimeCleanupGeneration(environment, bindingVersion, missingRuntimeID, "")
return errors.Join(hub.ErrConflict, store.SetRuntimeCleanupPending(ctx, pending, true))
}
@@ -1163,15 +1191,15 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers",
gatewayCreatePayloadForAccount(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil {
- return false, errors.Join(gatewayUnreachable(callErr), reconcileGatewayCreate(ctx, store, target, environment, body))
+ return false, errors.Join(gatewayUnreachable(callErr), reconcileGatewayCreate(ctx, store, target, environment, status, callErr, body))
}
if status != http.StatusCreated {
- return false, errors.Join(gatewayRejected(status, body), reconcileGatewayCreate(ctx, store, target, environment, body))
+ return false, errors.Join(gatewayRejected(status, body), reconcileGatewayCreate(ctx, store, target, environment, status, callErr, body))
}
var created containerStatus
if json.Unmarshal(body, &created) != nil || !validCreatedRuntime(created, environment, true) {
return false, errors.Join(errors.New("gateway returned an invalid runtime generation"),
- reconcileGatewayCreate(ctx, store, target, environment, body))
+ reconcileGatewayCreate(ctx, store, target, environment, status, callErr, body))
}
_, err = activateGatewayRuntime(ctx, store, target, environment, created.ID, created.NetworkID)
return err == nil, err
@@ -1321,7 +1349,7 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
if callErr != nil {
createErr = gatewayUnreachable(callErr)
}
- reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
+ reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(createErr, reconcileErr))
@@ -1331,7 +1359,7 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
}
var createdRuntime containerStatus
if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) {
- reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
+ 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 create result unknown; environment retained for reconciliation"}, reconcileErr))
@@ -1562,7 +1590,7 @@ func startBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExit
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers",
gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil || status != http.StatusCreated {
- reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
+ 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 start result unknown; retry to reconcile"}, reconcileErr))
@@ -1575,7 +1603,7 @@ func startBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExit
}
var created containerStatus
if json.Unmarshal(body, &created) != nil || !validCreatedRuntime(created, environment, true) {
- reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
+ 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 start result unknown; retry to reconcile"}, reconcileErr))
}
@@ -1671,7 +1699,7 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkEx
gatewayCreatePayloadForAccount(environment, imageRef, networkExit), gatewayLongTimeout)
var createdRuntime containerStatus
if callErr != nil || status != http.StatusCreated {
- reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
+ 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))
@@ -1683,7 +1711,7 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkEx
return hubError(c, gatewayRejected(status, body))
}
if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) {
- reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
+ 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))
}
@@ -1892,7 +1920,7 @@ func restoreRebindRuntime(ctx context.Context, store hubStore, resolve func(hub.
}
created, err := createGatewayRuntime(ctx, target, environment, spec)
if err != nil {
- cleanupErr := reconcileGatewayCreateGeneration(ctx, store, target, cleanupContext, environment.BindingVersion, created.NetworkID)
+ cleanupErr := reconcileGatewayCreateGeneration(ctx, store, target, cleanupContext, environment.BindingVersion, created.NetworkID, false)
return false, errors.Join(err, cleanupErr)
}
if previous.State == "running" && accountRunnable(environment) {
@@ -2037,7 +2065,7 @@ func rebindBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, errors.Join(err, contextErr))
}
- cleanupErr := reconcileGatewayCreateGeneration(c.Context(), store, target, cleanupContext, candidate.BindingVersion, candidateRuntime.NetworkID)
+ cleanupErr := reconcileGatewayCreateGeneration(c.Context(), store, target, cleanupContext, candidate.BindingVersion, candidateRuntime.NetworkID, false)
if cleanupErr != nil {
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, errors.Join(err, cleanupErr))
diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go
index c6d335d..8d780f6 100644
--- a/cmd/control-plane/hub_test.go
+++ b/cmd/control-plane/hub_test.go
@@ -1203,6 +1203,25 @@ func TestCreateBrowserTracksUnknownNetworkGenerationWithoutAliasCleanup(t *testi
}
}
+func TestCreateBrowserDeterministicRejectionDoesNotWedgeBinding(t *testing.T) {
+ store := newMemoryStore()
+ _ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
+ gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1, failCreateStatus: http.StatusBadRequest}
+ app := newTestApp(t, store, gateway)
+
+ response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
+ if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "alias already in use") {
+ t.Fatalf("deterministic gateway rejection must surface its reason, got %d: %s", response.Code, response.Body.String())
+ }
+ environment, err := store.GetEnvironmentContext(context.Background(), "account-a")
+ if err != nil || environment.RuntimeCleanupPending {
+ t.Fatalf("deterministic rejection wedged the binding for manual reconciliation: environment=%#v err=%v", environment, err)
+ }
+ if requests := gateway.recorded(); len(requests) != 1 || requests[0].method != http.MethodPost {
+ t.Fatalf("deterministic rejection must not reconcile or mutate the gateway: %#v", requests)
+ }
+}
+
func TestReconcileGatewayCreateDoesNotReuseOldNetworkGeneration(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"}
@@ -1217,7 +1236,7 @@ func TestReconcileGatewayCreateDoesNotReuseOldNetworkGeneration(t *testing.T) {
defer server.Close()
target := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
- err := reconcileGatewayCreate(context.Background(), store, target, environment, []byte(`{"error":"result unknown"}`))
+ err := reconcileGatewayCreate(context.Background(), store, target, environment, http.StatusBadGateway, nil, []byte(`{"error":"result unknown"}`))
after := store.bindings[environment.Alias]
if !errors.Is(err, hub.ErrConflict) || len(gateway.recorded()) != 0 || len(gateway.containers) != 1 ||
!after.RuntimeCleanupPending || after.RuntimeCleanupRuntimeID != missingRuntimeID || after.RuntimeCleanupNetworkID != "" {
diff --git a/scripts/dev-backend.mjs b/scripts/dev-backend.mjs
index c6d1c3b..b84b75a 100644
--- a/scripts/dev-backend.mjs
+++ b/scripts/dev-backend.mjs
@@ -66,6 +66,7 @@ const compose = spawnSync(
"compose.dev.yaml",
"up",
"-d",
+ "--build",
"postgres",
"docker-gateway",
],
diff --git a/web/src/BrowsersPage.jsx b/web/src/BrowsersPage.jsx
index 87a1923..4cc9d40 100644
--- a/web/src/BrowsersPage.jsx
+++ b/web/src/BrowsersPage.jsx
@@ -1,87 +1,226 @@
-import { useEffect, useState } from 'react'
-import { Link, useNavigate, useParams } from 'react-router'
-import { useDataProvider, useList, useOne } from '@refinedev/core'
+import { useEffect, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router";
+import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
- Alert, Button, Card, CardContent, Copyable, DetailList, Field, Input, Modal, PageHeader,
- PageState, Select, StatusPill, Table,
-} from './lib/ui.jsx'
-import { useTitle } from './lib/hooks.js'
+ Alert,
+ Button,
+ Card,
+ CardContent,
+ Copyable,
+ DetailList,
+ Field,
+ Input,
+ Modal,
+ PageHeader,
+ PageState,
+ Select,
+ StatusPill,
+ conflictMessage,
+} from "./lib/ui.jsx";
+import { useTitle } from "./lib/hooks.js";
-const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/
-const platformOptions = ['windows', 'linux', 'macos'].map(value => ({ value, label: value }))
-const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi'].map(value => ({ value, label: value }))
-const spoofingFields = ['font', 'audio', 'canvas', 'clientrects', 'gpu']
+const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/;
+const platformOptions = ["windows", "linux", "macos"].map((value) => ({
+ value,
+ label: value,
+}));
+const brandOptions = ["Chrome", "Edge", "Opera", "Vivaldi"].map((value) => ({
+ value,
+ label: value,
+}));
+const spoofingFields = ["font", "audio", "canvas", "clientrects", "gpu"];
-const stateText = { created: '已创建', running: '运行中', exited: '已停止', restarting: '重启中', paused: '已暂停', missing: '未部署' }
+const stateText = {
+ created: "已创建",
+ running: "运行中",
+ exited: "已停止",
+ restarting: "重启中",
+ paused: "已暂停",
+ missing: "未部署",
+};
const blockText = {
- account_revoked: '授权已撤销', account_paused: '账号已暂停', binding_missing: '绑定缺失',
- network_exit_missing: '固定出口缺失', network_exit_unhealthy: '固定出口不健康',
- runtime_stop_pending: '停止结果待确认', runtime_missing: '运行实例缺失',
-}
+ account_revoked: "授权已撤销",
+ account_paused: "账号已暂停",
+ binding_missing: "绑定缺失",
+ network_exit_missing: "固定出口缺失",
+ network_exit_unhealthy: "固定出口不健康",
+ runtime_stop_pending: "停止结果待确认",
+ runtime_missing: "运行实例缺失",
+};
-const scheduleBlock = runtime => runtime.schedule_status === 'ready'
- ? ''
- : blockText[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度'
+const scheduleBlock = (runtime) =>
+ runtime.schedule_status === "ready"
+ ? ""
+ : blockText[runtime.schedule_block_reason] ||
+ runtime.schedule_block_reason ||
+ "不可调度";
const createInitial = {
- name: '', alias: '', gateway: '', image_version: '', account_id: '', network_exit_id: '', seed: '1000',
- platform: '', platform_version: '', brand: '', brand_version: '', hardware_concurrency: '', lang: '', accept_lang: '', timezone: '', disable_spoofing: [],
-}
+ name: "",
+ alias: "",
+ gateway: "",
+ image_version: "",
+ account_id: "",
+ network_exit_id: "",
+ seed: "1000",
+ platform: "",
+ platform_version: "",
+ brand: "",
+ brand_version: "",
+ hardware_concurrency: "",
+ lang: "",
+ accept_lang: "",
+ timezone: "",
+ disable_spoofing: [],
+};
// 高级字段空值不提交,收敛到 seed 驱动。
function fingerprintPayload(form) {
- const payload = { seed: Number(form.seed) }
- const advanced = { platform: form.platform, platform_version: form.platform_version, brand: form.brand, brand_version: form.brand_version, lang: form.lang, accept_lang: form.accept_lang, timezone: form.timezone }
- for (const [key, value] of Object.entries(advanced)) if (value !== '') payload[key] = value
- if (form.hardware_concurrency !== '') payload.hardware_concurrency = Number(form.hardware_concurrency)
- if (form.disable_spoofing.length > 0) payload.disable_spoofing = form.disable_spoofing.join(',')
- return payload
+ const payload = { seed: Number(form.seed) };
+ const advanced = {
+ platform: form.platform,
+ platform_version: form.platform_version,
+ brand: form.brand,
+ brand_version: form.brand_version,
+ lang: form.lang,
+ accept_lang: form.accept_lang,
+ timezone: form.timezone,
+ };
+ for (const [key, value] of Object.entries(advanced))
+ if (value !== "") payload[key] = value;
+ if (form.hardware_concurrency !== "")
+ payload.hardware_concurrency = Number(form.hardware_concurrency);
+ if (form.disable_spoofing.length > 0)
+ payload.disable_spoofing = form.disable_spoofing.join(",");
+ return payload;
}
function StatePill({ state }) {
- const known = Object.hasOwn(stateText, state)
- return
容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。
++ 容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。 +
{runtime.name}
-{runtime.alias} · seed {runtime.fingerprint?.seed}
-{runtime.account_id || '未绑定账号'}
-{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version}
-+ {runtime.alias} · seed {runtime.fingerprint?.seed} +
{runtime.account_id || "未绑定账号"}
++ {runtime.network_exit_id || "当前机器直连"} ·{" "} + {runtime.image_version} +
+{runtime.name}
-{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}
++ {runtime.alias} · {runtime.image_version} · seed{" "} + {runtime.fingerprint?.seed} +
账号:{runtime.account_id || '未绑定'} · 网络出口:{runtime.network_exit_id || '当前机器直连'}
++ 账号:{runtime.account_id || "未绑定"} · 网络出口: + {runtime.network_exit_id || "当前机器直连"} +
Docker socket 仅由受限网关访问,镜像与参数由平台下发
+