feat: support direct network exit for runtimes
This commit is contained in:
+65
-48
@@ -319,9 +319,7 @@ func environmentScheduleReadiness(environment hub.EnvironmentContext) (string, s
|
||||
return "blocked", "account_revoked"
|
||||
case environment.AccountStatus != "active":
|
||||
return "blocked", "account_paused"
|
||||
case environment.Exit.ID == "":
|
||||
return "blocked", "network_exit_missing"
|
||||
case environment.Exit.HealthStatus != "healthy":
|
||||
case environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy":
|
||||
return "blocked", "network_exit_unhealthy"
|
||||
case environment.RuntimeCleanupPending:
|
||||
return "blocked", "runtime_stop_pending"
|
||||
@@ -859,7 +857,7 @@ func listBrowsers(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
|
||||
view.RuntimeInstanceID = environment.RuntimeInstanceID
|
||||
view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment)
|
||||
view.CleanupPending = environment.RuntimeCleanupPending
|
||||
view.RecoveryRequired = environment.Exit.ID == "" || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending
|
||||
view.RecoveryRequired = (environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy") || environment.RuntimeCleanupPending
|
||||
} else if !errors.Is(contextErr, hub.ErrNotFound) {
|
||||
return hubError(c, contextErr)
|
||||
}
|
||||
@@ -971,9 +969,6 @@ func reconcileRuntimeSnapshot(ctx context.Context, store hubStore, probe network
|
||||
continue
|
||||
}
|
||||
if found && container.State == "running" {
|
||||
if environment.Exit.ID == "" {
|
||||
continue
|
||||
}
|
||||
auditRecovery := !containerMatchesBinding(container, environment) || !container.ProxyReady
|
||||
action := actionForEnvironment("reconcile", environment)
|
||||
if auditRecovery {
|
||||
@@ -1037,9 +1032,13 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
|
||||
_, err = removeGatewayRuntime(ctx, store, target, environment)
|
||||
return false, err
|
||||
}
|
||||
access, _, err := verifyNetworkExit(ctx, store, probe, environment.Exit.ID)
|
||||
if err != nil {
|
||||
return false, discardRuntime(ctx, store, environment)
|
||||
var access hub.NetworkExitAccess
|
||||
var err error
|
||||
if environment.Exit.ID != "" {
|
||||
access, _, err = verifyNetworkExit(ctx, store, probe, environment.Exit.ID)
|
||||
if err != nil {
|
||||
return false, discardRuntime(ctx, store, environment)
|
||||
}
|
||||
}
|
||||
target, err := store.GetGateway(ctx, environment.Gateway)
|
||||
if err != nil {
|
||||
@@ -1049,15 +1048,22 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
|
||||
_, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID)
|
||||
return err == nil, err
|
||||
}
|
||||
networkExit, err := gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
return false, discardRuntime(ctx, store, environment)
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if environment.Exit.ID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
return false, discardRuntime(ctx, store, environment)
|
||||
}
|
||||
}
|
||||
if containerMatchesBinding(container, environment) {
|
||||
status, _, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/proxy",
|
||||
gatewayProxyPayload(environment, container.ID, container.NetworkID, networkExit), 30*time.Second)
|
||||
if callErr == nil && status == http.StatusNoContent {
|
||||
if environment.Exit.ID == "" {
|
||||
container.ProxyReady = true
|
||||
} else {
|
||||
status, _, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/proxy",
|
||||
gatewayProxyPayload(environment, container.ID, container.NetworkID, networkExit), 30*time.Second)
|
||||
if callErr == nil && status == http.StatusNoContent {
|
||||
container.ProxyReady = true
|
||||
}
|
||||
}
|
||||
if container.ProxyReady {
|
||||
_, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID)
|
||||
@@ -1138,13 +1144,6 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
|
||||
if err := env.Fingerprint.Validate(); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": err.Error()})
|
||||
}
|
||||
exit, err := store.GetNetworkExit(c.Context(), input.NetworkExitID)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
if exit.HealthStatus != "healthy" {
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
environment, created, err := store.CreateBoundEnv(c.Context(), env, input.AccountID, input.NetworkExitID)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
@@ -1158,10 +1157,14 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
|
||||
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, input.NetworkExitID)
|
||||
if err != nil {
|
||||
_ = finish("failed", reason, environment)
|
||||
return hubError(c, err)
|
||||
var access hub.NetworkExitAccess
|
||||
if input.NetworkExitID != "" {
|
||||
var reason string
|
||||
access, reason, err = verifyNetworkExit(c.Context(), store, probe, input.NetworkExitID)
|
||||
if err != nil {
|
||||
_ = finish("failed", reason, environment)
|
||||
return hubError(c, err)
|
||||
}
|
||||
}
|
||||
gateway, err := store.GetGateway(c.Context(), env.Gateway)
|
||||
if err != nil {
|
||||
@@ -1184,10 +1187,13 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
|
||||
_ = finish("failed", "image_unavailable", environment)
|
||||
return hubError(c, err)
|
||||
}
|
||||
networkExit, err := gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if input.NetworkExitID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
}
|
||||
if !created {
|
||||
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias)
|
||||
@@ -1409,14 +1415,19 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
|
||||
if !accountRunnable(environment) {
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
access, reason, err := verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID)
|
||||
if err != nil {
|
||||
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
|
||||
_ = finish("unknown", "cleanup_result_unknown", environment)
|
||||
return hubError(c, cleanupErr)
|
||||
var access hub.NetworkExitAccess
|
||||
var err error
|
||||
if environment.Exit.ID != "" {
|
||||
var reason string
|
||||
access, reason, err = verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID)
|
||||
if err != nil {
|
||||
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
|
||||
_ = finish("unknown", "cleanup_result_unknown", environment)
|
||||
return hubError(c, cleanupErr)
|
||||
}
|
||||
_ = finish("failed", reason, environment)
|
||||
return hubError(c, err)
|
||||
}
|
||||
_ = finish("failed", reason, environment)
|
||||
return hubError(c, err)
|
||||
}
|
||||
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
|
||||
if err != nil {
|
||||
@@ -1445,14 +1456,17 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
|
||||
_ = finish("failed", "image_unavailable", environment)
|
||||
return hubError(c, err)
|
||||
}
|
||||
networkExit, err := gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
|
||||
_ = finish("unknown", "cleanup_result_unknown", environment)
|
||||
return hubError(c, cleanupErr)
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if environment.Exit.ID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
|
||||
_ = finish("unknown", "cleanup_result_unknown", environment)
|
||||
return hubError(c, cleanupErr)
|
||||
}
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
container, found, err := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
|
||||
if err != nil {
|
||||
@@ -1639,9 +1653,12 @@ func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub.
|
||||
if err != nil {
|
||||
return runtimeCreateSpec{}, err
|
||||
}
|
||||
networkExit, err := gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
return runtimeCreateSpec{}, err
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if environment.Exit.ID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
return runtimeCreateSpec{}, err
|
||||
}
|
||||
}
|
||||
return runtimeCreateSpec{imageRef: imageRef, networkExit: networkExit}, nil
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ func TestResumeBlockReasonIsStable(t *testing.T) {
|
||||
}{
|
||||
"revoked": {phasea.Account{AuthorizationStatus: "revoked"}, healthy, true, "account_revoked"},
|
||||
"missing binding": {account, hub.EnvironmentContext{}, false, "binding_missing"},
|
||||
"direct exit": {account, hub.EnvironmentContext{}, true, "account_conflict"},
|
||||
"unhealthy exit": {account, hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "unhealthy"}}, true, "network_exit_unhealthy"},
|
||||
"cleanup pending": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeCleanupPending: true}, true, "runtime_stop_pending"},
|
||||
"runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeInstanceID: "runtime-a"}, true, "runtime_active"},
|
||||
@@ -375,7 +376,7 @@ func (s *memoryStore) ValidateEnvironmentRebind(_ context.Context, alias, exitID
|
||||
defer s.mu.Unlock()
|
||||
bound, ok := s.bindings[alias]
|
||||
if !ok {
|
||||
bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]}
|
||||
bound.BindingVersion = 1
|
||||
}
|
||||
if bound.BindingVersion != expectedBindingVersion || bound.RuntimeCleanupPending || s.exits[exitID].HealthStatus != "healthy" {
|
||||
return hub.ErrConflict
|
||||
@@ -941,6 +942,38 @@ func TestCreateBrowserOrchestratesGateway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBrowserSupportsDirectMachineExit(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"}
|
||||
server := httptest.NewServer(gateway.handler(t))
|
||||
defer server.Close()
|
||||
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
|
||||
app := fiber.New()
|
||||
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","image_version":"148.0.7778.215",` +
|
||||
`"fingerprint":{"seed":2024},"account_id":"account-a","network_exit_id":""}`
|
||||
|
||||
response := do(app, http.MethodPost, "/api/browsers", body)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("expected direct create 201, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
bound := store.bindings["direct-env"]
|
||||
if bound.Exit.ID != "" || bound.RuntimeID == "" {
|
||||
t.Fatalf("direct binding was not activated: %#v", bound)
|
||||
}
|
||||
requests := gateway.recorded()
|
||||
if len(requests) != 1 || requests[0].body["network_exit_id"] != "" {
|
||||
t.Fatalf("direct create sent an unexpected gateway payload: %#v", requests)
|
||||
}
|
||||
exit, ok := requests[0].body["network_exit"].(map[string]any)
|
||||
if !ok || exit["protocol"] != "" || exit["host"] != "" || exit["port"] != float64(0) {
|
||||
t.Fatalf("direct create must not send proxy settings: %#v", requests[0].body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitFailuresStopCreateBeforeGateway(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -4275,41 +4308,28 @@ func TestDisableExitPropagatesUnknownGatewayReadAndPreservesLease(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyNullBindingIsListableAndExplicitlyRecoverable(t *testing.T) {
|
||||
func TestDirectBindingIsListable(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", 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: "legacy-runtime", RuntimeID: "legacy-container",
|
||||
RuntimeInstanceID: "direct-runtime", RuntimeID: "direct-container", RuntimeNetworkID: "direct-network",
|
||||
}
|
||||
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
|
||||
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
|
||||
ID: "legacy-container", Alias: "account-a", State: "running", BindingVersion: 1,
|
||||
ID: "direct-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkID: "direct-network", ProxyReady: true,
|
||||
}}}
|
||||
app := newTestApp(t, store, gateway)
|
||||
|
||||
response := do(app, http.MethodGet, "/api/browsers", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("legacy NULL binding broke browser listing: %d: %s", response.Code, response.Body.String())
|
||||
t.Fatalf("direct binding broke browser listing: %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var views []envView
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &views); err != nil || len(views) != 1 || !views[0].RecoveryRequired || views[0].NetworkExitID != "" {
|
||||
t.Fatalf("legacy recovery state was not visible: %#v err=%v", views, err)
|
||||
}
|
||||
|
||||
response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-1"}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("legacy explicit recovery failed: %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
requests := gateway.recorded()
|
||||
if len(requests) != 5 || requests[3].method != http.MethodDelete || requests[4].method != http.MethodPost {
|
||||
t.Fatalf("legacy recovery must inspect, remove, then recreate: %#v", requests)
|
||||
}
|
||||
encoded, _ := json.Marshal(requests[4].body)
|
||||
if strings.Contains(string(encoded), "legacy") || strings.Contains(string(encoded), "secret") {
|
||||
t.Fatalf("legacy Config.Cmd credentials reached the recovered runtime: %s", encoded)
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &views); err != nil || len(views) != 1 || views[0].RecoveryRequired || views[0].NetworkExitID != "" || views[0].ScheduleStatus != "ready" {
|
||||
t.Fatalf("direct runtime state was not visible: %#v err=%v", views, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,9 +334,7 @@ func resumeBlockReason(account phasea.Account, environment hub.EnvironmentContex
|
||||
return "account_revoked"
|
||||
case !bindingFound:
|
||||
return "binding_missing"
|
||||
case environment.Exit.ID == "":
|
||||
return "network_exit_missing"
|
||||
case environment.Exit.HealthStatus != "healthy":
|
||||
case environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy":
|
||||
return "network_exit_unhealthy"
|
||||
case environment.RuntimeCleanupPending:
|
||||
return "runtime_stop_pending"
|
||||
|
||||
@@ -124,7 +124,7 @@ func decodeRestrictedBrowserRequest(body []byte, target any) error {
|
||||
|
||||
func validDouyinGeneration(input douyinGenerationRequest) bool {
|
||||
return input.BindingVersion > 0 && exitIDPattern.MatchString(input.RuntimeID) &&
|
||||
exitIDPattern.MatchString(input.NetworkID) && exitIDPattern.MatchString(input.NetworkExitID)
|
||||
exitIDPattern.MatchString(input.NetworkID) && (input.NetworkExitID == "" || exitIDPattern.MatchString(input.NetworkExitID))
|
||||
}
|
||||
|
||||
func (api gateway) requireDouyinGeneration(alias string, input douyinGenerationRequest) error {
|
||||
|
||||
+23
-15
@@ -353,6 +353,7 @@ func (api gateway) list(c fiber.Ctx) error {
|
||||
}
|
||||
bindingVersion, _ := strconv.ParseInt(container.Labels[bindingVersionLabel], 10, 64)
|
||||
proxyPort, _ := strconv.Atoi(container.Labels[proxyPortLabel])
|
||||
direct := container.Labels[networkExitLabel] == ""
|
||||
browsers = append(browsers, browser{
|
||||
ID: container.ID,
|
||||
Alias: alias,
|
||||
@@ -363,7 +364,7 @@ func (api gateway) list(c fiber.Ctx) error {
|
||||
BindingVersion: bindingVersion,
|
||||
NetworkExitID: container.Labels[networkExitLabel],
|
||||
NetworkID: container.Labels[networkIDLabel],
|
||||
ProxyReady: api.proxies.ready(alias, proxyPort, container.ID, container.Labels[networkIDLabel]),
|
||||
ProxyReady: direct || api.proxies.ready(alias, proxyPort, container.ID, container.Labels[networkIDLabel]),
|
||||
})
|
||||
}
|
||||
return writeJSON(c, http.StatusOK, browsers)
|
||||
@@ -397,6 +398,7 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return writeError(c, statusFor(err), err)
|
||||
}
|
||||
direct := input.NetworkExitID == ""
|
||||
network, proxyServer, undoProxy := "none", "", func() {}
|
||||
var networkGeneration tenantNetworkGeneration
|
||||
keepNetwork := input.Stopped
|
||||
@@ -425,9 +427,11 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
return writeNetworkError(c, http.StatusBadGateway, errors.New("configure isolated browser network"), networkGeneration.ID)
|
||||
}
|
||||
network = networkGeneration.ID
|
||||
proxyServer, undoProxy, err = api.proxies.configure(input.Alias, input.BindingVersion, bindHost, 0, input.NetworkExit, networkGeneration.ID)
|
||||
if err != nil {
|
||||
return writeNetworkError(c, statusFor(err), errors.Join(errors.New("configure in-memory proxy"), err), networkGeneration.ID)
|
||||
if !direct {
|
||||
proxyServer, undoProxy, err = api.proxies.configure(input.Alias, input.BindingVersion, bindHost, 0, input.NetworkExit, networkGeneration.ID)
|
||||
if err != nil {
|
||||
return writeNetworkError(c, statusFor(err), errors.Join(errors.New("configure in-memory proxy"), err), networkGeneration.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
keepProxy := false
|
||||
@@ -439,7 +443,7 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
|
||||
pidsLimit := int64(512)
|
||||
cmd := append([]string{}, input.Cmd...)
|
||||
if !input.Stopped {
|
||||
if !input.Stopped && !direct {
|
||||
cmd = append(cmd[:len(cmd)-1], "--proxy-server="+proxyServer, "--disable-non-proxied-udp", cmd[len(cmd)-1])
|
||||
}
|
||||
payload := map[string]any{
|
||||
@@ -508,14 +512,16 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
if !input.Stopped {
|
||||
if !api.proxies.bind(input.Alias, input.BindingVersion, proxyServer, created.ID, networkGeneration.ID) {
|
||||
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
||||
if cleanupErr != nil {
|
||||
return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("proxy generation changed and container cleanup failed: %w", cleanupErr), networkGeneration.ID)
|
||||
if !direct {
|
||||
if !api.proxies.bind(input.Alias, input.BindingVersion, proxyServer, created.ID, networkGeneration.ID) {
|
||||
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
||||
if cleanupErr != nil {
|
||||
return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("proxy generation changed and container cleanup failed: %w", cleanupErr), networkGeneration.ID)
|
||||
}
|
||||
return writeNetworkError(c, http.StatusConflict, errGenerationConflict, networkGeneration.ID)
|
||||
}
|
||||
return writeNetworkError(c, http.StatusConflict, errGenerationConflict, networkGeneration.ID)
|
||||
undoProxy = func() { api.proxies.remove(input.Alias, input.BindingVersion, created.ID) }
|
||||
}
|
||||
undoProxy = func() { api.proxies.remove(input.Alias, input.BindingVersion, created.ID) }
|
||||
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
|
||||
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
|
||||
if cleanupErr != nil {
|
||||
@@ -524,7 +530,7 @@ func (api gateway) create(c fiber.Ctx) error {
|
||||
return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err), networkGeneration.ID)
|
||||
}
|
||||
}
|
||||
keepProxy, keepNetwork = !input.Stopped, true
|
||||
keepProxy, keepNetwork = !input.Stopped && !direct, true
|
||||
return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias, "network_id": networkGeneration.ID})
|
||||
}
|
||||
|
||||
@@ -541,8 +547,10 @@ func validateCreate(input createRequest) error {
|
||||
if !volumePattern.MatchString(input.Volume) {
|
||||
return errors.New("volume must be a valid volume name")
|
||||
}
|
||||
if input.BindingVersion < 1 || (!input.Stopped && !exitIDPattern.MatchString(input.NetworkExitID)) ||
|
||||
(input.Stopped && (input.NetworkExitID != "" || input.NetworkExit != (gatewayProxyExit{}))) {
|
||||
direct := input.NetworkExitID == "" && input.NetworkExit == (gatewayProxyExit{})
|
||||
if input.BindingVersion < 1 || (input.Stopped && !direct) ||
|
||||
(!input.Stopped && !direct && !exitIDPattern.MatchString(input.NetworkExitID)) ||
|
||||
(input.NetworkExitID == "") != (input.NetworkExit == (gatewayProxyExit{})) {
|
||||
return errors.New("binding_version and network_exit_id must identify the current binding")
|
||||
}
|
||||
if len(input.Cmd) == 0 || len(input.Cmd) > 64 || input.Cmd[len(input.Cmd)-1] != "about:blank" {
|
||||
@@ -561,7 +569,7 @@ func validateCreate(input createRequest) error {
|
||||
if total > 4096 {
|
||||
return errors.New("cmd arguments exceed 4096 characters")
|
||||
}
|
||||
if input.Stopped {
|
||||
if input.Stopped || direct {
|
||||
return nil
|
||||
}
|
||||
proxy := input.NetworkExit
|
||||
|
||||
@@ -572,6 +572,50 @@ func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCreatesDirectBrowserWithoutProxyArguments(t *testing.T) {
|
||||
var created map[string]any
|
||||
docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
|
||||
_, _ = response.Write([]byte(`{}`))
|
||||
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
|
||||
if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
_, _ = response.Write([]byte(`{"Id":"container-id"}`))
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"):
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
handler := newGateway(docker, "creatorhub_browser", testToken)
|
||||
body := `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` +
|
||||
`"cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` +
|
||||
`"binding_version":1,"network_exit_id":"","network_exit":{}}`
|
||||
response := httptest.NewRecorder()
|
||||
adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
|
||||
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
cmd := created["Cmd"].([]any)
|
||||
encoded, _ := json.Marshal(cmd)
|
||||
if len(cmd) != 2 || strings.Contains(string(encoded), "proxy") {
|
||||
t.Fatalf("direct runtime received proxy arguments: %#v", cmd)
|
||||
}
|
||||
host := created["HostConfig"].(map[string]any)
|
||||
labels := created["Labels"].(map[string]any)
|
||||
if host["NetworkMode"] != "network-account-a" || labels[networkExitLabel] != "" || labels[proxyPortLabel] != "0" {
|
||||
t.Fatalf("direct runtime metadata is invalid: host=%#v labels=%#v", host, labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCreateUsesCapturedNetworkIDAcrossNameReplacement(t *testing.T) {
|
||||
networkID := ""
|
||||
members := map[string]string{}
|
||||
|
||||
+4
-4
@@ -94,11 +94,11 @@ docker compose logs --tail=200 creator-hub docker-gateway postgres
|
||||
|
||||
页面路径如下:
|
||||
|
||||
1. 登录后在「网关管理」注册 http://docker-gateway:8081,令牌必须等于 GATEWAY_TOKEN。
|
||||
1. 登录后在「网关管理」注册 <http://docker-gateway:8081,令牌必须等于> GATEWAY_TOKEN。
|
||||
2. 在「镜像版本」添加并启用一个可拉取的 fingerprint-chromium 镜像。
|
||||
3. 在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。
|
||||
3. 可选:在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。留空则使用网关所在机器的网络出口直连。
|
||||
4. 在「社媒账号」创建平台为 mock 的账号;创建后默认暂停。
|
||||
5. 在「运行环境」选择该账号、健康出口和镜像,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。
|
||||
5. 在「运行环境」选择该账号、镜像和可选的健康出口,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。
|
||||
6. 在账号详情创建文本草稿,点击「核对草稿」,勾选“我已核对当前账号、草稿内容、运行环境和固定出口”,依次执行「确认当前快照」→「保存确认」→「加入队列」。
|
||||
7. Mock 执行器没有独立页面,使用下面的 POST /api/phase-a/mock/execute,再到「任务中心」和「审计」核对结果。
|
||||
|
||||
@@ -231,7 +231,7 @@ docker compose stop
|
||||
| 手工验证脚本在 `:?` 处退出 | CREATORHUB_PORT、GATEWAY_TOKEN、CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否都已 export | 在启动 Compose 的同一个 shell 中 export 完整变量集;不要只依赖 `.env` 或 Compose 默认值 |
|
||||
| creator-hub 未启动 | docker compose ps、docker compose logs --tail=200 postgres docker-gateway creator-hub | 先确认 PostgreSQL 与网关 health 为 healthy;网关需能访问 /var/run/docker.sock,DOCKER_GID 使用 stat -c '%g' /var/run/docker.sock 的实际值 |
|
||||
| API 返回 401 | curl 是否带 --user CONTROL_PLANE_USERNAME:CONTROL_PLANE_PASSWORD | /healthz 不需要认证,其余 /api/* 需要控制面 Basic Auth |
|
||||
| /api/browsers 返回 503 或网关不可用 | 网关注册的 Endpoint、令牌与 Compose 的 GATEWAY_TOKEN | Endpoint 在 Compose 网络内应为 http://docker-gateway:8081;重新注册时令牌必须完全一致 |
|
||||
| /api/browsers 返回 503 或网关不可用 | 网关注册的 Endpoint、令牌与 Compose 的 GATEWAY_TOKEN | Endpoint 在 Compose 网络内应为 <http://docker-gateway:8081;重新注册时令牌必须完全一致> |
|
||||
| 出口一直是 unchecked/unhealthy | 出口协议、主机、端口;控制面容器到代理的连通性;last_check_reason | 先用无认证代理完成最小验证;有认证时只提供已配置的凭据引用,不把认证值放到请求、日志或文档 |
|
||||
| 创建环境时报 image_unavailable 或拉取超时 | image_ref 格式、镜像架构、Docker daemon 的 registry 登录和网络 | 版本表中的镜像必须可被 Docker daemon 拉取;缺失镜像会由网关按引用拉取,最长约 10 分钟 |
|
||||
| 恢复/入队返回 503 | readiness、GET /api/browsers/<alias>、GET /api/network-exits/<id> | binding_missing、network_exit_unhealthy、runtime_missing 表示固定资源未就绪;先修复出口并启动原环境,不要换出口重试 |
|
||||
|
||||
@@ -355,7 +355,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) ||
|
||||
!exitIDPattern.MatchString(exitID) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) ||
|
||||
(exitID != "" && !exitIDPattern.MatchString(exitID)) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) ||
|
||||
env.Fingerprint.ProxyServer != "" {
|
||||
return EnvironmentContext{}, false, ErrInvalid
|
||||
}
|
||||
@@ -391,15 +391,16 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
SELECT $1, $2, $3, image.version, $5
|
||||
FROM browser_image image, social_account account, network_exit network
|
||||
FROM browser_image image, social_account account
|
||||
WHERE image.version = $4 AND image.enabled AND account.id = $6 AND account.status = 'paused'
|
||||
AND account.authorization_status = 'authorized' AND network.id = $7 AND network.health_status = 'healthy'
|
||||
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.ImageVersion, encoded, accountID, exitID).Scan(&created); err != nil {
|
||||
return EnvironmentContext{}, false, rowError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id)
|
||||
VALUES ($1, $1, $2, $3)`, accountID, env.Alias, exitID); err != nil {
|
||||
VALUES ($1, $1, $2, NULLIF($3, ''))`, accountID, env.Alias, exitID); err != nil {
|
||||
return EnvironmentContext{}, false, publicDatabaseError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE social_account SET version = version + 1, updated_at = now() WHERE id = $1`, accountID); err != nil {
|
||||
@@ -614,8 +615,8 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi
|
||||
if len(networkIDs) == 1 {
|
||||
networkID = networkIDs[0]
|
||||
}
|
||||
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 || !exitIDPattern.MatchString(exitID) ||
|
||||
!exitIDPattern.MatchString(networkID) || len(networkIDs) != 1 {
|
||||
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 ||
|
||||
(exitID != "" && !exitIDPattern.MatchString(exitID)) || !exitIDPattern.MatchString(networkID) || len(networkIDs) != 1 {
|
||||
return EnvironmentContext{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
|
||||
+24
-24
@@ -858,15 +858,14 @@ func (s *Store) EnqueueConfirmation(ctx context.Context, confirmationID string)
|
||||
}
|
||||
return Task{}, false, publicDatabaseError(err)
|
||||
}
|
||||
if !networkExitID.Valid {
|
||||
return Task{}, false, &ReadinessError{Reason: "network_exit_missing", Unavailable: true}
|
||||
}
|
||||
var exitStatus string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT health_status FROM network_exit WHERE id = $1 FOR SHARE`, networkExitID.String).Scan(&exitStatus); err != nil {
|
||||
return Task{}, false, publicDatabaseError(err)
|
||||
}
|
||||
if exitStatus != "healthy" {
|
||||
return Task{}, false, &ReadinessError{Reason: "network_exit_unhealthy", Unavailable: true}
|
||||
if networkExitID.Valid {
|
||||
var exitStatus string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT health_status FROM network_exit WHERE id = $1 FOR SHARE`, networkExitID.String).Scan(&exitStatus); err != nil {
|
||||
return Task{}, false, publicDatabaseError(err)
|
||||
}
|
||||
if exitStatus != "healthy" {
|
||||
return Task{}, false, &ReadinessError{Reason: "network_exit_unhealthy", Unavailable: true}
|
||||
}
|
||||
}
|
||||
if cleanupPending {
|
||||
return Task{}, false, &ReadinessError{Reason: "runtime_stop_pending", Unavailable: true}
|
||||
@@ -1035,8 +1034,8 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error {
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM environment_binding binding
|
||||
JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
WHERE binding.account_id = $1 AND network.health_status = 'healthy'
|
||||
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
WHERE binding.account_id = $1 AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy')
|
||||
AND NOT binding.runtime_cleanup_pending
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM runtime_instance runtime
|
||||
@@ -1184,7 +1183,7 @@ func (s *Store) ResumeTask(ctx context.Context, taskID string) error {
|
||||
JOIN confirmation confirmation ON confirmation.id = task.confirmation_id
|
||||
JOIN environment_binding binding ON binding.account_id = task.account_id
|
||||
JOIN browser_env environment ON environment.alias = binding.browser_env_alias
|
||||
JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL
|
||||
WHERE task.id = $1 AND task.state IN ('policy_hold', 'needs_confirmation')
|
||||
AND task.verification_result = 'not_executed'
|
||||
@@ -1194,9 +1193,9 @@ func (s *Store) ResumeTask(ctx context.Context, taskID string) error {
|
||||
AND confirmation.account_id = task.account_id AND confirmation.account_version = task.account_version
|
||||
AND confirmation.draft_id = task.draft_id AND confirmation.draft_version = task.draft_version
|
||||
AND confirmation.version = task.confirmation_version
|
||||
AND network.health_status = 'healthy' AND NOT binding.runtime_cleanup_pending
|
||||
AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending
|
||||
AND runtime.binding_version = binding.version AND runtime.lease_until > now()
|
||||
FOR UPDATE OF task, account, draft, confirmation, binding, environment, network, runtime`, taskID).
|
||||
FOR UPDATE OF task, account, draft, confirmation, binding, environment, runtime`, taskID).
|
||||
Scan(&accountID, &confirmationID, &confirmationVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
task, taskErr := scanTask(tx.QueryRowContext(ctx, `
|
||||
@@ -1255,8 +1254,8 @@ func taskReadinessReason(ctx context.Context, queryer rowQuerier, taskID string)
|
||||
OR confirmation.version <> task.confirmation_version THEN 'confirmation_version_changed'
|
||||
WHEN binding.id IS NULL THEN 'binding_missing'
|
||||
WHEN environment.alias IS NULL THEN 'environment_missing'
|
||||
WHEN network.id IS NULL THEN 'exit_missing'
|
||||
WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy'
|
||||
WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing'
|
||||
WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy'
|
||||
WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending'
|
||||
WHEN runtime.id IS NULL THEN 'runtime_missing'
|
||||
WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed'
|
||||
@@ -1382,7 +1381,7 @@ func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) {
|
||||
JOIN confirmation c ON c.id = t.confirmation_id
|
||||
JOIN environment_binding binding ON binding.account_id = a.id
|
||||
JOIN browser_env environment ON environment.alias = binding.browser_env_alias
|
||||
JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
JOIN runtime_instance runtime ON runtime.binding_id = binding.id
|
||||
AND runtime.released_at IS NULL AND runtime.lease_until > now() AND runtime.binding_version = binding.version
|
||||
WHERE t.state = 'queued' AND a.status = 'active' AND a.authorization_status = 'authorized'
|
||||
@@ -1391,9 +1390,9 @@ func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) {
|
||||
AND c.account_id = t.account_id AND c.account_version = t.account_version
|
||||
AND c.draft_id = t.draft_id AND c.draft_version = t.draft_version
|
||||
AND c.version = t.confirmation_version
|
||||
AND network.health_status = 'healthy' AND NOT binding.runtime_cleanup_pending
|
||||
AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending
|
||||
ORDER BY t.created_at, t.id
|
||||
FOR UPDATE OF t, a, binding, network, runtime SKIP LOCKED LIMIT 1
|
||||
FOR UPDATE OF t, a, binding, runtime SKIP LOCKED LIMIT 1
|
||||
)
|
||||
UPDATE operation_task t SET state = 'executing', hold_reason = NULL,
|
||||
verification_result = NULL, verified_at = NULL, verified_by = NULL,
|
||||
@@ -1448,8 +1447,8 @@ func (s *Store) complete(ctx context.Context, workerID string, execution Executi
|
||||
OR confirmation.draft_version <> task.draft_version OR confirmation.version <> task.confirmation_version THEN 'confirmation_version_changed'
|
||||
WHEN binding.id IS NULL THEN 'binding_missing'
|
||||
WHEN environment.alias IS NULL THEN 'environment_missing'
|
||||
WHEN network.id IS NULL THEN 'exit_missing'
|
||||
WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy'
|
||||
WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing'
|
||||
WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy'
|
||||
WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending'
|
||||
WHEN runtime.id IS NULL OR runtime.lease_until <= now() THEN 'runtime_missing'
|
||||
WHEN claim.binding_version IS DISTINCT FROM binding.version
|
||||
@@ -1600,8 +1599,8 @@ func quarantineInvalid(ctx context.Context, tx *sql.Tx) ([]taskstate.Transition,
|
||||
OR confirmation.version <> t.confirmation_version THEN 'confirmation_version_changed'
|
||||
WHEN binding.id IS NULL THEN 'binding_missing'
|
||||
WHEN environment.alias IS NULL THEN 'environment_missing'
|
||||
WHEN network.id IS NULL THEN 'exit_missing'
|
||||
WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy'
|
||||
WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing'
|
||||
WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy'
|
||||
WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending'
|
||||
WHEN runtime.id IS NULL THEN 'runtime_missing'
|
||||
WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed'
|
||||
@@ -1622,7 +1621,8 @@ func quarantineInvalid(ctx context.Context, tx *sql.Tx) ([]taskstate.Transition,
|
||||
OR confirmation.id IS NULL OR confirmation.account_id <> t.account_id
|
||||
OR confirmation.account_version <> t.account_version OR confirmation.draft_id <> t.draft_id
|
||||
OR confirmation.draft_version <> t.draft_version OR confirmation.version <> t.confirmation_version
|
||||
OR binding.id IS NULL OR environment.alias IS NULL OR network.id IS NULL OR network.health_status <> 'healthy'
|
||||
OR binding.id IS NULL OR environment.alias IS NULL
|
||||
OR (binding.network_exit_id IS NOT NULL AND (network.id IS NULL OR network.health_status <> 'healthy'))
|
||||
OR binding.runtime_cleanup_pending OR runtime.id IS NULL
|
||||
OR runtime.binding_version IS DISTINCT FROM binding.version OR runtime.lease_until <= now()
|
||||
)
|
||||
|
||||
@@ -63,8 +63,8 @@ export function accountReadiness(account, binding, bindingError = false) {
|
||||
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false }
|
||||
const resourceReason = binding.cleanup_pending
|
||||
? 'runtime_stop_pending'
|
||||
: binding.network_exit_health !== 'healthy'
|
||||
? (binding.network_exit_id ? 'network_exit_unhealthy' : 'network_exit_missing')
|
||||
: binding.network_exit_id && binding.network_exit_health !== 'healthy'
|
||||
? 'network_exit_unhealthy'
|
||||
: ''
|
||||
if (resourceReason) return { label: reasonLabels[resourceReason], reason: resourceReason, canResume: false, ready: false }
|
||||
if (account.runtime_status === 'paused') {
|
||||
@@ -234,7 +234,7 @@ export function AccountDetail() {
|
||||
<Alert severity="warning" icon={<PauseCircleOutlined />} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。</Alert>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'repeat(2, minmax(0, 1fr))' }, gap: 2.5 }}>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})</Typography><Typography>运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境:{binding.name}({binding.alias})</Typography><Typography sx={wrapAnywhere}>固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本:{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口,因此不能恢复或排队。</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境:{binding.name}({binding.alias})</Typography><Typography sx={wrapAnywhere}>网络出口:{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}</Typography><Typography>绑定版本:{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境,因此不能恢复或排队。</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境</Button></>}</Stack></Paper>
|
||||
</Box>
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
|
||||
<Typography variant="h6">文本草稿</Typography>
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('AccountList', () => {
|
||||
|
||||
it('distinguishes resumable bindings from missing resources', () => {
|
||||
expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
|
||||
expect(accountReadiness(account, { ...binding, network_exit_id: '', network_exit_health: 'unchecked' })).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
|
||||
expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
|
||||
expect(accountReadiness(account, undefined, true)).toMatchObject({ label: '环境状态未知', canResume: false })
|
||||
})
|
||||
@@ -96,8 +97,8 @@ describe('AccountDetail', () => {
|
||||
render(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
|
||||
|
||||
expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy()
|
||||
expect(screen.getByText(/运行环境、固定出口与 readiness 状态未知/)).toBeTruthy()
|
||||
expect(screen.queryByText(/尚未绑定运行环境与固定出口/)).toBeNull()
|
||||
expect(screen.getByText(/运行环境与网络出口状态未知/)).toBeTruthy()
|
||||
expect(screen.queryByText(/尚未绑定运行环境/)).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '恢复账号' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '暂停账号' }).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
|
||||
|
||||
+6
-11
@@ -13,7 +13,6 @@ import {
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -136,7 +135,6 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
|
||||
const availableAccounts = accounts.filter(account => account.authorization_status === 'authorized' && account.runtime_status === 'paused')
|
||||
const healthyExits = networkExits.filter(exit => exit.health_status === 'healthy')
|
||||
const defaultAccountID = availableAccounts[0]?.id ?? ''
|
||||
const defaultExitID = healthyExits[0]?.id ?? ''
|
||||
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
const toggleSpoofing = option => setForm(current => ({
|
||||
...current,
|
||||
@@ -154,13 +152,9 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
|
||||
useEffect(() => {
|
||||
if (form.account_id === '' && defaultAccountID !== '') update('account_id', defaultAccountID)
|
||||
}, [defaultAccountID, form.account_id])
|
||||
useEffect(() => {
|
||||
if (form.network_exit_id === '' && defaultExitID !== '') update('network_exit_id', defaultExitID)
|
||||
}, [defaultExitID, form.network_exit_id])
|
||||
|
||||
const seedNumber = Number(form.seed)
|
||||
const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' &&
|
||||
form.image_version !== '' && form.account_id !== '' && form.network_exit_id !== '' &&
|
||||
form.image_version !== '' && form.account_id !== '' &&
|
||||
Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
|
||||
|
||||
const submit = async event => {
|
||||
@@ -217,8 +211,9 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
|
||||
</TextField>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{label('env-exit', '网络出口', true)}
|
||||
<TextField id="env-exit" select required slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} disabled={healthyExits.length === 0} helperText={healthyExits.length === 0 ? '请先检查并启用健康出口' : ' '}>
|
||||
{label('env-exit', '网络出口')}
|
||||
<TextField id="env-exit" select slotProps={{ htmlInput: { 'aria-label': '网络出口' } }} value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)} helperText="留空时使用当前机器网络出口直连">
|
||||
<MenuItem value="">当前机器直连</MenuItem>
|
||||
{healthyExits.map(exit => <MenuItem key={exit.id} value={exit.id}>{exit.id} · {exit.protocol}://{exit.host}:{exit.port}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
@@ -300,7 +295,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
|
||||
<TableRow key={runtime.alias} sx={{ '&:last-child td': { borderBottom: 0 } }}>
|
||||
<TableCell><Typography fontWeight={650}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · seed {runtime.fingerprint?.seed}</Typography></TableCell>
|
||||
<TableCell><Status state={runtime.state} /></TableCell>
|
||||
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
|
||||
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
|
||||
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <Typography variant="body2" color="text.secondary">—</Typography>}</TableCell>
|
||||
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></TableCell>
|
||||
</TableRow>
|
||||
@@ -312,7 +307,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
|
||||
}
|
||||
|
||||
function RuntimeCards({ runtimes, busy, onAction }) {
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
|
||||
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号:{runtime.account_id || '未绑定'} · 网络出口:{runtime.network_exit_id || '当前机器直连'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
|
||||
}
|
||||
|
||||
export function BrowserList() {
|
||||
|
||||
@@ -126,6 +126,8 @@ describe('BrowserList', () => {
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '店铺三号' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'shop-03' } })
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: '网络出口' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: /exit-1/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: {
|
||||
@@ -141,6 +143,25 @@ describe('BrowserList', () => {
|
||||
expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull()
|
||||
})
|
||||
|
||||
it('creates an env with the current machine exit when network exit is empty', async () => {
|
||||
const dataProvider = provider({ getList: vi.fn(resource => {
|
||||
if (resource === 'network-exits') return Promise.resolve({ data: [], total: 0 })
|
||||
return provider().getList(resource)
|
||||
}) })
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
await screen.findAllByText('店铺一号')
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '直连环境' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'direct-env' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('browsers', { data: expect.objectContaining({
|
||||
alias: 'direct-env',
|
||||
network_exit_id: '',
|
||||
}) }))
|
||||
expect(screen.getByText('留空时使用当前机器网络出口直连')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps binding input after a 409 conflict', async () => {
|
||||
const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409)) })
|
||||
render(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
|
||||
|
||||
@@ -66,7 +66,7 @@ export function DraftDetail() {
|
||||
confirmation.account_version === draft.account.version && confirmation.draft_version === draft.version)
|
||||
const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === draft.account_id)
|
||||
const readiness = accountReadiness(draft.account, binding, Boolean(browsersError))
|
||||
const canConfirm = snapshotCurrent && Boolean(binding?.network_exit_id) && !browsersError
|
||||
const canConfirm = snapshotCurrent && Boolean(binding) && !browsersError
|
||||
const canEnqueue = Boolean(currentConfirmation) && snapshotCurrent && readiness.ready
|
||||
|
||||
function openDialog() {
|
||||
@@ -114,8 +114,8 @@ export function DraftDetail() {
|
||||
</Box>
|
||||
|
||||
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
|
||||
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用:当前运行环境与固定出口状态未知,不能确认或排队。</Alert> : null}
|
||||
{!snapshotCurrent ? <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。</Alert> : null}
|
||||
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用:当前运行环境与网络出口状态未知,不能确认或排队。</Alert> : null}
|
||||
{snapshotCurrent ? null : <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。</Alert>}
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'minmax(0, 1.5fr) minmax(0, 1fr)' }, gap: 2.5 }}>
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, minWidth: 0 }}>
|
||||
@@ -129,7 +129,7 @@ export function DraftDetail() {
|
||||
<Typography>账号版本:{draft.account.version}</Typography>
|
||||
<Typography>草稿版本:{draft.version}</Typography>
|
||||
<Typography sx={wrapAnywhere}>运行环境:{browsersError ? '状态未知' : (binding?.name || '未绑定')}</Typography>
|
||||
<Typography sx={wrapAnywhere}>固定出口:{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')}</Typography>
|
||||
<Typography sx={wrapAnywhere}>网络出口:{browsersError ? '状态未知' : (binding?.network_exit_id || '当前机器直连')}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', color: readiness.ready ? 'success.main' : 'warning.main' }}>{readiness.ready ? <CheckCircleOutlined /> : <ReportProblemOutlined />}<Typography fontWeight={650}>{readiness.label}</Typography></Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -144,12 +144,12 @@ export function DraftDetail() {
|
||||
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
|
||||
<Typography variant="h6">显式核对与入队</Typography>
|
||||
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" />
|
||||
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和网络出口" />
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2 }}>
|
||||
<Button ref={confirmTrigger} variant="outlined" disabled={!checked || !canConfirm || busy} onClick={openDialog}>确认当前快照</Button>
|
||||
<Button variant="contained" disabled={!canEnqueue || busy} onClick={enqueue}>{busy ? '处理中…' : '加入队列'}</Button>
|
||||
</Stack>
|
||||
{!currentConfirmation ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列。</Typography> : null}
|
||||
{currentConfirmation ? null : <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列。</Typography>}
|
||||
{currentConfirmation ? <Paper variant="outlined" sx={{ p: 2, mt: 2.5, minWidth: 0 }}><Typography fontWeight={700}>确认快照 v{currentConfirmation.version}</Typography><Typography variant="body2">账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}</Typography><Typography variant="body2" sx={wrapAnywhere}>环境:{currentConfirmation.browser_env_alias || '未记录'} · 出口:{currentConfirmation.network_exit_id || '未记录'} · 绑定版本:{currentConfirmation.binding_version || '未记录'}</Typography></Paper> : null}
|
||||
</Paper>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function DraftDetail() {
|
||||
|
||||
<Dialog open={dialogOpen} disableRestoreFocus onClose={() => busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}>
|
||||
<DialogTitle id="confirm-draft-title">确认草稿版本</DialogTitle>
|
||||
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与固定出口 {binding?.network_exit_id} 的只读确认快照。版本变化后必须重新确认。</DialogContentText></DialogContent>
|
||||
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与网络出口 {binding?.network_exit_id || '当前机器直连'} 的只读确认快照。版本变化后必须重新确认。</DialogContentText></DialogContent>
|
||||
<DialogActions><Button autoFocus disabled={busy} onClick={closeDialog}>返回核对</Button><Button variant="contained" disabled={busy} onClick={confirm}>{busy ? '确认中…' : '保存确认'}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -46,6 +46,16 @@ describe('DraftDetail', () => {
|
||||
expect(screen.getByRole('button', { name: '确认当前快照' })).toBe(document.activeElement)
|
||||
})
|
||||
|
||||
it('allows confirming a direct-machine environment', async () => {
|
||||
const direct = { ...binding, network_exit_id: '', network_exit_health: 'unchecked' }
|
||||
const dataProvider = provider(draft, { getList: vi.fn().mockResolvedValue({ data: [direct], total: 1 }) })
|
||||
renderDraft(dataProvider)
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: /我已核对当前账号/ }))
|
||||
expect(screen.getByText(/网络出口:当前机器直连/)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '确认当前快照' }).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('submits only once while a repeated enqueue click is in flight', async () => {
|
||||
let finish
|
||||
const pending = new Promise(resolve => { finish = resolve })
|
||||
|
||||
Reference in New Issue
Block a user