Files
creator-hub/cmd/control-plane/hub.go
T

1525 lines
59 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"github.com/gofiber/fiber/v3"
)
// hubStore 是控制面编排所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。
type hubStore interface {
CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error)
ListGateways(ctx context.Context) ([]hub.Gateway, error)
GetGateway(ctx context.Context, name string) (hub.Gateway, error)
DeleteGateway(ctx context.Context, name string) error
CreateImage(ctx context.Context, image hub.Image) error
UpdateImage(ctx context.Context, image hub.Image) error
ListImages(ctx context.Context, enabledOnly bool) ([]hub.Image, error)
DeleteImage(ctx context.Context, version string) error
ImageRef(ctx context.Context, version string) (string, error)
CreateEnv(ctx context.Context, env hub.Env) error
ListEnvs(ctx context.Context) ([]hub.Env, error)
GetEnv(ctx context.Context, alias string) (hub.Env, error)
UpgradeEnv(ctx context.Context, alias, version string) error
CreateNetworkExit(ctx context.Context, exit hub.NetworkExit, credentialReferenceID string) (hub.NetworkExit, error)
ListNetworkExits(ctx context.Context) ([]hub.NetworkExit, error)
GetNetworkExit(ctx context.Context, id string) (hub.NetworkExit, error)
GetNetworkExitAccess(ctx context.Context, id string) (hub.NetworkExitAccess, error)
RecordNetworkExitCheck(ctx context.Context, id string, observation hub.ExitObservation, failureReason string) (hub.NetworkExit, string, error)
DisableNetworkExit(ctx context.Context, id string) (hub.NetworkExit, error)
CreateBoundEnv(ctx context.Context, env hub.Env, accountID, exitID string) (hub.EnvironmentContext, bool, error)
GetEnvironmentContext(ctx context.Context, alias string) (hub.EnvironmentContext, error)
ValidateEnvironmentRebind(ctx context.Context, alias, exitID string, expectedBindingVersion int64) error
RebindEnvironment(ctx context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64) (hub.EnvironmentContext, error)
ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string) (hub.EnvironmentContext, error)
ReleaseRuntime(ctx context.Context, alias string) error
SetRuntimeCleanupPending(ctx context.Context, alias string, pending bool) error
AppendEnvironmentAction(ctx context.Context, eventType string, action hub.EnvironmentAction) error
}
const (
gatewayLongTimeout = 11 * time.Minute // 覆盖网关侧最长 10 分钟的镜像拉取
gatewayReconcileDelay = 100 * time.Millisecond
gatewayReconcileAttempts = 10
)
// gatewayCall 调用某个网关的 /v1 路由;ok 为 false 时 status/body 携带网关错误。
func gatewayCall(ctx context.Context, target hub.Gateway, method, path string, body any, timeout time.Duration) (status int, responseBody []byte, err error) {
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var payload io.Reader
if body != nil {
encoded, marshalErr := json.Marshal(body)
if marshalErr != nil {
return 0, nil, marshalErr
}
payload = bytes.NewReader(encoded)
}
request, requestErr := http.NewRequestWithContext(callCtx, method, target.Endpoint+path, payload)
if requestErr != nil {
return 0, nil, requestErr
}
request.Header.Set("Authorization", "Bearer "+target.Token)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, requestErr := http.DefaultClient.Do(request)
if requestErr != nil {
return 0, nil, requestErr
}
defer response.Body.Close()
responseBody, err = io.ReadAll(io.LimitReader(response.Body, 1<<20))
return response.StatusCode, responseBody, err
}
func gatewayCreatePayload(environment hub.EnvironmentContext, imageRef string, networkExit gatewayNetworkExit) map[string]any {
fingerprint := environment.Fingerprint
fingerprint.ProxyServer = ""
fingerprint.DisableNonProxiedUDP = false
cmd := append(fingerprint.Args(), "about:blank")
return map[string]any{
"alias": environment.Alias,
"name": environment.Name,
"image": imageRef,
"cmd": cmd,
"volume": "creatorhub-profile-" + environment.Alias,
"binding_version": environment.BindingVersion,
"network_exit_id": environment.Exit.ID,
"network_exit": networkExit,
}
}
func gatewayProxyPayload(environment hub.EnvironmentContext, networkExit gatewayNetworkExit) map[string]any {
return map[string]any{
"binding_version": environment.BindingVersion,
"network_exit_id": environment.Exit.ID,
"network_exit": networkExit,
}
}
// gatewayFailure 将网关错误转换为对调用方可读的失败;4xx 保留原状态,其余一律 502。
type gatewayFailure struct {
status int
message string
}
func (e gatewayFailure) Error() string { return e.message }
func gatewayRejected(status int, body []byte) error {
if status >= 400 && status < 500 {
return gatewayFailure{status: status, message: "gateway rejected: " + errorFromBody(body, status)}
}
return gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf("gateway call failed with status %d", status)}
}
func gatewayUnreachable(err error) error {
return gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf("gateway unreachable: %v", err)}
}
func reconcileGatewayContainer(ctx context.Context, target hub.Gateway, alias string) (containerStatus, bool, error) {
var lastErr error
for attempt := 0; attempt < gatewayReconcileAttempts; attempt++ {
if attempt > 0 {
timer := time.NewTimer(gatewayReconcileDelay)
select {
case <-ctx.Done():
timer.Stop()
return containerStatus{}, false, ctx.Err()
case <-timer.C:
}
}
status, body, err := gatewayCall(ctx, target, http.MethodGet, "/v1/browsers", nil, 30*time.Second)
if err != nil {
lastErr = err
continue
}
if status != http.StatusOK {
lastErr = gatewayRejected(status, body)
continue
}
browsers, parseErr := parseGatewayBrowserList(body)
if parseErr != nil {
lastErr = parseErr
continue
}
lastErr = nil
for _, browser := range browsers {
if browser.Alias == alias {
return browser, true, nil
}
}
}
return containerStatus{}, false, lastErr
}
func parseGatewayBrowserList(body []byte) ([]containerStatus, error) {
var entries []json.RawMessage
if err := json.Unmarshal(body, &entries); err != nil || entries == nil {
return nil, errors.New("gateway returned an invalid browser list")
}
browsers := make([]containerStatus, 0, len(entries))
aliases := make(map[string]bool, len(entries))
for _, entry := range entries {
var browser *containerStatus
if err := json.Unmarshal(entry, &browser); err != nil || browser == nil || browser.ID == "" || browser.Alias == "" || browser.State == "" ||
browser.BindingVersion < 0 || aliases[browser.Alias] {
return nil, errors.New("gateway returned an invalid browser list")
}
aliases[browser.Alias] = true
browsers = append(browsers, *browser)
}
return browsers, nil
}
func errorFromBody(body []byte, status int) string {
var envelope struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &envelope) == nil && envelope.Error != "" {
return envelope.Error
}
return http.StatusText(status)
}
type containerStatus struct {
ID string `json:"id"`
Alias string `json:"alias"`
Name string `json:"name"`
State string `json:"state"`
Status string `json:"status"`
Endpoint string `json:"endpoint"`
BindingVersion int64 `json:"binding_version"`
NetworkExitID string `json:"network_exit_id"`
ProxyReady bool `json:"proxy_ready"`
}
type envView struct {
hub.Env
State string `json:"state"`
Status string `json:"status"`
ContainerID string `json:"container_id"`
Endpoint string `json:"endpoint"`
NetworkExitID string `json:"network_exit_id"`
BindingVersion int64 `json:"binding_version"`
RecoveryRequired bool `json:"recovery_required"`
CleanupPending bool `json:"cleanup_pending"`
}
func containerMatchesBinding(container containerStatus, environment hub.EnvironmentContext) bool {
return container.BindingVersion == environment.BindingVersion && container.NetworkExitID == environment.Exit.ID
}
func registerHub(app *fiber.App, store hubStore) {
registerHubWithNetwork(app, store, defaultNetworkExitProbe(), resolveExitCredential)
}
var runtimeOperations sync.Mutex
func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) {
// ponytail: one control-plane instance is serialized globally; use keyed/distributed locks if replicas or throughput require it.
serialized := func(handler fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
runtimeOperations.Lock()
defer runtimeOperations.Unlock()
return handler(c)
}
}
app.Get("/api/browsers", serialized(listBrowsers(store, probe, resolve)))
app.Post("/api/browsers", serialized(createBrowser(store, probe, resolve)))
app.Post("/api/browsers/:alias/:action", serialized(browserAction(store, probe, resolve)))
app.Delete("/api/browsers/:alias", serialized(deleteBrowser(store)))
app.Get("/api/network-exits", listNetworkExits(store))
app.Post("/api/network-exits", serialized(createNetworkExit(store)))
app.Post("/api/network-exits/:id/check", serialized(checkNetworkExit(store, probe)))
app.Post("/api/network-exits/:id/disable", serialized(disableNetworkExit(store, probe, resolve)))
app.Get("/api/browser-images", func(c fiber.Ctx) error {
images, err := store.ListImages(c.Context(), false)
if err != nil {
return hubError(c, err)
}
return c.JSON(images)
})
app.Post("/api/browser-images", func(c fiber.Ctx) error {
input := struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Note string `json:"note"`
Enabled *bool `json:"enabled"`
}{}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
if err := store.CreateImage(c.Context(), hub.Image{Version: input.Version, ImageRef: input.ImageRef, Note: input.Note, Enabled: enabled}); err != nil {
return hubError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(map[string]any{
"version": input.Version, "image_ref": input.ImageRef, "note": input.Note, "enabled": enabled,
})
})
app.Put("/api/browser-images/:version", serialized(func(c fiber.Ctx) error {
input := struct {
ImageRef string `json:"image_ref"`
Note string `json:"note"`
Enabled *bool `json:"enabled"`
}{}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
if err := store.UpdateImage(c.Context(), hub.Image{Version: c.Params("version"), ImageRef: input.ImageRef, Note: input.Note, Enabled: enabled}); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}))
app.Delete("/api/browser-images/:version", serialized(func(c fiber.Ctx) error {
if err := store.DeleteImage(c.Context(), c.Params("version")); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}))
app.Get("/api/gateways", func(c fiber.Ctx) error {
gateways, err := store.ListGateways(c.Context())
if err != nil {
return hubError(c, err)
}
return c.JSON(gateways)
})
app.Post("/api/gateways", func(c fiber.Ctx) error {
input := struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Token string `json:"token"`
}{}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
gateway, err := store.CreateGateway(c.Context(), input.Name, input.Endpoint, input.Token)
if err != nil {
return hubError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(gateway)
})
app.Delete("/api/gateways/:name", func(c fiber.Ctx) error {
if err := store.DeleteGateway(c.Context(), c.Params("name")); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
})
}
func listNetworkExits(store hubStore) fiber.Handler {
return func(c fiber.Ctx) error {
exits, err := store.ListNetworkExits(c.Context())
if err != nil {
return hubError(c, err)
}
return c.JSON(exits)
}
}
func createNetworkExit(store hubStore) fiber.Handler {
return func(c fiber.Ctx) error {
var input struct {
Protocol string `json:"protocol"`
Host string `json:"host"`
Port int `json:"port"`
CredentialReference struct {
ID string `json:"id"`
} `json:"credential_reference"`
ExpectedPublicIP string `json:"expected_public_ip"`
ExpectedRegion string `json:"expected_region"`
}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
exit, err := store.CreateNetworkExit(c.Context(), hub.NetworkExit{
Protocol: input.Protocol, Host: input.Host, Port: input.Port,
ExpectedPublicIP: input.ExpectedPublicIP, ExpectedRegion: input.ExpectedRegion,
}, input.CredentialReference.ID)
if err != nil {
return hubError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(exit)
}
}
func checkNetworkExit(store hubStore, probe networkExitProbe) fiber.Handler {
return func(c fiber.Ctx) error {
access, err := store.GetNetworkExitAccess(c.Context(), c.Params("id"))
if err != nil {
return hubError(c, err)
}
if access.HealthStatus == "disabled" {
return hubError(c, hub.ErrConflict)
}
observation, failureReason := probe.Check(c.Context(), access)
exit, reason, err := store.RecordNetworkExitCheck(c.Context(), access.ID, observation, failureReason)
if err != nil {
return hubError(c, err)
}
return c.JSON(struct {
hub.NetworkExit
ReasonCode string `json:"reason_code"`
}{exit, reason})
}
}
func disableNetworkExit(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) fiber.Handler {
return func(c fiber.Ctx) error {
exit, err := store.DisableNetworkExit(c.Context(), c.Params("id"))
if err != nil {
return hubError(c, err)
}
if err := reconcileRuntimeLeasesUnlocked(c.Context(), store, probe, resolve); err != nil {
return hubError(c, err)
}
return c.JSON(exit)
}
}
func verifyNetworkExit(ctx context.Context, store hubStore, probe networkExitProbe, id string) (hub.NetworkExitAccess, string, error) {
access, err := store.GetNetworkExitAccess(ctx, id)
if err != nil {
return hub.NetworkExitAccess{}, "exit_unavailable", err
}
if access.HealthStatus == "disabled" {
return hub.NetworkExitAccess{}, "exit_disabled", hub.ErrConflict
}
observation, failureReason := probe.Check(ctx, access)
exit, reason, err := store.RecordNetworkExitCheck(ctx, id, observation, failureReason)
if err != nil {
return hub.NetworkExitAccess{}, reason, err
}
if exit.HealthStatus != "healthy" {
return hub.NetworkExitAccess{}, reason, hub.ErrConflict
}
access, err = store.GetNetworkExitAccess(ctx, id)
return access, reason, err
}
func listBrowsers(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) fiber.Handler {
return func(c fiber.Ctx) error {
envs, err := store.ListEnvs(c.Context())
if err != nil {
return hubError(c, err)
}
containers, gatewayRead, gatewayErrors := gatewayContainerSnapshot(c.Context(), store, envs)
if err := reconcileRuntimeSnapshot(c.Context(), store, probe, resolve, envs, containers, gatewayRead, gatewayErrors); err != nil {
return hubError(c, err)
}
views := make([]envView, 0, len(envs))
for _, env := range envs {
view := envView{Env: env, State: "missing", Status: "网关上不存在容器"}
if environment, contextErr := store.GetEnvironmentContext(c.Context(), env.Alias); contextErr == nil {
view.NetworkExitID, view.BindingVersion = environment.Exit.ID, environment.BindingVersion
view.CleanupPending = environment.RuntimeCleanupPending
view.RecoveryRequired = environment.Exit.ID == "" || environment.RuntimeCleanupPending
} else if !errors.Is(contextErr, hub.ErrNotFound) {
return hubError(c, contextErr)
}
if container, ok := containers[env.Gateway][env.Alias]; ok {
view.State, view.Status = container.State, container.Status
view.ContainerID, view.Endpoint = container.ID, container.Endpoint
}
views = append(views, view)
}
return c.JSON(views)
}
}
func reconcileRuntimeLeases(ctx context.Context, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) error {
runtimeOperations.Lock()
defer runtimeOperations.Unlock()
return reconcileRuntimeLeasesUnlocked(ctx, store, probe, resolve)
}
func reconcileRuntimeLeasesUnlocked(ctx context.Context, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) error {
envs, err := store.ListEnvs(ctx)
if err != nil {
return err
}
containers, gatewayRead, gatewayErrors := gatewayContainerSnapshot(ctx, store, envs)
return reconcileRuntimeSnapshot(ctx, store, probe, resolve, envs, containers, gatewayRead, gatewayErrors)
}
func gatewayContainerSnapshot(ctx context.Context, store hubStore, envs []hub.Env) (map[string]map[string]containerStatus, map[string]bool, map[string]error) {
gateways := map[string]bool{}
containers := map[string]map[string]containerStatus{}
gatewayRead := map[string]bool{}
gatewayErrors := map[string]error{}
for _, env := range envs {
if gateways[env.Gateway] {
continue
}
gateways[env.Gateway] = true
gateway, err := store.GetGateway(ctx, env.Gateway)
if err != nil {
gatewayErrors[env.Gateway] = gatewayUnreachable(err)
continue
}
status, body, callErr := gatewayCall(ctx, gateway, http.MethodGet, "/v1/browsers", nil, 30*time.Second)
if callErr != nil {
gatewayErrors[env.Gateway] = gatewayUnreachable(callErr)
continue
}
if status != http.StatusOK {
gatewayErrors[env.Gateway] = gatewayRejected(status, body)
continue
}
list, parseErr := parseGatewayBrowserList(body)
if parseErr != nil {
gatewayErrors[env.Gateway] = gatewayFailure{status: http.StatusBadGateway, message: "gateway returned an invalid browser list"}
continue
}
byAlias := map[string]containerStatus{}
for _, container := range list {
byAlias[container.Alias] = container
}
containers[env.Gateway] = byAlias
gatewayRead[env.Gateway] = true
}
return containers, gatewayRead, gatewayErrors
}
func reconcileRuntimeSnapshot(ctx context.Context, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), envs []hub.Env,
containers map[string]map[string]containerStatus, gatewayRead map[string]bool, gatewayErrors map[string]error) error {
var firstErr error
for _, env := range envs {
if readErr := gatewayErrors[env.Gateway]; readErr != nil {
if firstErr == nil {
firstErr = readErr
}
continue
}
environment, err := store.GetEnvironmentContext(ctx, env.Alias)
if errors.Is(err, hub.ErrNotFound) {
continue
}
if err != nil {
return err
}
if environment.RuntimeCleanupPending {
target, targetErr := store.GetGateway(ctx, environment.Gateway)
if targetErr != nil {
return targetErr
}
if _, cleanupErr := removeGatewayRuntime(ctx, store, target, environment); cleanupErr != nil {
return cleanupErr
}
continue
}
container, found := containers[env.Gateway][env.Alias]
if found && container.State == "running" {
if environment.Exit.ID == "" {
continue
}
auditRecovery := !containerMatchesBinding(container, environment) || !container.ProxyReady
action := actionForEnvironment("reconcile", environment)
if auditRecovery {
if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", action); err != nil {
return err
}
}
ready, restoreErr := restoreOrRebuildRuntime(ctx, store, probe, resolve, environment, container)
if auditRecovery {
action.Outcome, action.ReasonCode = "succeeded", "runtime_reconciled"
if !ready {
action.Outcome, action.ReasonCode = "failed", "runtime_unavailable"
}
if restoreErr != nil {
action.Outcome, action.ReasonCode = runtimeRecoveryFailure(ctx, store, environment.Alias, restoreErr)
}
if err := store.AppendEnvironmentAction(ctx, "environment_action_finished", action); err != nil {
return errors.Join(restoreErr, err)
}
}
if restoreErr != nil {
return restoreErr
}
} else if gatewayRead[env.Gateway] {
if err := store.ReleaseRuntime(ctx, env.Alias); err != nil {
return err
}
}
}
return firstErr
}
func runtimeRecoveryFailure(ctx context.Context, store hubStore, alias string, err error) (string, string) {
var gatewayErr gatewayFailure
if errors.As(err, &gatewayErr) {
return "unknown", "gateway_result_unknown"
}
if environment, contextErr := store.GetEnvironmentContext(ctx, alias); contextErr == nil && environment.RuntimeCleanupPending {
return "unknown", "cleanup_result_unknown"
}
return "failed", "runtime_persistence_failed"
}
func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkExitProbe,
resolve func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, container containerStatus) (bool, error) {
if environment.RuntimeCleanupPending {
target, err := store.GetGateway(ctx, environment.Gateway)
if err != nil {
return false, err
}
_, 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)
}
target, err := store.GetGateway(ctx, environment.Gateway)
if err != nil {
return false, err
}
if containerMatchesBinding(container, environment) && container.ProxyReady {
_, err := store.ActivateRuntime(ctx, environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
return err == nil, err
}
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, networkExit), 30*time.Second)
if callErr == nil && status == http.StatusNoContent {
container.ProxyReady = true
}
if container.ProxyReady {
_, err := store.ActivateRuntime(ctx, environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
return err == nil, err
}
}
_, removeErr := removeGatewayRuntime(ctx, store, target, environment)
if removeErr != nil {
return false, removeErr
}
imageRef, err := store.ImageRef(ctx, environment.ImageVersion)
if err != nil {
return false, err
}
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers",
gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil {
return false, gatewayUnreachable(callErr)
}
if status != http.StatusCreated {
return false, gatewayRejected(status, body)
}
var created struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &created) != nil || created.ID == "" {
return false, errors.New("gateway returned an invalid runtime id")
}
_, err = store.ActivateRuntime(ctx, environment.Alias, created.ID, environment.BindingVersion, environment.Exit.ID)
return err == nil, err
}
func discardRuntime(ctx context.Context, store hubStore, environment hub.EnvironmentContext) error {
target, targetErr := store.GetGateway(ctx, environment.Gateway)
if targetErr != nil {
return targetErr
}
_, err := removeGatewayRuntime(ctx, store, target, environment)
return err
}
func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) fiber.Handler {
return func(c fiber.Ctx) error {
input := struct {
Alias string `json:"alias"`
Name string `json:"name"`
Gateway string `json:"gateway"`
ImageVersion string `json:"image_version"`
Fingerprint hub.Fingerprint `json:"fingerprint"`
AccountID string `json:"account_id"`
NetworkExitID string `json:"network_exit_id"`
}{}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
if input.Fingerprint.ProxyServer != "" {
return hubError(c, hub.ErrInvalid)
}
input.Fingerprint.DisableNonProxiedUDP = false
env := hub.Env{Alias: input.Alias, Name: input.Name, Gateway: input.Gateway, ImageVersion: input.ImageVersion, Fingerprint: input.Fingerprint}
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)
}
action := actionForEnvironment("create", environment)
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID
action.BindingVersion = current.BindingVersion
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)
}
gateway, err := store.GetGateway(c.Context(), env.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", environment)
return hubError(c, err)
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
}
environment, err = store.GetEnvironmentContext(c.Context(), env.Alias)
if err != nil {
return hubError(c, err)
}
}
imageRef, err := store.ImageRef(c.Context(), env.ImageVersion)
if err != nil {
_ = 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)
}
if !created {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayUnreachable(reconcileErr))
}
if found && container.State == "running" {
ready, restoreErr := restoreOrRebuildRuntime(c.Context(), store, probe, resolve, environment, container)
if restoreErr != nil {
outcome, reason := runtimeRecoveryFailure(c.Context(), store, environment.Alias, restoreErr)
_ = finish(outcome, reason, environment)
return hubError(c, restoreErr)
}
if !ready {
_ = finish("failed", "runtime_unavailable", environment)
return c.Status(fiber.StatusConflict).JSON(map[string]string{"error": "environment exists but runtime is not running"})
}
environment, err = store.GetEnvironmentContext(c.Context(), env.Alias)
if err != nil {
return hubError(c, err)
}
if err := finish("succeeded", "environment_reused", environment); err != nil {
return hubError(c, err)
}
return c.JSON(map[string]string{"alias": env.Alias})
}
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil || status >= http.StatusInternalServerError {
createErr := gatewayRejected(status, body)
if callErr != nil {
createErr = gatewayUnreachable(callErr)
}
container, exists, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias)
if reconcileErr == nil && exists && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) {
environment, err = store.ActivateRuntime(c.Context(), env.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_ = finish("failed", "runtime_persistence_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "gateway_reconciled", environment); err != nil {
return hubError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(map[string]string{"alias": env.Alias})
}
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf(
"gateway create result unknown; environment retained for reconciliation: %v", createErr)})
}
_ = finish("failed", "gateway_create_failed", environment)
return hubError(c, createErr)
}
if status != http.StatusCreated {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
var createdRuntime struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &createdRuntime) != nil || createdRuntime.ID == "" {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway create result unknown; environment retained for reconciliation"})
}
environment, err = store.ActivateRuntime(c.Context(), env.Alias, createdRuntime.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment)
if cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
} else {
_ = finish("failed", "runtime_persistence_failed", environment)
}
return hubError(c, err)
}
if err := finish("succeeded", "environment_created", environment); err != nil {
return hubError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(map[string]string{"alias": env.Alias})
}
}
func browserAction(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) fiber.Handler {
return func(c fiber.Ctx) error {
switch c.Params("action") {
case "start", "stop":
return lifecycleAction(store, probe, resolve, c)
case "upgrade":
return upgradeBrowser(store, probe, resolve, c)
case "rebind":
return rebindBrowser(store, probe, resolve, c)
default:
return hubError(c, hub.ErrInvalid)
}
}
}
func lifecycleAction(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error {
environment, err := store.GetEnvironmentContext(c.Context(), c.Params("alias"))
if err != nil {
return hubError(c, err)
}
actionName := strings.Clone(c.Params("action"))
action := actionForEnvironment(actionName, environment)
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID
action.BindingVersion = current.BindingVersion
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
if actionName == "start" {
return startBrowser(store, probe, resolve, c, environment, finish)
}
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", environment)
return hubError(c, err)
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
}
if err := finish("succeeded", "environment_stopped", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers/"+environment.Alias+"/"+actionName, nil, 30*time.Second)
if callErr != nil {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayUnreachable(callErr))
}
if !found || container.State != "running" {
if err := store.ReleaseRuntime(c.Context(), environment.Alias); err != nil {
_ = finish("failed", "runtime_release_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "gateway_reconciled", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
_ = finish("failed", "gateway_action_failed", environment)
return hubError(c, gatewayUnreachable(callErr))
}
if status != http.StatusNoContent && status != http.StatusNotModified {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
if err := store.ReleaseRuntime(c.Context(), environment.Alias); err != nil {
_ = finish("failed", "runtime_release_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "environment_stopped", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx,
environment hub.EnvironmentContext, finish func(string, string, hub.EnvironmentContext) error) error {
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)
}
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", environment)
return hubError(c, err)
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
}
environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias)
if err != nil {
return hubError(c, err)
}
}
imageRef, err := store.ImageRef(c.Context(), environment.ImageVersion)
if err != nil {
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
}
_ = 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)
}
_ = finish("failed", "credential_unavailable", environment)
return hubError(c, hub.ErrConflict)
}
container, found, err := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
if err != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayUnreachable(err))
}
if found && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) {
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_ = finish("failed", "runtime_persistence_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "gateway_reconciled", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
if found {
if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, removeErr)
}
} else if err := store.ReleaseRuntime(c.Context(), environment.Alias); err != nil {
_ = finish("failed", "runtime_release_failed", environment)
return hubError(c, err)
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers",
gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil || status >= http.StatusInternalServerError {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"})
}
if !found || container.State != "running" || !container.ProxyReady || !containerMatchesBinding(container, environment) {
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
}
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
} else {
if status != http.StatusCreated {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
var created struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &created) != nil || created.ID == "" {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"})
}
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, created.ID, environment.BindingVersion, environment.Exit.ID)
}
if err != nil {
_ = finish("failed", "runtime_persistence_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "environment_started", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error {
var input struct {
Version string `json:"version"`
}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
environment, err := store.GetEnvironmentContext(c.Context(), c.Params("alias"))
if err != nil {
return hubError(c, err)
}
var imageRef string
var imageErr error
if !hub.ValidImageVersion(input.Version) {
imageErr = hub.ErrInvalid
} else {
imageRef, imageErr = store.ImageRef(c.Context(), input.Version)
}
action := actionForEnvironment("upgrade", environment)
action.OldImageVersion = environment.ImageVersion
if imageErr != nil {
action.NewImageVersion, action.ReasonCode = "", "upgrade_input_rejected"
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
action.Outcome = "failed"
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action); err != nil {
return hubError(c, err)
}
return hubError(c, imageErr)
}
action.NewImageVersion = input.Version
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID
action.BindingVersion = current.BindingVersion
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
access, reason, err := verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID)
if err != nil {
_ = finish("failed", reason, environment)
return hubError(c, err)
}
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", environment)
return hubError(c, err)
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
}
environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias)
if err != nil {
return hubError(c, err)
}
}
networkExit, err := gatewayNetworkExitFor(access, resolve)
if err != nil {
_ = finish("failed", "credential_unavailable", environment)
return hubError(c, hub.ErrConflict)
}
// 先删容器(保留卷);404 视为已删除,保证升级可重试。
if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, removeErr)
}
if err := store.UpgradeEnv(c.Context(), environment.Alias, input.Version); err != nil {
_ = finish("failed", "persistence_failed", environment)
return hubError(c, err)
}
environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias)
if err != nil {
_ = finish("failed", "persistence_failed", environment)
return hubError(c, err)
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
var createdRuntime struct {
ID string `json:"id"`
}
if callErr != nil || status >= http.StatusInternalServerError {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"})
}
if !found || container.State != "running" || !container.ProxyReady || !containerMatchesBinding(container, environment) {
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
}
createdRuntime.ID = container.ID
} else {
if status != http.StatusCreated {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
if json.Unmarshal(body, &createdRuntime) != nil || createdRuntime.ID == "" {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"})
}
}
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, createdRuntime.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment)
if cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
} else {
_ = finish("failed", "runtime_persistence_failed", environment)
}
return hubError(c, err)
}
if err := finish("succeeded", "environment_upgraded", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
type runtimeCreateSpec struct {
imageRef string
networkExit gatewayNetworkExit
}
func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub.NetworkExitAccess) (string, error),
environment hub.EnvironmentContext, access hub.NetworkExitAccess) (runtimeCreateSpec, error) {
imageRef, err := store.ImageRef(ctx, environment.ImageVersion)
if err != nil {
return runtimeCreateSpec{}, err
}
networkExit, err := gatewayNetworkExitFor(access, resolve)
if err != nil {
return runtimeCreateSpec{}, err
}
return runtimeCreateSpec{imageRef: imageRef, networkExit: networkExit}, nil
}
func createGatewayRuntime(ctx context.Context, target hub.Gateway, environment hub.EnvironmentContext, spec runtimeCreateSpec) (containerStatus, error) {
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers",
gatewayCreatePayload(environment, spec.imageRef, spec.networkExit), gatewayLongTimeout)
if callErr == nil && status == http.StatusCreated {
var created containerStatus
if json.Unmarshal(body, &created) == nil && created.ID != "" {
created.Alias, created.State = environment.Alias, "running"
created.BindingVersion, created.NetworkExitID, created.ProxyReady = environment.BindingVersion, environment.Exit.ID, true
return created, nil
}
}
container, found, reconcileErr := reconcileGatewayContainer(ctx, target, environment.Alias)
if reconcileErr == nil && found && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) {
return container, nil
}
if callErr != nil {
return containerStatus{}, gatewayUnreachable(callErr)
}
if status != http.StatusCreated {
return containerStatus{}, gatewayRejected(status, body)
}
if reconcileErr != nil {
return containerStatus{}, gatewayUnreachable(reconcileErr)
}
return containerStatus{}, errors.New("gateway returned an invalid runtime id")
}
func createStoppedGatewayRuntime(ctx context.Context, target hub.Gateway, environment hub.EnvironmentContext, imageRef string) error {
payload := gatewayCreatePayload(environment, imageRef, gatewayNetworkExit{})
payload["stopped"] = true
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers", payload, gatewayLongTimeout)
if callErr == nil && status == http.StatusCreated {
return nil
}
container, found, reconcileErr := reconcileGatewayContainer(ctx, target, environment.Alias)
if reconcileErr == nil && found && container.State != "running" && containerMatchesBinding(container, environment) {
return nil
}
if callErr != nil {
return gatewayUnreachable(callErr)
}
if status != http.StatusCreated {
return gatewayRejected(status, body)
}
if reconcileErr != nil {
return gatewayUnreachable(reconcileErr)
}
return errors.New("gateway did not preserve the stopped runtime")
}
func removeGatewayRuntime(ctx context.Context, store hubStore, target hub.Gateway, environment hub.EnvironmentContext) (bool, error) {
removed := environment.RuntimeCleanupPending
if !environment.RuntimeCleanupPending {
if err := store.SetRuntimeCleanupPending(ctx, environment.Alias, true); err != nil {
return false, err
}
}
for attempt := 0; attempt < 2; attempt++ {
status, body, callErr := gatewayCall(ctx, target, http.MethodDelete, "/v1/browsers/"+environment.Alias, nil, 30*time.Second)
if callErr == nil && (status == http.StatusNoContent || status == http.StatusNotFound) {
return true, store.SetRuntimeCleanupPending(ctx, environment.Alias, false)
}
if callErr == nil && status == http.StatusAccepted {
removed = true
continue
}
_, found, reconcileErr := reconcileGatewayContainer(ctx, target, environment.Alias)
if reconcileErr != nil {
if callErr != nil {
return removed, errors.Join(gatewayUnreachable(callErr), gatewayUnreachable(reconcileErr))
}
return removed, errors.Join(gatewayRejected(status, body), gatewayUnreachable(reconcileErr))
}
if found {
if callErr != nil {
return removed, gatewayUnreachable(callErr)
}
return removed, gatewayRejected(status, body)
}
removed = true
if callErr != nil {
return true, gatewayUnreachable(callErr)
}
return true, gatewayRejected(status, body)
}
if removed {
return true, gatewayFailure{status: http.StatusBadGateway, message: "gateway removed the container but network cleanup is pending"}
}
return false, gatewayFailure{status: http.StatusBadGateway, message: "gateway runtime cleanup is pending"}
}
func restoreRebindRuntime(ctx context.Context, store hubStore, resolve func(hub.NetworkExitAccess) (string, error),
target hub.Gateway, environment hub.EnvironmentContext, previous containerStatus, found bool, prepared *runtimeCreateSpec) (bool, error) {
if !found {
return true, store.ReleaseRuntime(ctx, environment.Alias)
}
if environment.Exit.ID == "" {
if err := store.ReleaseRuntime(ctx, environment.Alias); err != nil {
return false, err
}
if previous.State == "running" {
return false, nil
}
imageRef := ""
if prepared != nil {
imageRef = prepared.imageRef
} else {
var err error
imageRef, err = store.ImageRef(ctx, environment.ImageVersion)
if err != nil {
return false, err
}
}
if err := createStoppedGatewayRuntime(ctx, target, environment, imageRef); err != nil {
_, cleanupErr := removeGatewayRuntime(ctx, store, target, environment)
return false, errors.Join(err, cleanupErr)
}
return true, nil
}
spec := runtimeCreateSpec{}
if prepared != nil {
spec = *prepared
} else {
access, err := store.GetNetworkExitAccess(ctx, environment.Exit.ID)
if err != nil {
return false, err
}
spec, err = prepareRuntimeCreate(ctx, store, resolve, environment, access)
if err != nil {
return false, err
}
}
if err := store.ReleaseRuntime(ctx, environment.Alias); err != nil {
return false, err
}
created, err := createGatewayRuntime(ctx, target, environment, spec)
if err != nil {
_, cleanupErr := removeGatewayRuntime(ctx, store, target, environment)
return false, errors.Join(err, cleanupErr)
}
if previous.State == "running" {
_, err = store.ActivateRuntime(ctx, environment.Alias, created.ID, environment.BindingVersion, environment.Exit.ID)
} else {
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/stop", nil, 30*time.Second)
if callErr != nil {
err = gatewayUnreachable(callErr)
} else if status != http.StatusNoContent {
err = gatewayRejected(status, body)
}
}
if err != nil {
_, cleanupErr := removeGatewayRuntime(ctx, store, target, environment)
return false, errors.Join(err, cleanupErr)
}
return true, nil
}
func rebindRecoveryError(ready bool, err error) error {
if err != nil {
return err
}
if !ready {
return gatewayFailure{status: http.StatusBadGateway, message: "rebind recovery is incomplete; retry reconciliation"}
}
return nil
}
func runtimeCreateSpecMatches(ctx context.Context, store hubStore, current, previous hub.EnvironmentContext, prepared *runtimeCreateSpec) bool {
if prepared == nil || current.BindingVersion != previous.BindingVersion || current.ImageVersion != previous.ImageVersion || current.Exit.ID != previous.Exit.ID {
return false
}
imageRef, err := store.ImageRef(ctx, current.ImageVersion)
return err == nil && imageRef == prepared.imageRef
}
func rebindBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error {
var input struct {
NetworkExitID string `json:"network_exit_id"`
}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
before, err := store.GetEnvironmentContext(c.Context(), c.Params("alias"))
if err != nil {
return hubError(c, err)
}
action := actionForEnvironment("rebind", before)
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode = outcome, reason
action.NetworkExitID, action.BindingVersion, action.RuntimeInstanceID = current.Exit.ID, current.BindingVersion, current.RuntimeInstanceID
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, before)
return hubError(c, err)
}
target, err := store.GetGateway(c.Context(), before.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", before)
return hubError(c, err)
}
if before.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, target, before); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", before)
return hubError(c, cleanupErr)
}
before, err = store.GetEnvironmentContext(c.Context(), before.Alias)
if err != nil {
return hubError(c, err)
}
}
container, found, err := reconcileGatewayContainer(c.Context(), target, before.Alias)
if err != nil {
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, gatewayUnreachable(err))
}
wasRunning := found && container.State == "running"
if err := store.ValidateEnvironmentRebind(c.Context(), before.Alias, input.NetworkExitID, before.BindingVersion); err != nil {
_ = finish("failed", "rebind_not_allowed", before)
return hubError(c, err)
}
var previousSpec *runtimeCreateSpec
if found && before.Exit.ID == "" {
imageRef, imageErr := store.ImageRef(c.Context(), before.ImageVersion)
if imageErr != nil {
_ = finish("failed", "runtime_prepare_failed", before)
return hubError(c, imageErr)
}
previousSpec = &runtimeCreateSpec{imageRef: imageRef}
} else if found {
previousAccess, accessErr := store.GetNetworkExitAccess(c.Context(), before.Exit.ID)
if accessErr != nil {
_ = finish("failed", "credential_unavailable", before)
return hubError(c, accessErr)
}
prepared, prepareErr := prepareRuntimeCreate(c.Context(), store, resolve, before, previousAccess)
if prepareErr != nil {
_ = finish("failed", "runtime_prepare_failed", before)
return hubError(c, prepareErr)
}
previousSpec = &prepared
}
candidate := before
candidate.Exit, candidate.BindingVersion = access.NetworkExit, before.BindingVersion+1
candidate.RuntimeInstanceID, candidate.RuntimeID = "", ""
var nextSpec runtimeCreateSpec
if wasRunning {
nextSpec, err = prepareRuntimeCreate(c.Context(), store, resolve, candidate, access)
if err != nil {
_ = finish("failed", "runtime_prepare_failed", before)
return hubError(c, err)
}
}
if found {
_, removeErr := removeGatewayRuntime(c.Context(), store, target, before)
if removeErr != nil {
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, removeErr)
}
}
var candidateRuntime containerStatus
if wasRunning {
candidateRuntime, err = createGatewayRuntime(c.Context(), target, candidate, nextSpec)
if err != nil {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, target, before)
if cleanupErr != nil {
_ = finish("unknown", "rebind_recovery_failed", before)
return hubError(c, errors.Join(err, cleanupErr))
}
current, contextErr := store.GetEnvironmentContext(c.Context(), before.Alias)
ready := false
if contextErr == nil {
ready, contextErr = restoreRebindRuntime(c.Context(), store, resolve, target, current, container, found, previousSpec)
}
contextErr = rebindRecoveryError(ready, contextErr)
if contextErr != nil {
_ = finish("unknown", "rebind_recovery_failed", before)
return hubError(c, errors.Join(err, contextErr))
}
_ = finish("failed", "gateway_create_failed", before)
return hubError(c, err)
}
}
after, err := store.RebindEnvironment(c.Context(), before.Alias, input.NetworkExitID, candidateRuntime.ID, before.BindingVersion)
if err != nil {
if candidateRuntime.ID != "" {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, target, before)
if cleanupErr != nil {
_ = finish("unknown", "rebind_recovery_failed", before)
return hubError(c, errors.Join(err, cleanupErr))
}
}
if found {
current, contextErr := store.GetEnvironmentContext(c.Context(), before.Alias)
ready := false
if contextErr == nil {
prepared := previousSpec
if !runtimeCreateSpecMatches(c.Context(), store, current, before, previousSpec) {
prepared = nil
}
ready, contextErr = restoreRebindRuntime(c.Context(), store, resolve, target, current, container, true, prepared)
}
contextErr = rebindRecoveryError(ready, contextErr)
if contextErr != nil {
_ = finish("unknown", "rebind_recovery_failed", before)
return hubError(c, errors.Join(err, contextErr))
}
}
_ = finish("failed", "rebind_not_allowed", before)
return hubError(c, err)
}
if err := finish("succeeded", "environment_rebound", after); err != nil {
return hubError(c, err)
}
return c.JSON(after)
}
func deleteBrowser(store hubStore) fiber.Handler {
return func(c fiber.Ctx) error {
environment, err := store.GetEnvironmentContext(c.Context(), c.Params("alias"))
if err != nil {
return hubError(c, err)
}
action := actionForEnvironment("recycle", environment)
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
if err != nil {
action.Outcome, action.ReasonCode = "failed", "gateway_unavailable"
_ = store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
return hubError(c, err)
}
if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil {
action.Outcome, action.ReasonCode = "unknown", "cleanup_result_unknown"
_ = store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
return hubError(c, removeErr)
}
action.Outcome, action.ReasonCode = "succeeded", "environment_recycled"
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
}
func actionForEnvironment(actionName string, environment hub.EnvironmentContext) hub.EnvironmentAction {
return hub.EnvironmentAction{
OperationID: hub.NewOperationID(), Action: actionName, ReasonCode: "action_requested",
AccountID: environment.AccountID, BrowserEnvAlias: environment.Alias, NetworkExitID: environment.Exit.ID,
RuntimeInstanceID: environment.RuntimeInstanceID, BindingVersion: environment.BindingVersion,
OldImageVersion: environment.ImageVersion, NewImageVersion: environment.ImageVersion,
}
}
func decodeHubJSON(c fiber.Ctx, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return hub.ErrInvalid
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return hub.ErrInvalid
}
return nil
}
func hubError(c fiber.Ctx, err error) error {
var failure gatewayFailure
if errors.As(err, &failure) {
return c.Status(failure.status).JSON(map[string]string{"error": failure.message})
}
status := fiber.StatusInternalServerError
message := "hub operation failed"
switch {
case errors.Is(err, hub.ErrInvalid):
status, message = fiber.StatusBadRequest, hub.ErrInvalid.Error()
case errors.Is(err, hub.ErrConflict):
status, message = fiber.StatusConflict, hub.ErrConflict.Error()
case errors.Is(err, hub.ErrNotFound):
status, message = fiber.StatusNotFound, hub.ErrNotFound.Error()
default:
return c.Status(status).JSON(map[string]string{"error": err.Error()})
}
return c.Status(status).JSON(map[string]string{"error": message})
}