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

2156 lines
83 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"github.com/gofiber/fiber/v3"
"github.com/sirupsen/logrus"
)
// hubStore 是控制面编排所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。
type hubStore interface {
LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error)
CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error)
UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (hub.Gateway, error)
ListGateways(ctx context.Context) ([]hub.Gateway, error)
GetGateway(ctx context.Context, name string) (hub.Gateway, error)
DeleteGateway(ctx context.Context, name string) error
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) (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)
GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error)
ValidateEnvironmentRebind(ctx context.Context, alias, exitID string, expectedBindingVersion int64) error
RebindEnvironment(ctx context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64, networkID ...string) (hub.EnvironmentContext, error)
ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string, networkID ...string) (hub.EnvironmentContext, error)
ReleaseRuntime(ctx context.Context, environment hub.EnvironmentContext) error
SetRuntimeCleanupPending(ctx context.Context, environment hub.EnvironmentContext, pending bool) error
AppendEnvironmentAction(ctx context.Context, eventType string, action hub.EnvironmentAction) error
}
type runtimeStopStore interface {
LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error)
GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error)
GetGateway(ctx context.Context, name string) (hub.Gateway, error)
ReleaseRuntime(ctx context.Context, environment hub.EnvironmentContext) error
SetRuntimeCleanupPending(ctx context.Context, environment hub.EnvironmentContext, pending bool) error
AppendEnvironmentAction(ctx context.Context, eventType string, action hub.EnvironmentAction) error
}
type runtimeCleanupStore interface {
SetRuntimeCleanupPending(ctx context.Context, environment hub.EnvironmentContext, pending bool) error
}
const (
gatewayLongTimeout = 11 * time.Minute // 覆盖网关侧最长 10 分钟的镜像拉取
gatewayReconcileDelay = 100 * time.Millisecond
gatewayReconcileAttempts = 10
missingRuntimeID = "runtime-not-found"
)
var gatewayGenerationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
// 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(), "--remote-allow-origins=*", "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 gatewayCreatePayloadForAccount(environment hub.EnvironmentContext, imageRef string, networkExit gatewayNetworkExit) map[string]any {
payload := gatewayCreatePayload(environment, imageRef, networkExit)
if !accountRunnable(environment) {
payload["network_exit_id"], payload["network_exit"], payload["stopped"] = "", gatewayNetworkExit{}, true
}
return payload
}
func gatewayProxyPayload(environment hub.EnvironmentContext, runtimeID, networkID string, networkExit gatewayNetworkExit) map[string]any {
return map[string]any{
"binding_version": environment.BindingVersion,
"runtime_id": runtimeID,
"network_id": networkID,
"network_exit_id": environment.Exit.ID,
"network_exit": networkExit,
}
}
func gatewayGenerationPayload(environment hub.EnvironmentContext) map[string]any {
return map[string]any{"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID, "network_id": environment.RuntimeNetworkID, "network_exit_id": environment.Exit.ID}
}
func gatewayCleanupGenerationPayload(environment hub.EnvironmentContext) map[string]any {
bindingVersion, runtimeID, networkID := environment.RuntimeCleanupBindingVersion, environment.RuntimeCleanupRuntimeID, environment.RuntimeCleanupNetworkID
if bindingVersion == 0 {
bindingVersion, runtimeID, networkID = environment.BindingVersion, environment.RuntimeID, environment.RuntimeNetworkID
}
return map[string]any{"binding_version": bindingVersion, "runtime_id": runtimeID, "network_id": networkID}
}
func runtimeCleanupGeneration(environment hub.EnvironmentContext, bindingVersion int64, runtimeID string, networkIDs ...string) hub.EnvironmentContext {
environment.RuntimeCleanupBindingVersion = bindingVersion
environment.RuntimeCleanupInstanceID = environment.RuntimeInstanceID
environment.RuntimeCleanupRuntimeID = runtimeID
environment.RuntimeCleanupNetworkID = environment.RuntimeNetworkID
if len(networkIDs) == 1 {
environment.RuntimeCleanupNetworkID = networkIDs[0]
}
return environment
}
func gatewayNetworkID(body []byte) string {
var envelope struct {
NetworkID string `json:"network_id"`
}
if json.Unmarshal(body, &envelope) == nil && gatewayGenerationIDPattern.MatchString(envelope.NetworkID) {
return envelope.NetworkID
}
return ""
}
func reconcileGatewayCreate(ctx context.Context, store runtimeCleanupStore, target hub.Gateway,
environment hub.EnvironmentContext, status int, callErr error, body []byte) error {
// 网关 4xx 拒绝且未产生 network 代:创建在网关侧任何 Docker 变更之前就被确定性拒绝,
// 无需清理 fence;其余情况(5xx、断连、无效 201)结果未知,仍走 fail-closed。
deterministicRejection := callErr == nil && status >= 400 && status < 500
logEntry := logrus.WithFields(logrus.Fields{
"service": "control-plane",
"event_type": "runtime_create_reconciliation",
"alias": environment.Alias,
"gateway": target.Name,
"binding_version": environment.BindingVersion,
"gateway_status": status,
"network_id_known": gatewayNetworkID(body) != "",
"deterministic": deterministicRejection,
})
if callErr != nil {
logEntry = logEntry.WithError(callErr)
}
if deterministicRejection {
logEntry.Info("gateway rejected runtime create before Docker side effects")
} else {
logEntry.Warn("gateway runtime create result requires reconciliation")
}
return reconcileGatewayCreateGeneration(ctx, store, target, environment, environment.BindingVersion,
gatewayNetworkID(body), deterministicRejection)
}
func reconcileGatewayCreateGeneration(ctx context.Context, store runtimeCleanupStore, target hub.Gateway,
environment hub.EnvironmentContext, bindingVersion int64, networkID string, deterministicRejection bool) error {
if networkID == "" {
if deterministicRejection {
// 网关明确拒绝:创建未发生、无副作用,不写 pending,让调用方透传真实失败原因,
// 绑定保持可重试(否则会把可判定的失败写成永远无法自动清理的“未知代”毒状态)。
return nil
}
pending := runtimeCleanupGeneration(environment, bindingVersion, missingRuntimeID, "")
return errors.Join(hub.ErrConflict, store.SetRuntimeCleanupPending(ctx, pending, true))
}
container, found, err := reconcileGatewayContainer(ctx, target, environment.Alias)
if err != nil {
runtimeID := missingRuntimeID
if bindingVersion == environment.BindingVersion && environment.RuntimeID != "" {
runtimeID = environment.RuntimeID
}
pending := runtimeCleanupGeneration(environment, bindingVersion, runtimeID, networkID)
return errors.Join(err, store.SetRuntimeCleanupPending(ctx, pending, true))
}
if found && (container.BindingVersion != bindingVersion || container.NetworkID != networkID) {
pending := runtimeCleanupGeneration(environment, bindingVersion, missingRuntimeID, networkID)
return errors.Join(hub.ErrConflict, store.SetRuntimeCleanupPending(ctx, pending, true))
}
if found {
environment = runtimeCleanupGeneration(environment, bindingVersion, container.ID, networkID)
} else {
environment = runtimeCleanupGeneration(environment, bindingVersion, missingRuntimeID, networkID)
}
_, cleanupErr := removeGatewayRuntime(ctx, store, target, environment)
return cleanupErr
}
// 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"`
NetworkID string `json:"network_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"`
AccountID string `json:"account_id"`
AccountStatus string `json:"account_status"`
AuthorizationStatus string `json:"authorization_status"`
NetworkExitID string `json:"network_exit_id"`
NetworkExitHealth string `json:"network_exit_health"`
BindingVersion int64 `json:"binding_version"`
RuntimeInstanceID string `json:"runtime_instance_id"`
ScheduleStatus string `json:"schedule_status"`
ScheduleBlockReason string `json:"schedule_block_reason,omitempty"`
RecoveryRequired bool `json:"recovery_required"`
CleanupPending bool `json:"cleanup_pending"`
}
func environmentScheduleReadiness(environment hub.EnvironmentContext) (string, string) {
switch {
case environment.AuthorizationStatus != "authorized":
return "blocked", "account_revoked"
case environment.AccountStatus != "active":
return "blocked", "account_paused"
case environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy":
return "blocked", "network_exit_unhealthy"
case environment.RuntimeCleanupPending:
return "blocked", "runtime_stop_pending"
case environment.RuntimeInstanceID == "":
return "blocked", "runtime_missing"
default:
return "ready", ""
}
}
func containerMatchesBinding(container containerStatus, environment hub.EnvironmentContext) bool {
if !validCreatedRuntime(container, environment, container.State == "running") || container.BindingVersion != environment.BindingVersion ||
(container.NetworkExitID != environment.Exit.ID && (container.State == "running" || container.NetworkExitID != "")) {
return false
}
return true
}
func validCreatedRuntime(created containerStatus, environment hub.EnvironmentContext, running bool) bool {
if !gatewayGenerationIDPattern.MatchString(created.ID) ||
(created.NetworkID != "" && !gatewayGenerationIDPattern.MatchString(created.NetworkID)) {
return false
}
if !running {
return true
}
if created.NetworkID == "" {
return false
}
if environment.RuntimeInstanceID != "" || environment.RuntimeID != "" {
return environment.RuntimeInstanceID != "" && environment.RuntimeID == created.ID &&
environment.RuntimeNetworkID != "" && created.NetworkID == environment.RuntimeNetworkID
}
return environment.RuntimeNetworkID == "" || created.NetworkID == environment.RuntimeNetworkID
}
func accountRunnable(environment hub.EnvironmentContext) bool {
return environment.AccountStatus == "active" && environment.AuthorizationStatus == "authorized"
}
func releaseRuntime(ctx context.Context, store runtimeStopStore, environment hub.EnvironmentContext) error {
err := store.ReleaseRuntime(ctx, environment)
if errors.Is(err, hub.ErrConflict) {
return nil
}
return err
}
func releaseRuntimeWithReconcileAudit(ctx context.Context, store hubStore, environment hub.EnvironmentContext) error {
action := actionForEnvironment("reconcile", environment)
if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", action); err != nil {
return err
}
releaseErr := releaseRuntime(ctx, store, environment)
action.Outcome, action.ReasonCode = "succeeded", "runtime_released"
if releaseErr != nil {
action.Outcome, action.ReasonCode = "failed", "runtime_release_failed"
}
if auditErr := store.AppendEnvironmentAction(ctx, "environment_action_finished", action); auditErr != nil {
return errors.Join(releaseErr, auditErr)
}
return releaseErr
}
func registerHub(app *fiber.App, store hubStore) {
registerHubWithNetwork(app, store, defaultNetworkExitProbe(), nil)
}
func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) {
app.Get("/api/browsers", listBrowsers(store))
app.Get("/api/browsers/:alias", getBrowser(store))
app.Post("/api/browsers", lockBrowserCreate(store, createBrowser(store, probe, resolve)))
app.Post("/api/browsers/:alias/:action", lockBrowserAlias(store, browserAction(store, probe, resolve)))
app.Delete("/api/browsers/:alias", lockBrowserAlias(store, deleteBrowser(store)))
app.Get("/api/network-exits", listNetworkExits(store))
app.Get("/api/network-exits/:id", getNetworkExit(store))
app.Post("/api/network-exits", createNetworkExit(store))
app.Post("/api/network-exits/:id/check", checkNetworkExit(store, probe))
app.Post("/api/network-exits/:id/disable", 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", lockBrowserImage(store, 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", lockBrowserImage(store, 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.Put("/api/gateways/:name", 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.UpdateGateway(c.Context(), c.Params("name"), input.Name, input.Endpoint, input.Token)
if err != nil {
return hubError(c, err)
}
return c.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 getBrowser(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)
}
return c.JSON(environment)
}
}
func lockBrowserAlias(store hubStore, handler fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
requestedExitID, imageVersion := "", ""
if c.Params("action") == "rebind" {
var input struct {
NetworkExitID string `json:"network_exit_id"`
}
if json.Unmarshal(c.Body(), &input) == nil {
if hub.ValidNetworkExitID(input.NetworkExitID) {
requestedExitID = input.NetworkExitID
}
}
}
if c.Params("action") == "upgrade" {
var input struct {
Version string `json:"version"`
}
if json.Unmarshal(c.Body(), &input) == nil {
if hub.ValidImageVersion(input.Version) {
imageVersion = input.Version
}
}
}
alias := c.Params("alias")
unlock, err := lockAliasResources(c.Context(), store, alias, requestedExitID, imageVersion)
if err != nil {
return hubError(c, err)
}
defer unlock()
return handler(c)
}
}
func lockBrowserImage(store hubStore, handler fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
unlock, err := store.LockResources(c.Context(), nil, nil, []string{c.Params("version")})
if err != nil {
return hubError(c, err)
}
defer unlock()
return handler(c)
}
}
func lockBrowserCreate(store hubStore, handler fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
var input struct {
Alias string `json:"alias"`
AccountID string `json:"account_id"`
NetworkExitID string `json:"network_exit_id"`
ImageVersion string `json:"image_version"`
}
if json.Unmarshal(c.Body(), &input) != nil || input.Alias == "" {
return handler(c)
}
exitIDs, imageVersions := []string(nil), []string(nil)
if hub.ValidNetworkExitID(input.NetworkExitID) {
exitIDs = []string{input.NetworkExitID}
}
if hub.ValidImageVersion(input.ImageVersion) {
imageVersions = []string{input.ImageVersion}
}
unlock, err := store.LockResources(c.Context(), nonEmpty(input.Alias, input.AccountID), exitIDs, imageVersions)
if err != nil {
return hubError(c, err)
}
defer unlock()
return handler(c)
}
}
func lockEnvironmentResources(ctx context.Context, store hubStore, envs []hub.Env) (func(), error) {
aliases := make([]string, 0, len(envs))
seen := make(map[string]bool, len(envs))
for _, env := range envs {
if !seen[env.Alias] {
seen[env.Alias] = true
aliases = append(aliases, env.Alias)
}
}
for {
before, err := environmentResourceMap(ctx, store, aliases)
if err != nil {
return nil, err
}
exitIDs, imageVersions := environmentResourceValues(before)
unlock, err := store.LockResources(ctx, aliases, exitIDs, imageVersions)
if err != nil {
return nil, err
}
after, err := environmentResourceMap(ctx, store, aliases)
if err != nil {
unlock()
return nil, err
}
if equalEnvironmentResourceMaps(before, after) {
return unlock, nil
}
unlock()
}
}
type environmentResource struct {
alias string
exitID string
imageVersion string
}
type resourceLockStore interface {
LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error)
GetEnvironmentContext(ctx context.Context, alias string) (hub.EnvironmentContext, error)
}
func lockAliasResources(ctx context.Context, store resourceLockStore, alias, requestedExitID, requestedImageVersion string) (func(), error) {
for {
before, beforeFound, err := environmentResources(ctx, store, alias)
if err != nil {
return nil, err
}
unlock, err := store.LockResources(ctx, []string{alias}, nonEmpty(before.exitID, requestedExitID), nonEmpty(before.imageVersion, requestedImageVersion))
if err != nil {
return nil, err
}
after, afterFound, err := environmentResources(ctx, store, alias)
if err != nil {
unlock()
return nil, err
}
if beforeFound == afterFound && before == after {
return unlock, nil
}
unlock()
}
}
func lockAccountResources(ctx context.Context, store runtimeStopStore, accountID string) (func(), error) {
if store == nil {
return func() {}, nil
}
for {
before, beforeFound, err := accountEnvironmentResources(ctx, store, accountID)
if err != nil {
return nil, err
}
unlock, err := store.LockResources(ctx, nonEmpty(accountID, before.alias), nonEmpty(before.exitID), nonEmpty(before.imageVersion))
if err != nil {
return nil, err
}
after, afterFound, err := accountEnvironmentResources(ctx, store, accountID)
if err != nil {
unlock()
return nil, err
}
if beforeFound == afterFound && before == after {
return unlock, nil
}
unlock()
}
}
func accountEnvironmentResources(ctx context.Context, store runtimeStopStore, accountID string) (environmentResource, bool, error) {
environment, err := store.GetEnvironmentContextForAccount(ctx, accountID)
if errors.Is(err, hub.ErrNotFound) {
return environmentResource{}, false, nil
}
if err != nil {
return environmentResource{}, false, err
}
return environmentResource{alias: environment.Alias, exitID: environment.Exit.ID, imageVersion: environment.ImageVersion}, true, nil
}
func environmentResources(ctx context.Context, store resourceLockStore, alias string) (environmentResource, bool, error) {
environment, err := store.GetEnvironmentContext(ctx, alias)
if errors.Is(err, hub.ErrNotFound) {
return environmentResource{}, false, nil
}
if err != nil {
return environmentResource{}, false, err
}
return environmentResource{alias: environment.Alias, exitID: environment.Exit.ID, imageVersion: environment.ImageVersion}, true, nil
}
func environmentResourceMap(ctx context.Context, store hubStore, aliases []string) (map[string]environmentResource, error) {
result := make(map[string]environmentResource, len(aliases))
for _, alias := range aliases {
resources, found, err := environmentResources(ctx, store, alias)
if err != nil {
return nil, err
}
if found {
result[alias] = resources
}
}
return result, nil
}
func nonEmpty(values ...string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
if value != "" && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result
}
func environmentResourceValues(values map[string]environmentResource) ([]string, []string) {
exitIDs, imageVersions := make([]string, 0, len(values)), make([]string, 0, len(values))
for _, value := range values {
exitIDs = append(exitIDs, value.exitID)
imageVersions = append(imageVersions, value.imageVersion)
}
return nonEmpty(exitIDs...), nonEmpty(imageVersions...)
}
func equalEnvironmentResourceMaps(left, right map[string]environmentResource) bool {
if len(left) != len(right) {
return false
}
for key, value := range left {
other, ok := right[key]
if !ok || other != value {
return false
}
}
return true
}
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 getNetworkExit(store hubStore) fiber.Handler {
return func(c fiber.Ctx) error {
exit, err := store.GetNetworkExit(c.Context(), c.Params("id"))
if err != nil {
return hubError(c, err)
}
return c.JSON(exit)
}
}
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"`
Username string `json:"username"`
Password string `json:"password"`
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,
Username: input.Username, Password: input.Password,
ExpectedPublicIP: input.ExpectedPublicIP, ExpectedRegion: input.ExpectedRegion,
})
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 {
unlock, err := store.LockResources(c.Context(), nil, []string{c.Params("id")}, nil)
if err != nil {
return hubError(c, err)
}
defer unlock()
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 {
unlock, err := store.LockResources(c.Context(), nil, []string{c.Params("id")}, nil)
if err != nil {
return hubError(c, err)
}
exit, err := store.DisableNetworkExit(c.Context(), c.Params("id"))
unlock()
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) fiber.Handler {
return func(c fiber.Ctx) error {
envs, err := store.ListEnvs(c.Context())
if err != nil {
return hubError(c, err)
}
views := make([]envView, 0, len(envs))
for _, env := range envs {
view := envView{Env: env, State: "missing", Status: "未记录运行实例", ScheduleStatus: "blocked", ScheduleBlockReason: "binding_missing", RecoveryRequired: true}
if environment, contextErr := store.GetEnvironmentContext(c.Context(), env.Alias); contextErr == nil {
view.AccountID, view.AccountStatus, view.AuthorizationStatus = environment.AccountID, environment.AccountStatus, environment.AuthorizationStatus
view.NetworkExitID, view.NetworkExitHealth, view.BindingVersion = environment.Exit.ID, environment.Exit.HealthStatus, environment.BindingVersion
view.RuntimeInstanceID = environment.RuntimeInstanceID
view.ContainerID = environment.RuntimeID
view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment)
view.CleanupPending = environment.RuntimeCleanupPending
view.RecoveryRequired = (environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy") || environment.RuntimeCleanupPending
if environment.RuntimeInstanceID != "" {
view.State, view.Status = "running", "已记录运行实例"
}
} else if !errors.Is(contextErr, hub.ErrNotFound) {
return hubError(c, contextErr)
}
views = append(views, view)
}
return c.JSON(views)
}
}
func reconcileRuntimeLeases(ctx context.Context, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) error {
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
}
unlock, err := lockEnvironmentResources(ctx, store, envs)
if err != nil {
return err
}
defer unlock()
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 !accountRunnable(environment) {
if found && container.State == "running" {
if err := stopEnvironmentRuntime(ctx, store, environment); err != nil {
return err
}
} else if gatewayRead[env.Gateway] && environment.RuntimeInstanceID != "" {
if err := releaseRuntimeWithReconcileAudit(ctx, store, environment); err != nil {
return err
}
}
continue
}
if found && container.State == "running" {
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 {
current, contextErr := store.GetEnvironmentContext(ctx, environment.Alias)
if contextErr == nil {
action.RuntimeInstanceID, action.BindingVersion, action.NetworkExitID =
current.RuntimeInstanceID, current.BindingVersion, current.Exit.ID
} else {
action.RuntimeInstanceID = ""
action.BindingVersion = 0
action.NetworkExitID = ""
restoreErr = errors.Join(restoreErr, contextErr)
}
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] && environment.RuntimeInstanceID != "" {
if err := releaseRuntimeWithReconcileAudit(ctx, store, environment); 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,
_ 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
}
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 {
return false, err
}
if containerMatchesBinding(container, environment) && container.ProxyReady {
_, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID)
return err == nil, err
}
networkExit := gatewayNetworkExit{}
if environment.Exit.ID != "" {
networkExit = gatewayNetworkExitFor(access)
}
if containerMatchesBinding(container, environment) {
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)
return err == nil, err
}
}
_, removeErr := removeGatewayRuntime(ctx, store, target, environment)
if removeErr != nil {
return false, removeErr
}
environment, err = store.GetEnvironmentContext(ctx, environment.Alias)
if err != nil {
return false, err
}
imageRef, err := store.ImageRef(ctx, environment.ImageVersion)
if err != nil {
return false, err
}
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers",
gatewayCreatePayloadForAccount(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil {
return false, errors.Join(gatewayUnreachable(callErr), reconcileGatewayCreate(ctx, store, target, environment, status, callErr, body))
}
if status != http.StatusCreated {
return false, errors.Join(gatewayRejected(status, body), reconcileGatewayCreate(ctx, store, target, environment, status, callErr, body))
}
var created containerStatus
if json.Unmarshal(body, &created) != nil || !validCreatedRuntime(created, environment, true) {
return false, errors.Join(errors.New("gateway returned an invalid runtime generation"),
reconcileGatewayCreate(ctx, store, target, environment, status, callErr, body))
}
_, err = activateGatewayRuntime(ctx, store, target, environment, created.ID, created.NetworkID)
return err == nil, err
}
func activateGatewayRuntime(ctx context.Context, store hubStore, target hub.Gateway, environment hub.EnvironmentContext, runtimeID, networkID string) (hub.EnvironmentContext, error) {
if !validCreatedRuntime(containerStatus{ID: runtimeID, NetworkID: networkID}, environment, true) {
return hub.EnvironmentContext{}, hub.ErrConflict
}
current, err := store.ActivateRuntime(ctx, environment.Alias, runtimeID, environment.BindingVersion, environment.Exit.ID, networkID)
if err == nil {
return current, nil
}
cleanup := environment
cleanup.RuntimeID, cleanup.RuntimeNetworkID = runtimeID, networkID
_, cleanupErr := removeGatewayRuntime(ctx, store, target, cleanup)
return hub.EnvironmentContext{}, errors.Join(err, cleanupErr)
}
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()})
}
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, action.NetworkExitID = current.BindingVersion, current.Exit.ID
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
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 {
_ = 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)
}
}
running := accountRunnable(environment)
imageRef, err := store.ImageRef(c.Context(), env.ImageVersion)
if err != nil {
_ = finish("failed", "image_unavailable", environment)
return hubError(c, err)
}
networkExit := gatewayNetworkExit{}
if input.NetworkExitID != "" {
networkExit = gatewayNetworkExitFor(access)
}
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 && !running {
if !containerMatchesBinding(container, environment) {
_ = finish("failed", "runtime_unavailable", environment)
return hubError(c, hub.ErrConflict)
}
if container.State == "running" {
stopped := environment
stopped.RuntimeID, stopped.RuntimeNetworkID = container.ID, container.NetworkID
if err := stopEnvironmentRuntime(c.Context(), store, stopped); err != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
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})
}
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",
gatewayCreatePayloadForAccount(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil || status != http.StatusCreated {
createErr := gatewayRejected(status, body)
if callErr != nil {
createErr = gatewayUnreachable(callErr)
}
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(createErr, reconcileErr))
}
_ = finish("failed", "gateway_create_failed", environment)
return hubError(c, createErr)
}
var createdRuntime containerStatus
if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway,
message: "gateway create result unknown; environment retained for reconciliation"}, reconcileErr))
}
if !running {
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})
}
environment, err = activateGatewayRuntime(c.Context(), store, gateway, environment, createdRuntime.ID, createdRuntime.NetworkID)
if err != nil {
_ = finish("failed", "runtime_persistence_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "environment_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"))
if actionName == "start" {
if !accountRunnable(environment) {
return hubError(c, hub.ErrConflict)
}
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, action.NetworkExitID = current.BindingVersion, current.Exit.ID
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
return startBrowser(store, probe, resolve, c, environment, finish)
}
if err := stopEnvironmentRuntime(c.Context(), store, environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func stopAccountRuntime(ctx context.Context, store runtimeStopStore, accountID string) error {
environment, err := store.GetEnvironmentContextForAccount(ctx, accountID)
if errors.Is(err, hub.ErrNotFound) {
return nil
}
if err != nil {
return err
}
return stopEnvironmentRuntime(ctx, store, environment)
}
func stopEnvironmentRuntime(ctx context.Context, store runtimeStopStore, environment hub.EnvironmentContext) error {
action := actionForEnvironment("stop", environment)
if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", action); err != nil {
return err
}
finish := func(outcome, reason string) error {
action.Outcome, action.ReasonCode = outcome, reason
return store.AppendEnvironmentAction(ctx, "environment_action_finished", action)
}
gateway, err := store.GetGateway(ctx, environment.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable")
return err
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(ctx, store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown")
return cleanupErr
}
return finish("succeeded", "environment_stopped")
}
if environment.RuntimeID == "" {
container, found, reconcileErr := reconcileGatewayContainer(ctx, gateway, environment.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown")
return gatewayUnreachable(reconcileErr)
}
if !found {
return finish("succeeded", "environment_stopped")
}
if container.BindingVersion != environment.BindingVersion {
_ = finish("failed", "runtime_generation_conflict")
return hub.ErrConflict
}
environment = runtimeCleanupGeneration(environment, environment.BindingVersion, container.ID, container.NetworkID)
} else {
environment = runtimeCleanupGeneration(environment, environment.BindingVersion, environment.RuntimeID)
}
if err := store.SetRuntimeCleanupPending(ctx, environment, true); err != nil {
_ = finish("failed", "runtime_release_failed")
return err
}
status, body, callErr := gatewayCall(ctx, gateway, http.MethodPost, "/v1/browsers/"+environment.Alias+"/stop",
gatewayCleanupGenerationPayload(environment), 30*time.Second)
if callErr != nil || status >= http.StatusInternalServerError {
container, found, reconcileErr := reconcileGatewayContainer(ctx, gateway, environment.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown")
if callErr != nil {
return gatewayUnreachable(callErr)
}
return gatewayRejected(status, body)
}
if !found || container.State != "running" {
if err := store.SetRuntimeCleanupPending(ctx, environment, false); err != nil {
_ = finish("failed", "runtime_release_failed")
return err
}
return finish("succeeded", "gateway_reconciled")
}
_ = finish("failed", "gateway_action_failed")
if callErr != nil {
return gatewayUnreachable(callErr)
}
return gatewayRejected(status, body)
}
if status != http.StatusNoContent && status != http.StatusNotModified && status != http.StatusNotFound {
_ = finish("failed", "gateway_rejected")
return gatewayRejected(status, body)
}
if err := store.SetRuntimeCleanupPending(ctx, environment, false); err != nil {
_ = finish("failed", "runtime_release_failed")
return err
}
return finish("succeeded", "environment_stopped")
}
func startBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx,
environment hub.EnvironmentContext, finish func(string, string, hub.EnvironmentContext) error) error {
if !accountRunnable(environment) {
return hubError(c, hub.ErrConflict)
}
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)
}
}
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)
}
if !accountRunnable(environment) {
return hubError(c, hub.ErrConflict)
}
}
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 := gatewayNetworkExit{}
if environment.Exit.ID != "" {
networkExit = gatewayNetworkExitFor(access)
}
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 = activateGatewayRuntime(c.Context(), store, gateway, environment, container.ID, container.NetworkID)
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 := releaseRuntime(c.Context(), store, environment); 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.StatusCreated {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr))
}
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
}
var created containerStatus
if json.Unmarshal(body, &created) != nil || !validCreatedRuntime(created, environment, true) {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr))
}
environment, err = activateGatewayRuntime(c.Context(), store, gateway, environment, created.ID, created.NetworkID)
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, _ 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, action.NetworkExitID = current.BindingVersion, current.Exit.ID
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
access, reason, err := verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID)
if err != nil {
_ = finish("failed", reason, environment)
return hubError(c, err)
}
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", environment)
return hubError(c, err)
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
}
environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias)
if err != nil {
return hubError(c, err)
}
}
running := accountRunnable(environment)
networkExit := gatewayNetworkExitFor(access)
// 先删容器(保留卷);404 视为已删除,保证升级可重试。
if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, removeErr)
}
if err := store.UpgradeEnv(c.Context(), environment.Alias, input.Version); err != nil {
_ = finish("failed", "persistence_failed", environment)
return hubError(c, err)
}
environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias)
if err != nil {
_ = finish("failed", "persistence_failed", environment)
return hubError(c, err)
}
running = accountRunnable(environment)
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers",
gatewayCreatePayloadForAccount(environment, imageRef, networkExit), gatewayLongTimeout)
var createdRuntime containerStatus
if callErr != nil || status != http.StatusCreated {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"}, reconcileErr))
}
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
}
if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body)
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"}, reconcileErr))
}
if !running {
if err := finish("succeeded", "environment_upgraded", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
environment, err = activateGatewayRuntime(c.Context(), store, gateway, environment, createdRuntime.ID, createdRuntime.NetworkID)
if err != nil {
_ = finish("failed", "runtime_persistence_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "environment_upgraded", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
type runtimeCreateSpec struct {
imageRef string
networkExit gatewayNetworkExit
}
func prepareRuntimeCreate(ctx context.Context, store hubStore, _ 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 := gatewayNetworkExit{}
if environment.Exit.ID != "" {
networkExit = gatewayNetworkExitFor(access)
}
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",
gatewayCreatePayloadForAccount(environment, spec.imageRef, spec.networkExit), gatewayLongTimeout)
unknown := containerStatus{NetworkID: gatewayNetworkID(body)}
if callErr == nil && status == http.StatusCreated {
var created containerStatus
if json.Unmarshal(body, &created) == nil && validCreatedRuntime(created, environment, accountRunnable(environment)) {
created.Alias, created.BindingVersion = environment.Alias, environment.BindingVersion
if accountRunnable(environment) {
created.State, created.NetworkExitID, created.ProxyReady = "running", environment.Exit.ID, true
} else {
created.State = "exited"
}
return created, nil
}
}
if callErr != nil {
return unknown, gatewayUnreachable(callErr)
}
if status != http.StatusCreated {
return unknown, gatewayRejected(status, body)
}
return unknown, errors.New("gateway returned an invalid runtime generation")
}
func createStoppedGatewayRuntime(ctx context.Context, target hub.Gateway, environment hub.EnvironmentContext, imageRef string) error {
payload := gatewayCreatePayload(environment, imageRef, gatewayNetworkExit{})
payload["network_exit_id"], payload["stopped"] = "", true
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers", payload, gatewayLongTimeout)
if callErr == nil && status == http.StatusCreated {
var created containerStatus
if json.Unmarshal(body, &created) == nil && validCreatedRuntime(created, environment, false) {
return nil
}
return errors.New("gateway returned an invalid stopped runtime generation")
}
if callErr != nil {
return gatewayUnreachable(callErr)
}
if status != http.StatusCreated {
return gatewayRejected(status, body)
}
return errors.New("gateway did not preserve the stopped runtime")
}
func removeGatewayRuntime(ctx context.Context, store runtimeCleanupStore, target hub.Gateway, environment hub.EnvironmentContext) (bool, error) {
removed := environment.RuntimeCleanupPending
if environment.RuntimeCleanupPending {
if environment.RuntimeCleanupRuntimeID == missingRuntimeID && environment.RuntimeCleanupNetworkID == "" {
// 未知代 pending:创建结果未知时登记的 runtime-not-found 没有实物 ID 可供清理。
// 按别名反查网关:无容器则该代无实物,直接解除;有同版本容器则采用其实物代继续清理;
// 版本不符说明别名下已是别的代,维持人工对账。
if environment.RuntimeCleanupBindingVersion < 1 {
return false, hub.ErrReconcileRequired
}
discovered, found, discoverErr := reconcileGatewayContainer(ctx, target, environment.Alias)
if discoverErr != nil {
return false, discoverErr
}
if !found {
return true, store.SetRuntimeCleanupPending(ctx, environment, false)
}
if discovered.BindingVersion != environment.RuntimeCleanupBindingVersion {
return false, hub.ErrReconcileRequired
}
if err := store.SetRuntimeCleanupPending(ctx, environment, false); err != nil {
return false, err
}
environment = runtimeCleanupGeneration(environment, environment.RuntimeCleanupBindingVersion, discovered.ID, discovered.NetworkID)
if err := store.SetRuntimeCleanupPending(ctx, environment, true); err != nil {
return false, err
}
} else if environment.RuntimeCleanupBindingVersion < 1 || environment.RuntimeCleanupRuntimeID == "" {
return false, hub.ErrReconcileRequired
}
}
if !environment.RuntimeCleanupPending {
container, found, err := reconcileGatewayContainer(ctx, target, environment.Alias)
if err != nil {
return false, err
}
bindingVersion, runtimeID, networkID := environment.BindingVersion, environment.RuntimeID, environment.RuntimeNetworkID
if environment.RuntimeCleanupBindingVersion > 0 {
bindingVersion, runtimeID, networkID = environment.RuntimeCleanupBindingVersion,
environment.RuntimeCleanupRuntimeID, environment.RuntimeCleanupNetworkID
}
if found {
if container.BindingVersion != bindingVersion || runtimeID == missingRuntimeID ||
(runtimeID != "" && runtimeID != container.ID) || (runtimeID == "" && networkID == "" && container.State == "running") {
return false, hub.ErrConflict
}
runtimeID = container.ID
if networkID != "" {
if container.NetworkID != "" && container.NetworkID != networkID {
return false, hub.ErrConflict
}
} else {
networkID = container.NetworkID
}
} else if environment.RuntimeID == "" {
runtimeID = missingRuntimeID
}
environment = runtimeCleanupGeneration(environment, bindingVersion, runtimeID, networkID)
}
if !environment.RuntimeCleanupPending {
if err := store.SetRuntimeCleanupPending(ctx, environment, true); err != nil {
return false, err
}
}
for attempt := 0; attempt < 2; attempt++ {
status, body, callErr := gatewayCall(ctx, target, http.MethodDelete, "/v1/browsers/"+environment.Alias,
gatewayCleanupGenerationPayload(environment), 30*time.Second)
if callErr == nil && (status == http.StatusNoContent || status == http.StatusNotFound) {
return true, store.SetRuntimeCleanupPending(ctx, environment, 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, releaseRuntime(ctx, store, environment)
}
if environment.Exit.ID == "" {
if err := releaseRuntime(ctx, store, environment); 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 := releaseRuntime(ctx, store, environment); err != nil {
return false, err
}
cleanupContext, err := store.GetEnvironmentContext(ctx, environment.Alias)
if err != nil {
return false, err
}
created, err := createGatewayRuntime(ctx, target, environment, spec)
if err != nil {
cleanupErr := reconcileGatewayCreateGeneration(ctx, store, target, cleanupContext, environment.BindingVersion, created.NetworkID, false)
return false, errors.Join(err, cleanupErr)
}
if previous.State == "running" && accountRunnable(environment) {
_, err = activateGatewayRuntime(ctx, store, target, environment, created.ID, created.NetworkID)
} else if accountRunnable(environment) {
stopped := environment
stopped.RuntimeID, stopped.RuntimeNetworkID = created.ID, created.NetworkID
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/stop",
gatewayGenerationPayload(stopped), 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)
}
if !hub.ValidNetworkExitID(input.NetworkExitID) {
if err := finish("failed", "rebind_input_rejected", before); err != nil {
return hubError(c, err)
}
return hubError(c, hub.ErrInvalid)
}
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", "exit_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 {
cleanupContext, contextErr := store.GetEnvironmentContext(c.Context(), before.Alias)
if contextErr != nil {
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, errors.Join(err, contextErr))
}
cleanupErr := reconcileGatewayCreateGeneration(c.Context(), store, target, cleanupContext, candidate.BindingVersion, candidateRuntime.NetworkID, false)
if cleanupErr != nil {
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, errors.Join(err, cleanupErr))
}
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)
}
}
runtimeID := ""
if accountRunnable(candidate) {
runtimeID = candidateRuntime.ID
}
after, err := store.RebindEnvironment(c.Context(), before.Alias, input.NetworkExitID, runtimeID, before.BindingVersion, candidateRuntime.NetworkID)
if err != nil {
if candidateRuntime.ID != "" {
cleanup, contextErr := store.GetEnvironmentContext(c.Context(), before.Alias)
if contextErr != nil || cleanup.BindingVersion != before.BindingVersion || cleanup.Exit.ID != before.Exit.ID {
_ = finish("unknown", "rebind_recovery_failed", before)
return hubError(c, errors.Join(err, contextErr, hub.ErrConflict))
}
cleanup.RuntimeID, cleanup.RuntimeNetworkID = candidateRuntime.ID, candidateRuntime.NetworkID
_, cleanupErr := removeGatewayRuntime(c.Context(), store, target, cleanup)
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.ErrReconcileRequired):
status, message = fiber.StatusConflict, hub.ErrReconcileRequired.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})
}