feat: support direct network exit for runtimes

This commit is contained in:
2026-09-01 19:12:48 +08:00
parent 5674cfe8c3
commit 3729ce910f
15 changed files with 259 additions and 144 deletions
+65 -48
View File
@@ -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
}
+40 -20
View File
@@ -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)
}
}
+1 -3
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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{}