2202 lines
84 KiB
Go
2202 lines
84 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(), "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 {
|
||
if environment.RuntimeCleanupBindingVersion > 0 {
|
||
return map[string]any{"binding_version": environment.RuntimeCleanupBindingVersion, "runtime_id": environment.RuntimeCleanupRuntimeID,
|
||
"network_id": environment.RuntimeCleanupNetworkID}
|
||
}
|
||
return map[string]any{"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID, "network_id": environment.RuntimeNetworkID}
|
||
}
|
||
|
||
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)}
|
||
}
|
||
|
||
// gatewayStatus 在网关基础信息上补充实时探测结果:connectivity 探测 /healthz(无认证,
|
||
// 收到任意 HTTP 响应即在线),health 探测 /v1/browsers(Bearer 认证,200 且容器列表可
|
||
// 解析为健康)。失败原因原样返回给页面,不吞掉。
|
||
type gatewayStatus struct {
|
||
hub.Gateway
|
||
Connectivity string `json:"connectivity"` // online | offline
|
||
ConnectivityReason string `json:"connectivity_reason,omitempty"` // 仅离线时携带
|
||
Health string `json:"health"` // healthy | unhealthy
|
||
HealthReason string `json:"health_reason,omitempty"` // 仅异常时携带
|
||
}
|
||
|
||
const gatewayHealthzTimeout = 5 * time.Second
|
||
|
||
// gatewayStatusList 并发探测全部网关;单个网关失败不影响其余结果与接口返回。
|
||
// 结果经由带缓冲 channel 回收,由收集协程独占写入,避免共享写。
|
||
func gatewayStatusList(ctx context.Context, gateways []hub.Gateway) []gatewayStatus {
|
||
results := make(chan struct {
|
||
index int
|
||
status gatewayStatus
|
||
}, len(gateways))
|
||
for index, target := range gateways {
|
||
go func(index int, target hub.Gateway) {
|
||
results <- struct {
|
||
index int
|
||
status gatewayStatus
|
||
}{index, probeGatewayStatus(ctx, target)}
|
||
}(index, target)
|
||
}
|
||
statuses := make([]gatewayStatus, len(gateways))
|
||
for range gateways {
|
||
result := <-results
|
||
statuses[result.index] = result.status
|
||
}
|
||
return statuses
|
||
}
|
||
|
||
func probeGatewayStatus(ctx context.Context, target hub.Gateway) gatewayStatus {
|
||
status := gatewayStatus{Gateway: target, Connectivity: "offline", Health: "unhealthy"}
|
||
probeCtx, cancel := context.WithTimeout(ctx, gatewayHealthzTimeout)
|
||
defer cancel()
|
||
request, requestErr := http.NewRequestWithContext(probeCtx, http.MethodGet, target.Endpoint+"/healthz", nil)
|
||
if requestErr != nil {
|
||
status.ConnectivityReason = requestErr.Error()
|
||
} else if response, callErr := http.DefaultClient.Do(request); callErr != nil {
|
||
status.ConnectivityReason = callErr.Error()
|
||
} else {
|
||
_ = response.Body.Close()
|
||
status.Connectivity = "online"
|
||
}
|
||
code, body, callErr := gatewayCall(ctx, target, http.MethodGet, "/v1/browsers", nil, 30*time.Second)
|
||
switch {
|
||
case callErr != nil:
|
||
status.HealthReason = callErr.Error()
|
||
case code != http.StatusOK:
|
||
status.HealthReason = fmt.Sprintf("gateway call failed with status %d", code)
|
||
default:
|
||
if _, parseErr := parseGatewayBrowserList(body); parseErr != nil {
|
||
status.HealthReason = "gateway returned an invalid browser list"
|
||
} else {
|
||
status.Health = "healthy"
|
||
}
|
||
}
|
||
return status
|
||
}
|
||
|
||
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, probe, resolve))
|
||
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(gatewayStatusList(c.Context(), 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, 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)
|
||
}
|
||
unlock, err := lockEnvironmentResources(c.Context(), store, envs)
|
||
if err != nil {
|
||
return hubError(c, err)
|
||
}
|
||
defer unlock()
|
||
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: "网关上不存在容器", 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.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment)
|
||
view.CleanupPending = environment.RuntimeCleanupPending
|
||
view.RecoveryRequired = (environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy") || 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 {
|
||
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",
|
||
gatewayGenerationPayload(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 &&
|
||
(environment.RuntimeCleanupBindingVersion < 1 || environment.RuntimeCleanupRuntimeID == "" ||
|
||
(environment.RuntimeCleanupRuntimeID == missingRuntimeID && environment.RuntimeCleanupNetworkID == "")) {
|
||
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,
|
||
gatewayGenerationPayload(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})
|
||
}
|