HH-802: enforce scheduling gates and runtime leases (#20)

This commit is contained in:
2026-08-29 21:44:23 +08:00
parent f1ccfa0096
commit 9fc8e44123
19 changed files with 4851 additions and 755 deletions
+458 -197
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -39,20 +40,36 @@ type hubStore interface {
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) (hub.EnvironmentContext, error)
ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string) (hub.EnvironmentContext, error)
ReleaseRuntime(ctx context.Context, alias string) error
SetRuntimeCleanupPending(ctx context.Context, alias string, pending bool) error
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 {
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)
@@ -99,14 +116,86 @@ func gatewayCreatePayload(environment hub.EnvironmentContext, imageRef string, n
}
}
func gatewayProxyPayload(environment hub.EnvironmentContext, networkExit gatewayNetworkExit) map[string]any {
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, body []byte) error {
return reconcileGatewayCreateGeneration(ctx, store, target, environment, environment.BindingVersion, gatewayNetworkID(body))
}
func reconcileGatewayCreateGeneration(ctx context.Context, store runtimeCleanupStore, target hub.Gateway,
environment hub.EnvironmentContext, bindingVersion int64, networkID string) error {
if networkID == "" {
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
@@ -200,6 +289,7 @@ type containerStatus struct {
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"`
}
@@ -216,7 +306,41 @@ type envView struct {
}
func containerMatchesBinding(container containerStatus, environment hub.EnvironmentContext) bool {
return container.BindingVersion == environment.BindingVersion && container.NetworkExitID == environment.Exit.ID
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 registerHub(app *fiber.App, store hubStore) {
@@ -528,6 +652,18 @@ func reconcileRuntimeSnapshot(ctx context.Context, store hubStore, probe network
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] {
if err := releaseRuntime(ctx, store, environment); err != nil {
return err
}
}
continue
}
if found && container.State == "running" {
if environment.Exit.ID == "" {
continue
@@ -541,6 +677,16 @@ func reconcileRuntimeSnapshot(ctx context.Context, store hubStore, probe network
}
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"
@@ -556,7 +702,7 @@ func reconcileRuntimeSnapshot(ctx context.Context, store hubStore, probe network
return restoreErr
}
} else if gatewayRead[env.Gateway] {
if err := store.ReleaseRuntime(ctx, env.Alias); err != nil {
if err := releaseRuntime(ctx, store, environment); err != nil {
return err
}
}
@@ -594,7 +740,7 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
return false, err
}
if containerMatchesBinding(container, environment) && container.ProxyReady {
_, err := store.ActivateRuntime(ctx, environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
_, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID)
return err == nil, err
}
networkExit, err := gatewayNetworkExitFor(access, resolve)
@@ -603,12 +749,12 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
}
if containerMatchesBinding(container, environment) {
status, _, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/proxy",
gatewayProxyPayload(environment, networkExit), 30*time.Second)
gatewayProxyPayload(environment, container.ID, container.NetworkID, networkExit), 30*time.Second)
if callErr == nil && status == http.StatusNoContent {
container.ProxyReady = true
}
if container.ProxyReady {
_, err := store.ActivateRuntime(ctx, environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
_, err := activateGatewayRuntime(ctx, store, target, environment, container.ID, container.NetworkID)
return err == nil, err
}
}
@@ -616,28 +762,45 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
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",
gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
gatewayCreatePayloadForAccount(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil {
return false, gatewayUnreachable(callErr)
return false, errors.Join(gatewayUnreachable(callErr), reconcileGatewayCreate(ctx, store, target, environment, body))
}
if status != http.StatusCreated {
return false, gatewayRejected(status, body)
return false, errors.Join(gatewayRejected(status, body), reconcileGatewayCreate(ctx, store, target, environment, body))
}
var created struct {
ID string `json:"id"`
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, body))
}
if json.Unmarshal(body, &created) != nil || created.ID == "" {
return false, errors.New("gateway returned an invalid runtime id")
}
_, err = store.ActivateRuntime(ctx, environment.Alias, created.ID, environment.BindingVersion, environment.Exit.ID)
_, 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 {
@@ -686,7 +849,7 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID
action.BindingVersion = current.BindingVersion
action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
access, reason, err := verifyNetworkExit(c.Context(), store, probe, input.NetworkExitID)
@@ -709,6 +872,7 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
return hubError(c, err)
}
}
running := accountRunnable(environment)
imageRef, err := store.ImageRef(c.Context(), env.ImageVersion)
if err != nil {
_ = finish("failed", "image_unavailable", environment)
@@ -725,6 +889,24 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
_ = 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 {
@@ -746,51 +928,37 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
return c.JSON(map[string]string{"alias": env.Alias})
}
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
if callErr != nil || status >= http.StatusInternalServerError {
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)
}
container, exists, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias)
if reconcileErr == nil && exists && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) {
environment, err = store.ActivateRuntime(c.Context(), env.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_ = finish("failed", "runtime_persistence_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "gateway_reconciled", environment); err != nil {
return hubError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(map[string]string{"alias": env.Alias})
}
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf(
"gateway create result unknown; environment retained for reconciliation: %v", createErr)})
return hubError(c, errors.Join(createErr, reconcileErr))
}
_ = finish("failed", "gateway_create_failed", environment)
return hubError(c, createErr)
}
if status != http.StatusCreated {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
var createdRuntime struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &createdRuntime) != nil || createdRuntime.ID == "" {
var createdRuntime containerStatus
if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway create result unknown; environment retained for reconciliation"})
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway,
message: "gateway create result unknown; environment retained for reconciliation"}, reconcileErr))
}
environment, err = store.ActivateRuntime(c.Context(), env.Alias, createdRuntime.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment)
if cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
} else {
_ = finish("failed", "runtime_persistence_failed", environment)
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 {
@@ -821,69 +989,120 @@ func lifecycleAction(store hubStore, probe networkExitProbe, resolve func(hub.Ne
return hubError(c, err)
}
actionName := strings.Clone(c.Params("action"))
action := actionForEnvironment(actionName, environment)
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID
action.BindingVersion = current.BindingVersion
return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action)
}
if actionName == "start" {
return startBrowser(store, probe, resolve, c, environment, finish)
}
gateway, err := store.GetGateway(c.Context(), environment.Gateway)
if err != nil {
_ = finish("failed", "gateway_unavailable", environment)
return hubError(c, err)
}
if environment.RuntimeCleanupPending {
if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
return hubError(c, cleanupErr)
if !accountRunnable(environment) {
return hubError(c, hub.ErrConflict)
}
if err := finish("succeeded", "environment_stopped", environment); err != nil {
action := actionForEnvironment(actionName, environment)
if err := store.AppendEnvironmentAction(c.Context(), "environment_action_requested", action); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers/"+environment.Alias+"/"+actionName, nil, 30*time.Second)
if callErr != nil {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayUnreachable(callErr))
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)
}
if !found || container.State != "running" {
if err := store.ReleaseRuntime(c.Context(), environment.Alias); err != nil {
_ = finish("failed", "runtime_release_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "gateway_reconciled", environment); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
}
_ = finish("failed", "gateway_action_failed", environment)
return hubError(c, gatewayUnreachable(callErr))
return startBrowser(store, probe, resolve, c, environment, finish)
}
if status != http.StatusNoContent && status != http.StatusNotModified {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
if err := store.ReleaseRuntime(c.Context(), environment.Alias); err != nil {
_ = finish("failed", "runtime_release_failed", environment)
return hubError(c, err)
}
if err := finish("succeeded", "environment_stopped", environment); err != nil {
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, resolve 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)
}
access, reason, err := verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID)
if err != nil {
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
@@ -907,6 +1126,9 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
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 {
@@ -932,7 +1154,7 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
return hubError(c, gatewayUnreachable(err))
}
if found && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) {
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
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)
@@ -947,40 +1169,31 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, removeErr)
}
} else if err := store.ReleaseRuntime(c.Context(), environment.Alias); err != nil {
} 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.StatusInternalServerError {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
if callErr != nil || status != http.StatusCreated {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"})
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr))
}
if !found || container.State != "running" || !container.ProxyReady || !containerMatchesBinding(container, environment) {
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, container.ID, environment.BindingVersion, environment.Exit.ID)
} else {
if status != http.StatusCreated {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
var created struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &created) != nil || created.ID == "" {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"})
}
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, created.ID, environment.BindingVersion, environment.Exit.ID)
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, 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)
@@ -1028,7 +1241,7 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Net
}
finish := func(outcome, reason string, current hub.EnvironmentContext) error {
action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID
action.BindingVersion = current.BindingVersion
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)
@@ -1051,6 +1264,7 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Net
return hubError(c, err)
}
}
running := accountRunnable(environment)
networkExit, err := gatewayNetworkExitFor(access, resolve)
if err != nil {
_ = finish("failed", "credential_unavailable", environment)
@@ -1070,42 +1284,36 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Net
_ = finish("failed", "persistence_failed", environment)
return hubError(c, err)
}
status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout)
var createdRuntime struct {
ID string `json:"id"`
}
if callErr != nil || status >= http.StatusInternalServerError {
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
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, body)
if reconcileErr != nil {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"})
return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"}, reconcileErr))
}
if !found || container.State != "running" || !container.ProxyReady || !containerMatchesBinding(container, environment) {
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
}
createdRuntime.ID = container.ID
} else {
if status != http.StatusCreated {
_ = finish("failed", "gateway_rejected", environment)
return hubError(c, gatewayRejected(status, body))
}
if json.Unmarshal(body, &createdRuntime) != nil || createdRuntime.ID == "" {
_ = finish("unknown", "gateway_result_unknown", environment)
return hubError(c, gatewayFailure{status: http.StatusBadGateway, message: "gateway upgrade result unknown; retry to reconcile"})
_ = finish("failed", "gateway_create_failed", environment)
if callErr != nil {
return hubError(c, gatewayUnreachable(callErr))
}
return hubError(c, gatewayRejected(status, body))
}
environment, err = store.ActivateRuntime(c.Context(), environment.Alias, createdRuntime.ID, environment.BindingVersion, environment.Exit.ID)
if err != nil {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment)
if cleanupErr != nil {
_ = finish("unknown", "cleanup_result_unknown", environment)
} else {
_ = finish("failed", "runtime_persistence_failed", environment)
if json.Unmarshal(body, &createdRuntime) != nil || !validCreatedRuntime(createdRuntime, environment, running) {
reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, 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 {
@@ -1134,41 +1342,39 @@ func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub.
func createGatewayRuntime(ctx context.Context, target hub.Gateway, environment hub.EnvironmentContext, spec runtimeCreateSpec) (containerStatus, error) {
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers",
gatewayCreatePayload(environment, spec.imageRef, spec.networkExit), gatewayLongTimeout)
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 && created.ID != "" {
created.Alias, created.State = environment.Alias, "running"
created.BindingVersion, created.NetworkExitID, created.ProxyReady = environment.BindingVersion, environment.Exit.ID, true
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
}
}
container, found, reconcileErr := reconcileGatewayContainer(ctx, target, environment.Alias)
if reconcileErr == nil && found && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) {
return container, nil
}
if callErr != nil {
return containerStatus{}, gatewayUnreachable(callErr)
return unknown, gatewayUnreachable(callErr)
}
if status != http.StatusCreated {
return containerStatus{}, gatewayRejected(status, body)
return unknown, gatewayRejected(status, body)
}
if reconcileErr != nil {
return containerStatus{}, gatewayUnreachable(reconcileErr)
}
return containerStatus{}, errors.New("gateway returned an invalid runtime id")
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["stopped"] = true
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 {
return nil
}
container, found, reconcileErr := reconcileGatewayContainer(ctx, target, environment.Alias)
if reconcileErr == nil && found && container.State != "running" && containerMatchesBinding(container, environment) {
return nil
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)
@@ -1176,23 +1382,54 @@ func createStoppedGatewayRuntime(ctx context.Context, target hub.Gateway, enviro
if status != http.StatusCreated {
return gatewayRejected(status, body)
}
if reconcileErr != nil {
return gatewayUnreachable(reconcileErr)
}
return errors.New("gateway did not preserve the stopped runtime")
}
func removeGatewayRuntime(ctx context.Context, store hubStore, target hub.Gateway, environment hub.EnvironmentContext) (bool, error) {
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 {
if err := store.SetRuntimeCleanupPending(ctx, environment.Alias, true); err != nil {
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, nil, 30*time.Second)
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.Alias, false)
return true, store.SetRuntimeCleanupPending(ctx, environment, false)
}
if callErr == nil && status == http.StatusAccepted {
removed = true
@@ -1226,10 +1463,10 @@ func removeGatewayRuntime(ctx context.Context, store hubStore, target hub.Gatewa
func restoreRebindRuntime(ctx context.Context, store hubStore, resolve func(hub.NetworkExitAccess) (string, error),
target hub.Gateway, environment hub.EnvironmentContext, previous containerStatus, found bool, prepared *runtimeCreateSpec) (bool, error) {
if !found {
return true, store.ReleaseRuntime(ctx, environment.Alias)
return true, releaseRuntime(ctx, store, environment)
}
if environment.Exit.ID == "" {
if err := store.ReleaseRuntime(ctx, environment.Alias); err != nil {
if err := releaseRuntime(ctx, store, environment); err != nil {
return false, err
}
if previous.State == "running" {
@@ -1264,18 +1501,25 @@ func restoreRebindRuntime(ctx context.Context, store hubStore, resolve func(hub.
return false, err
}
}
if err := store.ReleaseRuntime(ctx, environment.Alias); err != nil {
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 := removeGatewayRuntime(ctx, store, target, environment)
cleanupErr := reconcileGatewayCreateGeneration(ctx, store, target, cleanupContext, environment.BindingVersion, created.NetworkID)
return false, errors.Join(err, cleanupErr)
}
if previous.State == "running" {
_, err = store.ActivateRuntime(ctx, environment.Alias, created.ID, environment.BindingVersion, environment.Exit.ID)
} else {
status, body, callErr := gatewayCall(ctx, target, http.MethodPost, "/v1/browsers/"+environment.Alias+"/stop", nil, 30*time.Second)
if 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 {
@@ -1400,9 +1644,14 @@ func rebindBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
if wasRunning {
candidateRuntime, err = createGatewayRuntime(c.Context(), target, candidate, nextSpec)
if err != nil {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, target, before)
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)
if cleanupErr != nil {
_ = finish("unknown", "rebind_recovery_failed", before)
_ = finish("unknown", "gateway_result_unknown", before)
return hubError(c, errors.Join(err, cleanupErr))
}
current, contextErr := store.GetEnvironmentContext(c.Context(), before.Alias)
@@ -1419,10 +1668,20 @@ func rebindBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
return hubError(c, err)
}
}
after, err := store.RebindEnvironment(c.Context(), before.Alias, input.NetworkExitID, candidateRuntime.ID, before.BindingVersion)
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 != "" {
_, cleanupErr := removeGatewayRuntime(c.Context(), store, target, before)
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))
@@ -1513,6 +1772,8 @@ func hubError(c fiber.Ctx, err error) error {
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):
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -176,7 +176,7 @@ func newHandlerWithStores(webDirectory string, phaseAStore *phasea.Store, hubSto
registerHub(app, hubStore)
}
if phaseAStore != nil {
registerPhaseA(app, phaseAStore)
registerPhaseA(app, phaseAStore, hubStore)
}
app.Get("/*", spaHandler(webDirectory))
return app
+16 -18
View File
@@ -22,7 +22,7 @@ type accountRequest struct {
} `json:"credential_reference"`
}
func registerPhaseA(app *fiber.App, store *phasea.Store) {
func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeStopStore) {
app.Post("/api/phase-a/accounts", func(c fiber.Ctx) error {
var input accountRequest
if err := decodePhaseA(c, &input); err != nil {
@@ -57,13 +57,22 @@ func registerPhaseA(app *fiber.App, store *phasea.Store) {
})
app.Post("/api/phase-a/accounts/:id/pause", func(c fiber.Ctx) error {
runtimeOperations.Lock()
defer runtimeOperations.Unlock()
if err := store.PauseAccount(c.Context(), c.Params("id")); err != nil {
return phaseAError(c, err)
}
if runtimeStore != nil {
if err := stopAccountRuntime(c.Context(), runtimeStore, c.Params("id")); err != nil {
return hubError(c, err)
}
}
return c.SendStatus(fiber.StatusNoContent)
})
app.Post("/api/phase-a/accounts/:id/resume", func(c fiber.Ctx) error {
runtimeOperations.Lock()
defer runtimeOperations.Unlock()
if err := store.ResumeAccount(c.Context(), c.Params("id")); err != nil {
return phaseAError(c, err)
}
@@ -71,26 +80,15 @@ func registerPhaseA(app *fiber.App, store *phasea.Store) {
})
app.Post("/api/phase-a/accounts/:id/revoke", func(c fiber.Ctx) error {
runtimeOperations.Lock()
defer runtimeOperations.Unlock()
if err := store.RevokeAccount(c.Context(), c.Params("id")); err != nil {
return phaseAError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
})
app.Post("/api/phase-a/runtimes", func(c fiber.Ctx) error {
var input phasea.RuntimeBinding
if err := decodePhaseA(c, &input); err != nil {
return phaseAError(c, err)
}
if err := store.BindRuntime(c.Context(), input); err != nil {
return phaseAError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(map[string]string{"id": input.ID})
})
app.Delete("/api/phase-a/runtimes/:id", func(c fiber.Ctx) error {
if err := store.ReleaseRuntime(c.Context(), c.Params("id")); err != nil {
return phaseAError(c, err)
if runtimeStore != nil {
if err := stopAccountRuntime(c.Context(), runtimeStore, c.Params("id")); err != nil {
return hubError(c, err)
}
}
return c.SendStatus(fiber.StatusNoContent)
})
+818 -159
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+69 -28
View File
@@ -25,65 +25,106 @@ type memoryProxyRegistry struct {
}
type memoryProxy struct {
mu sync.RWMutex
exit gatewayProxyExit
bindHost string
listener net.Listener
server *http.Server
url string
mu sync.RWMutex
exit gatewayProxyExit
bindingVersion int64
runtimeID string
networkID string
bindHost string
listener net.Listener
server *http.Server
url string
}
func newMemoryProxyRegistry() *memoryProxyRegistry {
return &memoryProxyRegistry{proxies: map[string]*memoryProxy{}}
}
func (registry *memoryProxyRegistry) configure(alias, bindHost string, port int, exit gatewayProxyExit) (string, func(), error) {
func (registry *memoryProxyRegistry) configure(alias string, bindingVersion int64, bindHost string, port int, exit gatewayProxyExit,
networkIDs ...string) (string, func(), error) {
registry.mu.Lock()
defer registry.mu.Unlock()
networkID := ""
if len(networkIDs) == 1 {
networkID = networkIDs[0]
}
if proxy := registry.proxies[alias]; proxy != nil {
if proxy.bindHost == bindHost && (port == 0 || proxy.listener.Addr().(*net.TCPAddr).Port == port) && proxy.exit == exit {
return proxy.url, func() {}, nil
if proxy.bindingVersion == bindingVersion && proxy.bindHost == bindHost &&
(port == 0 || proxy.listener.Addr().(*net.TCPAddr).Port == port) && proxy.exit == exit && proxy.networkID == networkID {
return proxy.url, func() { registry.removeObject(alias, proxy) }, nil
}
delete(registry.proxies, alias)
_ = proxy.server.Close()
return "", nil, errGenerationConflict
}
listener, err := net.Listen("tcp4", net.JoinHostPort(bindHost, strconv.Itoa(port)))
if err != nil {
return "", nil, err
}
actualPort := listener.Addr().(*net.TCPAddr).Port
proxy := &memoryProxy{exit: exit, bindHost: bindHost, listener: listener, url: "http://" + net.JoinHostPort(browserProxyHost, strconv.Itoa(actualPort))}
proxy := &memoryProxy{exit: exit, bindingVersion: bindingVersion, networkID: networkID, bindHost: bindHost, listener: listener,
url: "http://" + net.JoinHostPort(browserProxyHost, strconv.Itoa(actualPort))}
proxy.server = &http.Server{Handler: proxy, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
registry.proxies[alias] = proxy
go func() { _ = proxy.server.Serve(listener) }()
undo := func() {
registry.mu.Lock()
defer registry.mu.Unlock()
if registry.proxies[alias] == proxy {
delete(registry.proxies, alias)
_ = proxy.server.Close()
}
registry.removeObject(alias, proxy)
}
return proxy.url, undo, nil
}
func (registry *memoryProxyRegistry) ready(alias string, port int) bool {
func (registry *memoryProxyRegistry) removeObject(alias string, proxy *memoryProxy) {
registry.mu.Lock()
defer registry.mu.Unlock()
proxy := registry.proxies[alias]
return proxy != nil && proxy.listener.Addr().(*net.TCPAddr).Port == port
}
func (registry *memoryProxyRegistry) remove(alias string) {
registry.mu.Lock()
proxy := registry.proxies[alias]
delete(registry.proxies, alias)
if registry.proxies[alias] == proxy {
delete(registry.proxies, alias)
} else {
proxy = nil
}
registry.mu.Unlock()
if proxy != nil {
_ = proxy.server.Close()
}
}
func (registry *memoryProxyRegistry) ready(alias string, port int, runtimeID string, networkIDs ...string) bool {
registry.mu.Lock()
defer registry.mu.Unlock()
proxy := registry.proxies[alias]
return proxy != nil && runtimeID != "" && proxy.runtimeID == runtimeID && proxy.listener.Addr().(*net.TCPAddr).Port == port &&
(len(networkIDs) == 0 || proxy.networkID == networkIDs[0])
}
func (registry *memoryProxyRegistry) bind(alias string, bindingVersion int64, proxyURL, runtimeID string, networkIDs ...string) bool {
registry.mu.Lock()
defer registry.mu.Unlock()
proxy := registry.proxies[alias]
if proxy == nil || runtimeID == "" || proxy.bindingVersion != bindingVersion || proxy.url != proxyURL {
return false
}
proxy.runtimeID = runtimeID
if len(networkIDs) == 1 {
proxy.networkID = networkIDs[0]
}
return true
}
func (registry *memoryProxyRegistry) remove(alias string, bindingVersion int64, runtimeID string, networkIDs ...string) bool {
registry.mu.Lock()
proxy := registry.proxies[alias]
if proxy != nil && runtimeID != "" && proxy.bindingVersion == bindingVersion && proxy.runtimeID == runtimeID &&
(len(networkIDs) == 0 || proxy.networkID == networkIDs[0]) {
delete(registry.proxies, alias)
} else if proxy != nil {
registry.mu.Unlock()
return false
} else {
proxy = nil
}
registry.mu.Unlock()
if proxy != nil {
_ = proxy.server.Close()
}
return true
}
func (proxy *memoryProxy) ServeHTTP(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodConnect {
proxy.tunnel(response, request)
+19 -3
View File
@@ -73,7 +73,7 @@ func TestMemoryProxyUsesSOCKS5Credentials(t *testing.T) {
host, portText, _ := net.SplitHostPort(listener.Addr().String())
port, _ := net.LookupPort("tcp", portText)
registry := newMemoryProxyRegistry()
proxyURL, cleanup, err := registry.configure("account-a", "127.0.0.1", 0, gatewayProxyExit{
proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{
Protocol: "socks5", Host: host, Port: port, Username: "operator", Password: "ephemeral",
})
if err != nil {
@@ -119,7 +119,7 @@ func TestMemoryProxyUsesAbsoluteFormForHTTPUpstream(t *testing.T) {
address, _ := url.Parse(upstream.URL)
port, _ := strconv.Atoi(address.Port())
registry := newMemoryProxyRegistry()
proxyURL, cleanup, err := registry.configure("account-a", "127.0.0.1", 0, gatewayProxyExit{
proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{
Protocol: "http", Host: address.Hostname(), Port: port, Username: "operator", Password: "ephemeral",
})
if err != nil {
@@ -141,7 +141,7 @@ func TestMemoryProxyUsesAbsoluteFormForHTTPUpstream(t *testing.T) {
func TestMemoryProxyRejectsCrossAliasAddress(t *testing.T) {
registry := newMemoryProxyRegistry()
proxyURL, cleanup, err := registry.configure("account-a", "127.0.0.1", 0, gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1})
proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1})
if err != nil {
t.Fatal(err)
}
@@ -152,3 +152,19 @@ func TestMemoryProxyRejectsCrossAliasAddress(t *testing.T) {
t.Fatal("another tenant address could reach account-a proxy")
}
}
func TestMemoryProxyRemoveRequiresMatchingGeneration(t *testing.T) {
registry := newMemoryProxyRegistry()
proxyURL, cleanup, err := registry.configure("account-a", 2, "127.0.0.1", 0, gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1})
if err != nil {
t.Fatal(err)
}
defer cleanup()
if !registry.bind("account-a", 2, proxyURL, "container-c2") {
t.Fatal("bind proxy generation")
}
proxy := registry.proxies["account-a"]
if registry.remove("account-a", 2, "") || registry.remove("account-a", 2, "container-c1") || registry.proxies["account-a"] != proxy {
t.Fatal("stale generation removed the current proxy")
}
}
+2
View File
@@ -44,6 +44,8 @@ services:
docker-gateway:
build: .
command: ["/app/docker-gateway"]
labels:
io.creatorhub.gateway-member: "true"
environment:
BROWSER_NETWORK: creatorhub_browser
GATEWAY_TOKEN: ${GATEWAY_TOKEN:-dev-creatorhub-gateway-token}
+164 -53
View File
@@ -51,13 +51,20 @@ type ExitObservation struct {
type EnvironmentContext struct {
Env
AccountID string `json:"account_id"`
BindingID string `json:"binding_id"`
BindingVersion int64 `json:"binding_version"`
RuntimeCleanupPending bool `json:"runtime_cleanup_pending,omitempty"`
Exit NetworkExit `json:"network_exit"`
RuntimeInstanceID string `json:"runtime_instance_id,omitempty"`
RuntimeID string `json:"runtime_id,omitempty"`
AccountID string `json:"account_id"`
AccountStatus string `json:"account_status"`
AuthorizationStatus string `json:"authorization_status"`
BindingID string `json:"binding_id"`
BindingVersion int64 `json:"binding_version"`
RuntimeCleanupPending bool `json:"runtime_cleanup_pending,omitempty"`
RuntimeCleanupBindingVersion int64 `json:"runtime_cleanup_binding_version,omitempty"`
RuntimeCleanupInstanceID string `json:"runtime_cleanup_instance_id,omitempty"`
RuntimeCleanupRuntimeID string `json:"runtime_cleanup_runtime_id,omitempty"`
RuntimeCleanupNetworkID string `json:"runtime_cleanup_network_id,omitempty"`
Exit NetworkExit `json:"network_exit"`
RuntimeInstanceID string `json:"runtime_instance_id,omitempty"`
RuntimeID string `json:"runtime_id,omitempty"`
RuntimeNetworkID string `json:"runtime_network_id,omitempty"`
}
type EnvironmentAction struct {
@@ -396,40 +403,39 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
return EnvironmentContext{}, errors.New("begin environment context read")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
UPDATE runtime_instance runtime SET released_at = now()
FROM environment_binding binding
WHERE binding.browser_env_alias = $1 AND runtime.binding_id = binding.id
AND runtime.released_at IS NULL AND runtime.lease_until <= now()`, alias); err != nil {
return EnvironmentContext{}, errors.New("expire environment runtime")
}
var result EnvironmentContext
var encoded []byte
var expectedIP, observedIP string
var checked sql.NullTime
var runtimeInstanceID, runtimeID sql.NullString
var runtimeInstanceID, runtimeID, runtimeNetworkID, cleanupInstanceID, cleanupRuntimeID, cleanupNetworkID sql.NullString
var cleanupBindingVersion sql.NullInt64
err = tx.QueryRowContext(ctx, `
SELECT environment.alias, environment.name, environment.gateway_name, environment.image_version,
environment.fingerprint, environment.created_at, binding.account_id, binding.id, binding.version,
binding.runtime_cleanup_pending,
environment.fingerprint, environment.created_at, binding.account_id, account.status, account.authorization_status,
binding.id, binding.version,
binding.runtime_cleanup_pending, binding.runtime_cleanup_binding_version,
binding.runtime_cleanup_instance_id, binding.runtime_cleanup_runtime_id, binding.runtime_cleanup_network_id,
COALESCE(network.id, ''), COALESCE(network.protocol, ''), COALESCE(network.host, ''), COALESCE(network.port, 0),
COALESCE(host(network.expected_public_ip), ''), COALESCE(network.expected_region, ''),
COALESCE(host(network.observed_public_ip), ''), COALESCE(network.observed_region, ''),
COALESCE(network.health_status, 'unchecked'), COALESCE(network.last_check_reason, ''),
COALESCE(network.version, 0), network.last_checked_at,
COALESCE(network.created_at, to_timestamp(0)), COALESCE(network.updated_at, to_timestamp(0)),
runtime.id, runtime.runtime_id
runtime.id, runtime.runtime_id, runtime.network_id
FROM browser_env environment
JOIN environment_binding binding ON binding.browser_env_alias = environment.alias
JOIN social_account account ON account.id = binding.account_id
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL
WHERE environment.alias = $1`, alias).
Scan(&result.Alias, &result.Name, &result.Gateway, &result.ImageVersion, &encoded, &result.CreatedAt,
&result.AccountID, &result.BindingID, &result.BindingVersion, &result.RuntimeCleanupPending,
&result.AccountID, &result.AccountStatus, &result.AuthorizationStatus,
&result.BindingID, &result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion,
&cleanupInstanceID, &cleanupRuntimeID, &cleanupNetworkID,
&result.Exit.ID, &result.Exit.Protocol, &result.Exit.Host, &result.Exit.Port,
&expectedIP, &result.Exit.ExpectedRegion, &observedIP, &result.Exit.ObservedRegion,
&result.Exit.HealthStatus, &result.Exit.LastCheckReason, &result.Exit.Version, &checked, &result.Exit.CreatedAt, &result.Exit.UpdatedAt,
&runtimeInstanceID, &runtimeID)
&runtimeInstanceID, &runtimeID, &runtimeNetworkID)
if err != nil {
return EnvironmentContext{}, rowError(err)
}
@@ -442,13 +448,30 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
if checked.Valid {
result.Exit.LastCheckedAt = &checked.Time
}
result.RuntimeInstanceID, result.RuntimeID = runtimeInstanceID.String, runtimeID.String
result.RuntimeInstanceID, result.RuntimeID, result.RuntimeNetworkID = runtimeInstanceID.String, runtimeID.String, runtimeNetworkID.String
if result.RuntimeCleanupPending {
result.RuntimeCleanupBindingVersion = cleanupBindingVersion.Int64
result.RuntimeCleanupInstanceID, result.RuntimeCleanupRuntimeID = cleanupInstanceID.String, cleanupRuntimeID.String
result.RuntimeCleanupNetworkID = cleanupNetworkID.String
}
if err := commitHub(tx); err != nil {
return EnvironmentContext{}, err
}
return result, nil
}
func (s *Store) GetEnvironmentContextForAccount(ctx context.Context, accountID string) (EnvironmentContext, error) {
if !aliasPattern.MatchString(accountID) {
return EnvironmentContext{}, ErrInvalid
}
var alias string
if err := s.db.QueryRowContext(ctx, `
SELECT browser_env_alias FROM environment_binding WHERE account_id = $1`, accountID).Scan(&alias); err != nil {
return EnvironmentContext{}, rowError(err)
}
return s.GetEnvironmentContext(ctx, alias)
}
func validateEnvironmentRebind(ctx context.Context, tx *sql.Tx, alias, exitID string, expectedBindingVersion int64) (string, string, error) {
var accountID, bindingID string
var bindingVersion int64
@@ -506,9 +529,14 @@ func (s *Store) ValidateEnvironmentRebind(ctx context.Context, alias, exitID str
return nil
}
func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64) (EnvironmentContext, error) {
func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64, networkIDs ...string) (EnvironmentContext, error) {
networkID := ""
if len(networkIDs) == 1 {
networkID = networkIDs[0]
}
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(exitID) ||
(runtimeID != "" && !exitIDPattern.MatchString(runtimeID)) || expectedBindingVersion < 1 {
(runtimeID != "" && !exitIDPattern.MatchString(runtimeID)) || (networkID != "" && !exitIDPattern.MatchString(networkID)) ||
len(networkIDs) > 1 || expectedBindingVersion < 1 {
return EnvironmentContext{}, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
@@ -528,8 +556,9 @@ func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID
}
if runtimeID != "" {
if _, err := tx.ExecContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, runtime_id, lease_until)
VALUES ($1, $2, $3, $4, now() + interval '1 minute')`, "runtime-"+newHubID(), accountID, bindingID, runtimeID); err != nil {
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, lease_until)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), now() + interval '1 minute')`,
"runtime-"+newHubID(), accountID, bindingID, expectedBindingVersion+1, runtimeID, networkID); err != nil {
return EnvironmentContext{}, publicDatabaseError(err)
}
}
@@ -539,8 +568,13 @@ func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID
return s.GetEnvironmentContext(ctx, alias)
}
func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string) (EnvironmentContext, error) {
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 || !exitIDPattern.MatchString(exitID) {
func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string, networkIDs ...string) (EnvironmentContext, error) {
networkID := ""
if len(networkIDs) == 1 {
networkID = networkIDs[0]
}
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || bindingVersion < 1 || !exitIDPattern.MatchString(exitID) ||
!exitIDPattern.MatchString(networkID) || len(networkIDs) != 1 {
return EnvironmentContext{}, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
@@ -548,18 +582,21 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi
return EnvironmentContext{}, errors.New("begin runtime activation")
}
defer tx.Rollback()
var accountID, bindingID, currentExitID string
var accountID, bindingID, currentExitID, accountStatus, authorizationStatus string
var currentBindingVersion int64
var cleanupPending bool
err = tx.QueryRowContext(ctx, `
SELECT binding.account_id, binding.id, binding.version, COALESCE(binding.network_exit_id, ''), binding.runtime_cleanup_pending
SELECT binding.account_id, binding.id, binding.version, COALESCE(binding.network_exit_id, ''), binding.runtime_cleanup_pending,
account.status, account.authorization_status
FROM environment_binding binding
WHERE binding.browser_env_alias = $1 FOR UPDATE OF binding`, alias).
Scan(&accountID, &bindingID, &currentBindingVersion, &currentExitID, &cleanupPending)
JOIN social_account account ON account.id = binding.account_id
WHERE binding.browser_env_alias = $1 FOR UPDATE OF binding, account`, alias).
Scan(&accountID, &bindingID, &currentBindingVersion, &currentExitID, &cleanupPending, &accountStatus, &authorizationStatus)
if err != nil {
return EnvironmentContext{}, rowError(err)
}
if cleanupPending || currentBindingVersion != bindingVersion || currentExitID != exitID {
if cleanupPending || accountStatus != "active" || authorizationStatus != "authorized" ||
currentBindingVersion != bindingVersion || currentExitID != exitID {
return EnvironmentContext{}, ErrConflict
}
if _, err := tx.ExecContext(ctx, `
@@ -567,19 +604,21 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi
WHERE binding_id = $1 AND released_at IS NULL AND lease_until <= now()`, bindingID); err != nil {
return EnvironmentContext{}, errors.New("expire runtime before activation")
}
var existingInstanceID, existingRuntimeID string
var existingInstanceID, existingRuntimeID, existingNetworkID string
var existingBindingVersion int64
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(id, ''), COALESCE(runtime_id, '') FROM runtime_instance
WHERE binding_id = $1 AND released_at IS NULL`, bindingID).Scan(&existingInstanceID, &existingRuntimeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
SELECT COALESCE(id, ''), COALESCE(runtime_id, ''), COALESCE(network_id, ''), COALESCE(binding_version, 0) FROM runtime_instance
WHERE binding_id = $1 AND released_at IS NULL`, bindingID).Scan(&existingInstanceID, &existingRuntimeID, &existingNetworkID, &existingBindingVersion); err != nil && !errors.Is(err, sql.ErrNoRows) {
return EnvironmentContext{}, publicDatabaseError(err)
}
if existingInstanceID != "" && existingRuntimeID != runtimeID {
if existingInstanceID != "" && (existingRuntimeID != runtimeID || existingNetworkID != networkID || existingBindingVersion != bindingVersion) {
return EnvironmentContext{}, ErrConflict
}
if existingInstanceID == "" {
if _, err := tx.ExecContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, runtime_id, lease_until)
VALUES ($1, $2, $3, $4, now() + interval '1 minute')`, "runtime-"+newHubID(), accountID, bindingID, runtimeID); err != nil {
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, lease_until)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), now() + interval '1 minute')`,
"runtime-"+newHubID(), accountID, bindingID, bindingVersion, runtimeID, networkID); err != nil {
return EnvironmentContext{}, publicDatabaseError(err)
}
} else if _, err := tx.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '1 minute' WHERE id = $1`, existingInstanceID); err != nil {
@@ -591,22 +630,37 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi
return s.GetEnvironmentContext(ctx, alias)
}
func (s *Store) ReleaseRuntime(ctx context.Context, alias string) error {
if !aliasPattern.MatchString(alias) {
func (s *Store) ReleaseRuntime(ctx context.Context, environment EnvironmentContext) error {
if !aliasPattern.MatchString(environment.Alias) || !exitIDPattern.MatchString(environment.BindingID) ||
environment.BindingVersion < 1 || (environment.RuntimeInstanceID != "" && !exitIDPattern.MatchString(environment.RuntimeInstanceID)) {
return ErrInvalid
}
_, err := s.db.ExecContext(ctx, `
if environment.RuntimeInstanceID == "" {
return nil
}
result, err := s.db.ExecContext(ctx, `
UPDATE runtime_instance runtime SET released_at = now()
FROM environment_binding binding
WHERE binding.browser_env_alias = $1 AND runtime.binding_id = binding.id AND runtime.released_at IS NULL`, alias)
WHERE binding.browser_env_alias = $1 AND binding.id = $2 AND binding.version = $3
AND runtime.binding_id = binding.id AND runtime.binding_version = binding.version
AND runtime.id = $4 AND runtime.released_at IS NULL`, environment.Alias, environment.BindingID,
environment.BindingVersion, environment.RuntimeInstanceID)
if err != nil {
return errors.New("release environment runtime")
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return ErrConflict
}
return nil
}
func (s *Store) SetRuntimeCleanupPending(ctx context.Context, alias string, pending bool) error {
if !aliasPattern.MatchString(alias) {
func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment EnvironmentContext, pending bool) error {
if !aliasPattern.MatchString(environment.Alias) || !exitIDPattern.MatchString(environment.BindingID) ||
environment.BindingVersion < 1 || environment.RuntimeCleanupBindingVersion < 1 ||
(pending && environment.RuntimeCleanupRuntimeID == "") ||
(environment.RuntimeCleanupInstanceID != "" && !exitIDPattern.MatchString(environment.RuntimeCleanupInstanceID)) ||
(environment.RuntimeCleanupRuntimeID != "" && !exitIDPattern.MatchString(environment.RuntimeCleanupRuntimeID)) ||
(environment.RuntimeCleanupNetworkID != "" && !exitIDPattern.MatchString(environment.RuntimeCleanupNetworkID)) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
@@ -614,18 +668,75 @@ func (s *Store) SetRuntimeCleanupPending(ctx context.Context, alias string, pend
return errors.New("begin runtime cleanup state update")
}
defer tx.Rollback()
var bindingID string
var currentPending bool
var cleanupBindingVersion sql.NullInt64
var cleanupInstanceID, cleanupRuntimeID, cleanupNetworkID sql.NullString
if err := tx.QueryRowContext(ctx, `
UPDATE environment_binding SET runtime_cleanup_pending = $2, updated_at = now()
WHERE browser_env_alias = $1 RETURNING id`, alias, pending).Scan(&bindingID); err != nil {
return rowError(err)
SELECT runtime_cleanup_pending, runtime_cleanup_binding_version,
runtime_cleanup_instance_id, runtime_cleanup_runtime_id, runtime_cleanup_network_id
FROM environment_binding
WHERE browser_env_alias = $1 AND id = $2 AND version = $3 FOR UPDATE`, environment.Alias,
environment.BindingID, environment.BindingVersion).
Scan(&currentPending, &cleanupBindingVersion, &cleanupInstanceID, &cleanupRuntimeID, &cleanupNetworkID); errors.Is(err, sql.ErrNoRows) {
return ErrConflict
} else if err != nil {
return publicDatabaseError(err)
}
if currentPending {
if cleanupBindingVersion.Int64 != environment.RuntimeCleanupBindingVersion ||
cleanupInstanceID.String != environment.RuntimeCleanupInstanceID || cleanupRuntimeID.String != environment.RuntimeCleanupRuntimeID ||
cleanupNetworkID.String != environment.RuntimeCleanupNetworkID {
return ErrConflict
}
if pending {
return commitHub(tx)
}
} else if !pending {
return commitHub(tx)
}
if pending && !currentPending {
var runtimeInstanceID string
err := tx.QueryRowContext(ctx, `
SELECT id FROM runtime_instance
WHERE binding_id = $1 AND binding_version = $2 AND released_at IS NULL`, environment.BindingID,
environment.BindingVersion).Scan(&runtimeInstanceID)
if errors.Is(err, sql.ErrNoRows) {
runtimeInstanceID = ""
} else if err != nil {
return publicDatabaseError(err)
}
if runtimeInstanceID != environment.RuntimeCleanupInstanceID {
return ErrConflict
}
if runtimeInstanceID != "" {
result, err := tx.ExecContext(ctx, `
UPDATE runtime_instance SET released_at = now()
WHERE id = $1 AND binding_id = $2 AND binding_version = $3 AND released_at IS NULL`, runtimeInstanceID,
environment.BindingID, environment.BindingVersion)
if err != nil {
return errors.New("release runtime for pending cleanup")
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return ErrConflict
}
}
}
if pending {
if _, err := tx.ExecContext(ctx, `
UPDATE runtime_instance SET released_at = now()
WHERE binding_id = $1 AND released_at IS NULL`, bindingID); err != nil {
return errors.New("release runtime for pending cleanup")
}
_, err = tx.ExecContext(ctx, `
UPDATE environment_binding SET runtime_cleanup_pending = true,
runtime_cleanup_binding_version = $2, runtime_cleanup_instance_id = NULLIF($3, ''),
runtime_cleanup_runtime_id = NULLIF($4, ''), runtime_cleanup_network_id = NULLIF($5, ''), updated_at = now()
WHERE id = $1`, environment.BindingID, environment.RuntimeCleanupBindingVersion,
environment.RuntimeCleanupInstanceID, environment.RuntimeCleanupRuntimeID, environment.RuntimeCleanupNetworkID)
} else {
_, err = tx.ExecContext(ctx, `
UPDATE environment_binding SET runtime_cleanup_pending = false,
runtime_cleanup_binding_version = NULL, runtime_cleanup_instance_id = NULL,
runtime_cleanup_runtime_id = NULL, runtime_cleanup_network_id = NULL, updated_at = now()
WHERE id = $1`, environment.BindingID)
}
if err != nil {
return errors.New("update runtime cleanup state")
}
return commitHub(tx)
}
+76 -3
View File
@@ -29,13 +29,78 @@ func TestUnifiedAccountMigration(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3, 4, 5, 6)`, 6)
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)`, 10)
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4)
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name LIKE 'runtime_cleanup_%'`, 5)
store = openFullyMigratedHub(t, ctx, testURL)
store.Close()
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3, 4, 5, 6)`, 6)
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)`, 10)
})
t.Run("previous migration 008 already applied", func(t *testing.T) {
ctx := context.Background()
testURL := isolatedDatabaseURL(t, databaseURL)
store := openFullyMigratedHub(t, ctx, testURL)
store.Close()
db, err := sql.Open("pgx", testURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`
INSERT INTO credential_reference (id, provider, reference_key)
VALUES ('credential-upgrade', 'os_keyring', 'creatorhub/upgrade');
INSERT INTO social_account
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status, status)
VALUES ('upgrade', 'credential-upgrade', 'mock', 'upgrade', 'owned', 'authorized', 'paused');
INSERT INTO gateway (name, endpoint, token)
VALUES ('upgrade-gateway', 'http://127.0.0.1:8081', 'upgrade-gateway-token');
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1');
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
VALUES ('upgrade', 'Upgrade', 'upgrade-gateway', '1', '{"seed":1}');
INSERT INTO environment_binding (id, account_id, browser_env_alias)
VALUES ('upgrade', 'upgrade', 'upgrade');
UPDATE environment_binding SET runtime_cleanup_pending = true,
runtime_cleanup_binding_version = version WHERE id = 'upgrade';
DELETE FROM schema_migration WHERE version = 9;
ALTER TABLE environment_binding ADD CONSTRAINT runtime_cleanup_generation_consistent CHECK (
(runtime_cleanup_pending AND runtime_cleanup_binding_version IS NOT NULL AND runtime_cleanup_binding_version > 0)
OR
(NOT runtime_cleanup_pending AND runtime_cleanup_binding_version IS NULL
AND runtime_cleanup_instance_id IS NULL AND runtime_cleanup_runtime_id IS NULL)
)`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = false WHERE id = 'upgrade'`); err == nil {
t.Fatal("old migration 008 constraint unexpectedly allowed a legacy writer to clear pending")
}
store, err = Open(ctx, testURL)
if err != nil {
t.Fatalf("apply forward compatibility migration: %v", err)
}
defer store.Close()
if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = false WHERE id = 'upgrade'`); err != nil {
t.Fatalf("migration 009 did not unblock the old writer: %v", err)
}
context, err := store.GetEnvironmentContext(ctx, "upgrade")
if err != nil || context.RuntimeCleanupPending || context.RuntimeCleanupBindingVersion != 0 || context.RuntimeCleanupRuntimeID != "" {
t.Fatalf("new runner reused stale cleanup generation after old writer clear: %#v err=%v", context, err)
}
if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = true WHERE id = 'upgrade'`); err != nil {
t.Fatalf("migration 009 did not allow the old writer to set pending: %v", err)
}
context, err = store.GetEnvironmentContext(ctx, "upgrade")
if err != nil || !context.RuntimeCleanupPending || context.RuntimeCleanupBindingVersion != 1 || context.RuntimeCleanupRuntimeID != "" {
t.Fatalf("new runner did not expose legacy pending for manual reconcile: %#v err=%v", context, err)
}
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (9, 10)`, 2)
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND
((table_name = 'runtime_instance' AND column_name = 'network_id') OR
(table_name = 'environment_binding' AND column_name = 'runtime_cleanup_network_id'))`, 2)
assertDatabaseCount(t, db, `SELECT count(*) FROM pg_constraint WHERE conname = 'runtime_cleanup_generation_consistent'`, 0)
})
t.Run("v1 and v2 data", func(t *testing.T) {
@@ -94,10 +159,18 @@ func TestUnifiedAccountMigration(t *testing.T) {
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'mapped' AND NOT runtime_cleanup_pending`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'unbound'`, 0)
assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-mapped' AND binding_id = 'mapped'`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-mapped' AND binding_version = 1`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-unbound' AND binding_id IS NULL`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM audit_event WHERE event_type = 'legacy_event'`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'legacy-task' AND state = 'policy_hold'`, 1)
assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND NOT (fingerprint ?| ARRAY['proxy_server', 'disable_non_proxied_udp'])`, 1)
if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = true WHERE id = 'mapped'`); err != nil {
t.Fatalf("migration 008 blocked an old writer setting cleanup pending: %v", err)
}
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE id = 'mapped' AND runtime_cleanup_pending AND runtime_cleanup_binding_version IS NULL AND runtime_cleanup_runtime_id IS NULL`, 1)
if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = false WHERE id = 'mapped'`); err != nil {
t.Fatalf("migration 008 blocked an old writer clearing cleanup pending: %v", err)
}
store, err = Open(ctx, testURL)
if err != nil {
t.Fatal(err)
@@ -175,7 +248,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
t.Fatal(err)
}
store.Close()
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (3, 4, 5, 6)`, 4)
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (3, 4, 5, 6, 7, 8, 9)`, 7)
})
}
@@ -0,0 +1,7 @@
ALTER TABLE runtime_instance
ADD COLUMN binding_version bigint CHECK (binding_version > 0);
UPDATE runtime_instance runtime
SET binding_version = binding.version
FROM environment_binding binding
WHERE runtime.binding_id = binding.id;
@@ -0,0 +1,4 @@
ALTER TABLE environment_binding
ADD COLUMN runtime_cleanup_binding_version bigint,
ADD COLUMN runtime_cleanup_instance_id text,
ADD COLUMN runtime_cleanup_runtime_id text;
@@ -0,0 +1,2 @@
ALTER TABLE environment_binding
DROP CONSTRAINT IF EXISTS runtime_cleanup_generation_consistent;
@@ -0,0 +1,5 @@
ALTER TABLE runtime_instance
ADD COLUMN network_id text;
ALTER TABLE environment_binding
ADD COLUMN runtime_cleanup_network_id text;
+17 -4
View File
@@ -34,10 +34,23 @@ var migration005 string
//go:embed migrations/006_runtime_cleanup.sql
var migration006 string
//go:embed migrations/007_runtime_binding_version.sql
var migration007 string
//go:embed migrations/008_runtime_cleanup_generation.sql
var migration008 string
//go:embed migrations/009_runtime_cleanup_compatibility.sql
var migration009 string
//go:embed migrations/010_runtime_network_generation.sql
var migration010 string
var (
ErrConflict = errors.New("resource conflicts with existing state")
ErrInvalid = errors.New("invalid hub input")
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource conflicts with existing state")
ErrInvalid = errors.New("invalid hub input")
ErrNotFound = errors.New("resource not found")
ErrReconcileRequired = errors.New("runtime cleanup generation is unknown; manual reconciliation required")
)
func ValidImageVersion(version string) bool { return imageVersionPattern.MatchString(version) }
@@ -118,7 +131,7 @@ func (s *Store) migrate(ctx context.Context) error {
for _, migration := range []struct {
version int
sql string
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}} {
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}} {
var applied bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
return errors.New("read hub schema migration state")
+96 -5
View File
@@ -8,6 +8,7 @@ import (
"reflect"
"strings"
"testing"
"time"
)
func TestFingerprintArgsFollowUpstreamCommandLineContract(t *testing.T) {
@@ -303,7 +304,10 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
if err != nil || created || reused.Alias != bound.Alias || reused.BindingID != bound.BindingID {
t.Fatalf("same account must reuse its environment: %#v created=%v err=%v", reused, created, err)
}
active, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID)
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil {
t.Fatal(err)
}
active, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID, "network-a")
if err != nil || active.RuntimeInstanceID == "" {
t.Fatalf("activate runtime: %#v err=%v", active, err)
}
@@ -331,13 +335,13 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
if err != nil || legacyRebound.Exit.ID != second.ID {
t.Fatalf("legacy binding without an exit must support explicit rebind: %#v err=%v", legacyRebound, err)
}
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion+1, second.ID); !errors.Is(err, ErrConflict) {
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion+1, second.ID, "network-a"); !errors.Is(err, ErrConflict) {
t.Fatalf("stale binding metadata must not activate a runtime: %v", err)
}
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '1 second' WHERE id = $1`, active.RuntimeInstanceID); err != nil {
t.Fatal(err)
}
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID); err != nil {
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID, "network-a"); err != nil {
t.Fatalf("runtime heartbeat failed: %v", err)
}
var renewed bool
@@ -347,23 +351,110 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() - interval '1 second' WHERE id = $1`, active.RuntimeInstanceID); err != nil {
t.Fatal(err)
}
expired, err := store.GetEnvironmentContext(ctx, env.Alias)
if err != nil || expired.RuntimeInstanceID != active.RuntimeInstanceID || expired.RuntimeID != active.RuntimeID {
t.Fatalf("context read discarded expired cleanup generation: %#v err=%v", expired, err)
}
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'paused' WHERE id = 'account-a'`); err != nil {
t.Fatal(err)
}
rebound, err := store.RebindEnvironment(ctx, env.Alias, second.ID, "", bound.BindingVersion)
if err != nil || rebound.Exit.ID != second.ID || rebound.BindingVersion != 2 {
t.Fatalf("expired runtime must be transactionally released before rebind: %#v err=%v", rebound, err)
}
if _, err := store.ActivateRuntime(ctx, env.Alias, "same-exit-container", rebound.BindingVersion, rebound.Exit.ID); err != nil {
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil {
t.Fatal(err)
}
if _, err := store.ActivateRuntime(ctx, env.Alias, "same-exit-container", rebound.BindingVersion, rebound.Exit.ID, "network-same-exit"); err != nil {
t.Fatalf("activate runtime before same-exit rebind: %v", err)
}
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'paused' WHERE id = 'account-a'`); err != nil {
t.Fatal(err)
}
if _, err := store.RebindEnvironment(ctx, env.Alias, second.ID, "", rebound.BindingVersion); !errors.Is(err, ErrConflict) {
t.Fatalf("active runtime must block same-exit rebind: %v", err)
}
if err := store.ReleaseRuntime(ctx, env.Alias); err != nil {
active, err = store.GetEnvironmentContext(ctx, env.Alias)
if err != nil {
t.Fatal(err)
}
if err := store.ReleaseRuntime(ctx, active); err != nil {
t.Fatal(err)
}
rebound, err = store.RebindEnvironment(ctx, env.Alias, second.ID, "rebound-container", rebound.BindingVersion)
if err != nil || rebound.BindingVersion != 3 || rebound.RuntimeID != "rebound-container" {
t.Fatalf("same-exit rebind must atomically CAS the binding and runtime: %#v err=%v", rebound, err)
}
if err := store.ReleaseRuntime(ctx, active); !errors.Is(err, ErrConflict) {
t.Fatalf("stale generation release must conflict: %v", err)
}
current, err := store.GetEnvironmentContext(ctx, env.Alias)
if err != nil || current.RuntimeInstanceID != rebound.RuntimeInstanceID || current.RuntimeID != "rebound-container" {
t.Fatalf("stale release changed the current runtime: %#v err=%v", current, err)
}
cleanup := current
cleanup.RuntimeCleanupBindingVersion = current.BindingVersion
cleanup.RuntimeCleanupInstanceID = current.RuntimeInstanceID
cleanup.RuntimeCleanupRuntimeID = current.RuntimeID
if err := store.SetRuntimeCleanupPending(ctx, cleanup, true); err != nil {
t.Fatalf("set generation cleanup pending: %v", err)
}
wrongCleanup := cleanup
wrongCleanup.RuntimeCleanupRuntimeID = "other-container"
if err := store.SetRuntimeCleanupPending(ctx, wrongCleanup, false); !errors.Is(err, ErrConflict) {
t.Fatalf("wrong cleanup generation cleared pending state: %v", err)
}
pending, err := store.GetEnvironmentContext(ctx, env.Alias)
if err != nil || !pending.RuntimeCleanupPending || pending.RuntimeCleanupRuntimeID != current.RuntimeID {
t.Fatalf("cleanup generation was not persisted: %#v err=%v", pending, err)
}
if _, err := store.ActivateRuntime(ctx, env.Alias, "candidate-container", current.BindingVersion, current.Exit.ID, "network-candidate"); !errors.Is(err, ErrConflict) {
t.Fatalf("paused account activated a stale request: %v", err)
}
if err := store.SetRuntimeCleanupPending(ctx, pending, false); err != nil {
t.Fatalf("clear matching cleanup generation: %v", err)
}
newGeneration, err := store.RebindEnvironment(ctx, env.Alias, current.Exit.ID, "", current.BindingVersion)
if err != nil {
t.Fatalf("advance binding generation: %v", err)
}
if err := store.SetRuntimeCleanupPending(ctx, cleanup, true); !errors.Is(err, ErrConflict) {
t.Fatalf("stale binding set cleanup pending on version %d: %v", newGeneration.BindingVersion, err)
}
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil {
t.Fatal(err)
}
pauseTx, err := store.db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
var locked string
if err := pauseTx.QueryRowContext(ctx, `SELECT id FROM social_account WHERE id = 'account-a' FOR UPDATE`).Scan(&locked); err != nil {
t.Fatal(err)
}
activation := make(chan error, 1)
go func() {
_, err := store.ActivateRuntime(ctx, env.Alias, "racing-container", newGeneration.BindingVersion, newGeneration.Exit.ID, "network-racing")
activation <- err
}()
select {
case err := <-activation:
t.Fatalf("activation bypassed the locked account row: %v", err)
case <-time.After(50 * time.Millisecond):
}
if _, err := pauseTx.ExecContext(ctx, `UPDATE social_account SET status = 'paused' WHERE id = 'account-a'`); err != nil {
t.Fatal(err)
}
if err := pauseTx.Commit(); err != nil {
t.Fatal(err)
}
if err := <-activation; !errors.Is(err, ErrConflict) {
t.Fatalf("activation won the pause race: %v", err)
}
latest, err := store.GetEnvironmentContext(ctx, env.Alias)
if err != nil || latest.RuntimeID != "" {
t.Fatalf("pause race left an active runtime: %#v err=%v", latest, err)
}
action := EnvironmentAction{
OperationID: NewOperationID(), Action: "start", AccountID: rebound.AccountID,
+148 -95
View File
@@ -49,13 +49,6 @@ type CredentialReference struct {
Provider string `json:"provider"`
}
type RuntimeBinding struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
RuntimeID string `json:"runtime_id"`
LeaseUntil time.Time `json:"lease_until"`
}
type Draft struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
@@ -193,7 +186,7 @@ func (s *Store) CreateAccount(ctx context.Context, account Account) error {
account.Platform, account.PlatformAccountKey, account.AuthorizationKind); err != nil {
return publicDatabaseError(err)
}
if err := appendAudit(ctx, tx, "account_created", account.ID, "", 0, "", "", map[string]string{"platform": account.Platform}); err != nil {
if err := appendAudit(ctx, tx, "account_created", "account_created", account.ID, "", 0, "", "", map[string]string{"platform": account.Platform}); err != nil {
return err
}
return commit(tx)
@@ -245,57 +238,6 @@ func scanAccount(row accountScanner) (Account, error) {
return account, nil
}
func (s *Store) BindRuntime(ctx context.Context, binding RuntimeBinding) error {
if !refPattern.MatchString(binding.ID) || !idPattern.MatchString(binding.AccountID) ||
!refPattern.MatchString(binding.RuntimeID) || !binding.LeaseUntil.After(time.Now()) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin runtime transaction")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE runtime_instance SET released_at = now() WHERE released_at IS NULL AND lease_until < now()`); err != nil {
return errors.New("expire runtime bindings")
}
var accountID string
if err := tx.QueryRowContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, runtime_id, lease_until)
SELECT $1, account.id, binding.id, $3, $4
FROM social_account account
JOIN environment_binding binding ON binding.account_id = account.id
WHERE account.id = $2 AND account.status = 'active'
RETURNING account_id`, binding.ID, binding.AccountID, binding.RuntimeID, binding.LeaseUntil).Scan(&accountID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrConflict
}
return rowError(err)
}
if err := appendAudit(ctx, tx, "runtime_bound", accountID, "", 0, "", "", map[string]string{"runtime_id": binding.RuntimeID}); err != nil {
return err
}
return commit(tx)
}
func (s *Store) ReleaseRuntime(ctx context.Context, id string) error {
if !refPattern.MatchString(id) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin runtime release")
}
defer tx.Rollback()
var accountID string
if err := tx.QueryRowContext(ctx, `UPDATE runtime_instance SET released_at = now() WHERE id = $1 AND released_at IS NULL RETURNING account_id`, id).Scan(&accountID); err != nil {
return rowError(err)
}
if err := appendAudit(ctx, tx, "runtime_released", accountID, "", 0, "", "", map[string]string{"binding_id": id}); err != nil {
return err
}
return commit(tx)
}
func (s *Store) CreateDraft(ctx context.Context, draft Draft) error {
if !refPattern.MatchString(draft.ID) || !idPattern.MatchString(draft.AccountID) || draft.Version < 1 || strings.TrimSpace(draft.Content) == "" {
return ErrInvalid
@@ -332,7 +274,7 @@ func (s *Store) Confirm(ctx context.Context, confirmation Confirmation) error {
confirmation.DraftID, confirmation.DraftVersion, confirmation.Version); err != nil {
return publicDatabaseError(err)
}
if err := appendAudit(ctx, tx, "draft_confirmed", confirmation.AccountID, confirmation.ID, confirmation.Version, "", "", map[string]any{
if err := appendAudit(ctx, tx, "draft_confirmed", "draft_confirmed", confirmation.AccountID, confirmation.ID, confirmation.Version, "", "", map[string]any{
"account_version": confirmation.AccountVersion, "draft_id": confirmation.DraftID, "draft_version": confirmation.DraftVersion,
}); err != nil {
return err
@@ -363,7 +305,7 @@ func (s *Store) Enqueue(ctx context.Context, task Task) (Task, bool, error) {
}
if insertedID != "" {
task.State = "queued"
if err := appendAudit(ctx, tx, "task_queued", task.AccountID, task.ConfirmationID, task.ConfirmationVersion, "", task.ID, nil); err != nil {
if err := appendAudit(ctx, tx, "task_queued", "task_queued", task.AccountID, task.ConfirmationID, task.ConfirmationVersion, "", task.ID, nil); err != nil {
return Task{}, false, err
}
if err := commit(tx); err != nil {
@@ -432,6 +374,14 @@ func (s *Store) disableAccount(ctx context.Context, accountID string, revoke boo
if err != nil {
return err
}
reason := "account_paused"
if revoke {
reason = "account_revoked"
}
interrupted, err := interruptExecutingTasks(ctx, tx, accountID, reason)
if err != nil {
return err
}
if unchanged {
return commit(tx)
}
@@ -439,7 +389,9 @@ func (s *Store) disableAccount(ctx context.Context, accountID string, revoke boo
if revoke {
eventType = "account_revoked"
}
if err := appendAudit(ctx, tx, eventType, accountID, "", 0, "", "", map[string]any{"account_version": version, "tasks_held": held}); err != nil {
if err := appendAudit(ctx, tx, eventType, reason, accountID, "", 0, "", "", map[string]any{
"account_version": version, "tasks_held": held, "attempts_interrupted": interrupted,
}); err != nil {
return err
}
return commit(tx)
@@ -468,6 +420,11 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error {
SELECT 1 FROM environment_binding binding
JOIN network_exit network ON network.id = binding.network_exit_id
WHERE binding.account_id = $1 AND network.health_status = 'healthy'
AND NOT binding.runtime_cleanup_pending
AND NOT EXISTS (
SELECT 1 FROM runtime_instance runtime
WHERE runtime.binding_id = binding.id AND runtime.released_at IS NULL
)
)`, accountID).Scan(&ready); err != nil {
return errors.New("validate account binding")
}
@@ -483,7 +440,7 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error {
WHERE id = $1 RETURNING version`, accountID).Scan(&version); err != nil {
return errors.New("resume account")
}
if err := appendAudit(ctx, tx, "account_resumed", accountID, "", 0, "", "", map[string]any{"account_version": version}); err != nil {
if err := appendAudit(ctx, tx, "account_resumed", "account_resumed", accountID, "", 0, "", "", map[string]any{"account_version": version}); err != nil {
return err
}
return commit(tx)
@@ -500,6 +457,49 @@ func holdQueuedTasks(ctx context.Context, tx *sql.Tx, accountID string) (int64,
return held, nil
}
func interruptExecutingTasks(ctx context.Context, tx *sql.Tx, accountID, reason string) (int64, error) {
rows, err := tx.QueryContext(ctx, `
UPDATE operation_task SET state = 'needs_confirmation', lease_owner = NULL, lease_until = NULL, updated_at = now()
WHERE account_id = $1 AND state = 'executing'
RETURNING id, current_attempt_id, confirmation_id, confirmation_version`, accountID)
if err != nil {
return 0, errors.New("interrupt executing account tasks")
}
type interruptedTask struct {
taskID, attemptID, confirmationID string
confirmationVersion int64
}
var tasks []interruptedTask
for rows.Next() {
var task interruptedTask
var attemptID, confirmationID sql.NullString
var confirmationVersion sql.NullInt64
if err := rows.Scan(&task.taskID, &attemptID, &confirmationID, &confirmationVersion); err != nil {
rows.Close()
return 0, errors.New("read interrupted account task")
}
task.attemptID, task.confirmationID, task.confirmationVersion = attemptID.String, confirmationID.String, confirmationVersion.Int64
tasks = append(tasks, task)
}
if err := rows.Close(); err != nil {
return 0, errors.New("close interrupted account task rows")
}
for _, task := range tasks {
if task.attemptID != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE execution_attempt SET finished_at = now(), outcome = 'uncertain'
WHERE id = $1 AND finished_at IS NULL`, task.attemptID); err != nil {
return 0, errors.New("mark interrupted attempt uncertain")
}
}
if err := appendAudit(ctx, tx, "task_interrupted", reason, accountID, task.confirmationID,
task.confirmationVersion, task.attemptID, task.taskID, nil); err != nil {
return 0, err
}
}
return int64(len(tasks)), nil
}
func (s *Store) CancelTask(ctx context.Context, taskID string) error {
if !refPattern.MatchString(taskID) {
return ErrInvalid
@@ -529,7 +529,7 @@ func (s *Store) CancelTask(ctx context.Context, taskID string) error {
return errors.New("mark cancelled attempt uncertain")
}
}
if err := appendAudit(ctx, tx, "task_cancel_requested", accountID, confirmationID.String, confirmationVersion.Int64, attemptID.String, taskID, map[string]string{"state": state}); err != nil {
if err := appendAudit(ctx, tx, "task_cancel_requested", "task_cancel_requested", accountID, confirmationID.String, confirmationVersion.Int64, attemptID.String, taskID, map[string]string{"state": state}); err != nil {
return err
}
return commit(tx)
@@ -566,14 +566,20 @@ func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) {
JOIN social_account a ON a.id = t.account_id
JOIN content_draft d ON d.id = t.draft_id
JOIN confirmation c ON c.id = t.confirmation_id
WHERE t.state = 'queued' AND a.status = 'active'
JOIN environment_binding binding ON binding.account_id = a.id
JOIN browser_env environment ON environment.alias = binding.browser_env_alias
JOIN network_exit network ON network.id = binding.network_exit_id
JOIN runtime_instance runtime ON runtime.binding_id = binding.id
AND runtime.released_at IS NULL AND runtime.lease_until > now() AND runtime.binding_version = binding.version
WHERE t.state = 'queued' AND a.status = 'active' AND a.authorization_status = 'authorized'
AND a.version = t.account_version
AND d.account_id = t.account_id AND d.version = t.draft_version
AND c.account_id = t.account_id AND c.account_version = t.account_version
AND c.draft_id = t.draft_id AND c.draft_version = t.draft_version
AND c.version = t.confirmation_version
AND network.health_status = 'healthy' AND NOT binding.runtime_cleanup_pending
ORDER BY t.created_at, t.id
FOR UPDATE OF t SKIP LOCKED LIMIT 1
FOR UPDATE OF t, a, binding, network, runtime SKIP LOCKED LIMIT 1
)
UPDATE operation_task t SET state = 'executing', lease_owner = $1,
lease_until = now() + interval '1 minute', updated_at = now()
@@ -598,7 +604,7 @@ func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) {
if _, err := tx.ExecContext(ctx, `UPDATE operation_task SET current_attempt_id = $1 WHERE id = $2`, execution.AttemptID, execution.TaskID); err != nil {
return Execution{}, errors.New("attach execution attempt")
}
if err := appendAudit(ctx, tx, "task_claimed", execution.AccountID, execution.ConfirmationID, execution.ConfirmationVersion, execution.AttemptID, execution.TaskID, map[string]string{"worker_id": workerID}); err != nil {
if err := appendAudit(ctx, tx, "task_claimed", "task_claimed", execution.AccountID, execution.ConfirmationID, execution.ConfirmationVersion, execution.AttemptID, execution.TaskID, map[string]string{"worker_id": workerID}); err != nil {
return Execution{}, err
}
if err := commit(tx); err != nil {
@@ -633,7 +639,11 @@ func (s *Store) complete(ctx context.Context, workerID string, execution Executi
UPDATE operation_task SET state = $1, lease_owner = NULL, lease_until = NULL, updated_at = now() WHERE id = $2`, state, execution.TaskID); err != nil {
return Execution{}, errors.New("finish task")
}
if err := appendAudit(ctx, tx, "task_finished", execution.AccountID, execution.ConfirmationID, execution.ConfirmationVersion, execution.AttemptID, execution.TaskID, map[string]string{"state": state}); err != nil {
reason := map[string]string{
"succeeded": "task_succeeded", "failed": "task_failed",
"needs_confirmation": "task_result_uncertain", "policy_hold": "task_policy_hold",
}[state]
if err := appendAudit(ctx, tx, "task_finished", reason, execution.AccountID, execution.ConfirmationID, execution.ConfirmationVersion, execution.AttemptID, execution.TaskID, map[string]string{"state": state}); err != nil {
return Execution{}, err
}
if err := commit(tx); err != nil {
@@ -676,7 +686,7 @@ func quarantineExpired(ctx context.Context, tx *sql.Tx) error {
return errors.New("mark expired attempt uncertain")
}
}
if err := appendAudit(ctx, tx, "task_lease_expired", task.accountID, task.confirmationID, task.confirmationVersion, task.attemptID, task.taskID, nil); err != nil {
if err := appendAudit(ctx, tx, "task_lease_expired", "execution_lease_expired", task.accountID, task.confirmationID, task.confirmationVersion, task.attemptID, task.taskID, nil); err != nil {
return err
}
}
@@ -685,46 +695,84 @@ func quarantineExpired(ctx context.Context, tx *sql.Tx) error {
func quarantineInvalid(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `
UPDATE operation_task t SET
state = CASE WHEN EXISTS (
SELECT 1 FROM social_account paused WHERE paused.id = t.account_id AND paused.status = 'paused'
) THEN 'policy_hold' ELSE 'needs_confirmation' END,
updated_at = now()
WHERE t.state = 'queued' AND NOT EXISTS (
SELECT 1
FROM social_account a
JOIN content_draft d ON d.id = t.draft_id
JOIN confirmation c ON c.id = t.confirmation_id
WHERE a.id = t.account_id AND a.status = 'active' AND a.version = t.account_version
AND d.account_id = t.account_id AND d.version = t.draft_version
AND c.account_id = t.account_id AND c.account_version = t.account_version
AND c.draft_id = t.draft_id AND c.draft_version = t.draft_version
AND c.version = t.confirmation_version
WITH invalid AS (
SELECT t.id,
CASE
WHEN account.id IS NULL OR account.status <> 'active' OR account.authorization_status <> 'authorized' THEN 'policy_hold'
WHEN account.version <> t.account_version THEN 'needs_confirmation'
WHEN draft.id IS NULL OR draft.account_id <> t.account_id OR draft.version <> t.draft_version THEN 'needs_confirmation'
WHEN confirmation.id IS NULL OR confirmation.account_id <> t.account_id
OR confirmation.account_version <> t.account_version OR confirmation.draft_id <> t.draft_id
OR confirmation.draft_version <> t.draft_version OR confirmation.version <> t.confirmation_version THEN 'needs_confirmation'
WHEN runtime.id IS NOT NULL AND runtime.binding_version IS DISTINCT FROM binding.version THEN 'needs_confirmation'
ELSE 'policy_hold'
END AS state,
CASE
WHEN account.id IS NULL THEN 'account_missing'
WHEN account.authorization_status = 'revoked' THEN 'account_revoked'
WHEN account.status <> 'active' THEN 'account_paused'
WHEN account.version <> t.account_version THEN 'account_version_changed'
WHEN draft.id IS NULL OR draft.account_id <> t.account_id OR draft.version <> t.draft_version THEN 'draft_version_changed'
WHEN confirmation.id IS NULL THEN 'confirmation_missing'
WHEN confirmation.account_id <> t.account_id OR confirmation.account_version <> t.account_version
OR confirmation.draft_id <> t.draft_id OR confirmation.draft_version <> t.draft_version
OR confirmation.version <> t.confirmation_version THEN 'confirmation_version_changed'
WHEN binding.id IS NULL THEN 'binding_missing'
WHEN environment.alias IS NULL THEN 'environment_missing'
WHEN network.id IS NULL THEN 'exit_missing'
WHEN network.health_status <> 'healthy' THEN 'exit_unhealthy'
WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending'
WHEN runtime.id IS NULL THEN 'runtime_missing'
WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed'
WHEN runtime.lease_until <= now() THEN 'runtime_lease_expired'
END AS reason_code
FROM operation_task t
LEFT JOIN social_account account ON account.id = t.account_id
LEFT JOIN content_draft draft ON draft.id = t.draft_id
LEFT JOIN confirmation confirmation ON confirmation.id = t.confirmation_id
LEFT JOIN environment_binding binding ON binding.account_id = t.account_id
LEFT JOIN browser_env environment ON environment.alias = binding.browser_env_alias
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL
WHERE t.state = 'queued' AND (
account.id IS NULL OR account.status <> 'active' OR account.authorization_status <> 'authorized'
OR account.version <> t.account_version
OR draft.id IS NULL OR draft.account_id <> t.account_id OR draft.version <> t.draft_version
OR confirmation.id IS NULL OR confirmation.account_id <> t.account_id
OR confirmation.account_version <> t.account_version OR confirmation.draft_id <> t.draft_id
OR confirmation.draft_version <> t.draft_version OR confirmation.version <> t.confirmation_version
OR binding.id IS NULL OR environment.alias IS NULL OR network.id IS NULL OR network.health_status <> 'healthy'
OR binding.runtime_cleanup_pending OR runtime.id IS NULL
OR runtime.binding_version IS DISTINCT FROM binding.version OR runtime.lease_until <= now()
)
FOR UPDATE OF t SKIP LOCKED
)
RETURNING t.id, t.account_id, t.confirmation_id, t.confirmation_version, t.state`)
UPDATE operation_task task SET state = invalid.state, updated_at = now()
FROM invalid WHERE task.id = invalid.id
RETURNING task.id, task.account_id, task.confirmation_id, task.confirmation_version, task.state, invalid.reason_code`)
if err != nil {
return errors.New("quarantine invalid tasks")
}
type invalidTask struct {
taskID, accountID, confirmationID, state string
confirmationVersion int64
taskID, accountID, confirmationID, state, reasonCode string
confirmationVersion int64
}
var invalid []invalidTask
for rows.Next() {
var taskID, accountID, state string
var taskID, accountID, state, reasonCode string
var confirmationID sql.NullString
var confirmationVersion sql.NullInt64
if err := rows.Scan(&taskID, &accountID, &confirmationID, &confirmationVersion, &state); err != nil {
if err := rows.Scan(&taskID, &accountID, &confirmationID, &confirmationVersion, &state, &reasonCode); err != nil {
rows.Close()
return errors.New("read quarantined task")
}
invalid = append(invalid, invalidTask{taskID, accountID, confirmationID.String, state, confirmationVersion.Int64})
invalid = append(invalid, invalidTask{taskID, accountID, confirmationID.String, state, reasonCode, confirmationVersion.Int64})
}
if err := rows.Close(); err != nil {
return errors.New("close quarantined task rows")
}
for _, task := range invalid {
if err := appendAudit(ctx, tx, "task_quarantined", task.accountID, task.confirmationID, task.confirmationVersion, "", task.taskID, map[string]string{"state": task.state}); err != nil {
if err := appendAudit(ctx, tx, "task_quarantined", task.reasonCode, task.accountID, task.confirmationID, task.confirmationVersion, "", task.taskID, map[string]string{"state": task.state}); err != nil {
return err
}
}
@@ -766,7 +814,7 @@ func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) {
return events, rows.Err()
}
func appendAudit(ctx context.Context, tx *sql.Tx, eventType, accountID, confirmationID string, confirmationVersion int64, attemptID, taskID string, details any) error {
func appendAudit(ctx context.Context, tx *sql.Tx, eventType, reasonCode, accountID, confirmationID string, confirmationVersion int64, attemptID, taskID string, details any) error {
if details == nil {
details = map[string]any{}
}
@@ -776,9 +824,14 @@ func appendAudit(ctx context.Context, tx *sql.Tx, eventType, accountID, confirma
}
_, err = tx.ExecContext(ctx, `
INSERT INTO audit_event
(event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id, actor, reason_code, details)
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, 0), NULLIF($5, ''), NULLIF($6, ''), 'local-user', $1, $7)`,
eventType, accountID, confirmationID, confirmationVersion, attemptID, taskID, encoded)
(event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id,
browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code, details)
SELECT $1, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, 0), NULLIF($6, ''), NULLIF($7, ''),
binding.browser_env_alias, binding.network_exit_id, runtime.id, binding.version, 'local-user', $2, $8
FROM (VALUES (1)) AS singleton(value)
LEFT JOIN environment_binding binding ON binding.account_id = NULLIF($3, '')
LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL`,
eventType, reasonCode, accountID, confirmationID, confirmationVersion, attemptID, taskID, encoded)
if err != nil {
return errors.New("append audit event")
}
+151 -14
View File
@@ -131,23 +131,21 @@ func TestPhaseAOfflineWorkflow(t *testing.T) {
t.Fatal(err)
}
lease := time.Now().Add(time.Minute)
if err := store.BindRuntime(ctx, RuntimeBinding{ID: "binding-a", AccountID: "account-a", RuntimeID: "runtime-a", LeaseUntil: lease}); err != nil {
t.Fatal(err)
}
if err := store.BindRuntime(ctx, RuntimeBinding{ID: "binding-a-2", AccountID: "account-a", RuntimeID: "runtime-b", LeaseUntil: lease}); !errors.Is(err, ErrConflict) {
t.Fatalf("expected one active runtime per account, got %v", err)
}
if err := store.BindRuntime(ctx, RuntimeBinding{ID: "binding-b", AccountID: "account-b", RuntimeID: "runtime-a", LeaseUntil: lease}); !errors.Is(err, ErrConflict) {
t.Fatalf("expected one account per runtime, got %v", err)
}
if err := store.ReleaseRuntime(ctx, "binding-a"); err != nil {
if _, err := store.db.ExecContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) VALUES
('runtime-instance-a', 'account-a', 'binding-a', 1, 'runtime-a', now() + interval '1 minute'),
('runtime-instance-b', 'account-b', 'binding-b', 1, 'runtime-b', now() + interval '1 minute')`); err != nil {
t.Fatal(err)
}
createApprovedDraft(t, store, "account-a", accountA.Version, "draft-a", "confirmation-a")
createApprovedDraft(t, store, "account-b", accountB.Version, "draft-b", "confirmation-b")
for index := range 20 {
task := approvedTask(index, "account-a", accountA.Version, "draft-a", "confirmation-a")
accountID, accountVersion, draftID, confirmationID := "account-a", accountA.Version, "draft-a", "confirmation-a"
if index%2 == 1 {
accountID, accountVersion, draftID, confirmationID = "account-b", accountB.Version, "draft-b", "confirmation-b"
}
task := approvedTask(index, accountID, accountVersion, draftID, confirmationID)
if _, inserted, err := store.Enqueue(ctx, task); err != nil || !inserted {
t.Fatalf("enqueue task %d: inserted=%v err=%v", index, inserted, err)
}
@@ -188,7 +186,51 @@ func TestPhaseAOfflineWorkflow(t *testing.T) {
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE state = 'succeeded'`, 20)
assertCount(t, store, `SELECT count(*) FROM execution_attempt`, 20)
createApprovedDraft(t, store, "account-b", accountB.Version, "draft-b", "confirmation-b")
for _, test := range []struct {
name, accountStatus, authorizationStatus, exitStatus, runtimeLease, wantState, wantReason string
binding, versionChanged, confirmationChanged bool
}{
{name: "binding-missing", accountStatus: "active", authorizationStatus: "authorized", wantState: "policy_hold", wantReason: "binding_missing"},
{name: "exit-unhealthy", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "unhealthy", wantState: "policy_hold", wantReason: "exit_unhealthy"},
{name: "runtime-missing", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", wantState: "policy_hold", wantReason: "runtime_missing"},
{name: "runtime-expired", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "expired", wantState: "policy_hold", wantReason: "runtime_lease_expired"},
{name: "binding-version", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", versionChanged: true, wantState: "needs_confirmation", wantReason: "binding_version_changed"},
{name: "confirmation-version", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", confirmationChanged: true, wantState: "needs_confirmation", wantReason: "confirmation_version_changed"},
{name: "paused", accountStatus: "paused", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", wantState: "policy_hold", wantReason: "account_paused"},
{name: "revoked", accountStatus: "paused", authorizationStatus: "revoked", binding: true, exitStatus: "healthy", runtimeLease: "active", wantState: "policy_hold", wantReason: "account_revoked"},
} {
t.Run("gate "+test.name, func(t *testing.T) {
taskID := seedGateTask(t, store, test.name, test.accountStatus, test.authorizationStatus, test.exitStatus,
test.runtimeLease, test.binding, test.versionChanged, test.confirmationChanged)
if execution, err := store.ExecuteMock(ctx, "worker-gate-"+test.name, "succeeded"); err != nil || execution.WasClaimed {
t.Fatalf("invalid task was claimed: execution=%+v err=%v", execution, err)
}
assertTaskGate(t, store, taskID, test.wantState, test.wantReason)
})
}
// A held task is never revived in place: only a newly confirmed task with a new idempotency key may run.
if _, err := store.db.ExecContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until)
VALUES ('runtime-recovered', 'gate-runtime-missing', 'binding-runtime-missing', 1, 'container-recovered', now() + interval '1 minute')`); err != nil {
t.Fatal(err)
}
if execution, err := store.ExecuteMock(ctx, "worker-old-held", "succeeded"); err != nil || execution.WasClaimed {
t.Fatalf("held task was revived after health recovery: execution=%+v err=%v", execution, err)
}
createApprovedDraft(t, store, "gate-runtime-missing", 1, "draft-recovered", "confirmation-recovered")
recovered := Task{
ID: "task-recovered", IdempotencyKey: "idempotency-recovered", AccountID: "gate-runtime-missing", AccountVersion: 1,
DraftID: "draft-recovered", DraftVersion: 1, ConfirmationID: "confirmation-recovered", ConfirmationVersion: 1,
}
if _, inserted, err := store.Enqueue(ctx, recovered); err != nil || !inserted {
t.Fatalf("enqueue recovered task: inserted=%v err=%v", inserted, err)
}
if execution, err := store.ExecuteMock(ctx, "worker-recovered", "succeeded"); err != nil || !execution.WasClaimed || execution.State != "succeeded" {
t.Fatalf("newly confirmed task did not run after recovery: execution=%+v err=%v", execution, err)
}
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-gate-runtime-missing' AND state = 'policy_hold'`, 1)
unconfirmed := approvedTask(30, "account-b", accountB.Version, "draft-b", "")
unconfirmed.ConfirmationVersion = 0
if _, _, err := store.Enqueue(ctx, unconfirmed); err != nil {
@@ -338,12 +380,107 @@ func approvedTask(index int, accountID string, accountVersion int64, draftID, co
}
}
func seedGateTask(t *testing.T, store *Store, suffix, accountStatus, authorizationStatus, exitStatus, runtimeLease string,
binding, versionChanged, confirmationChanged bool) string {
t.Helper()
ctx := context.Background()
accountID, taskID := "gate-"+suffix, "task-gate-"+suffix
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `INSERT INTO credential_reference (id, provider, reference_key) VALUES ($1, 'os_keyring', $2)`,
"credential-"+suffix, "creatorhub/gate-"+suffix); err != nil {
t.Fatal(err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO social_account
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status, status, version)
VALUES ($1, $2, 'mock', $1, 'owned', $3, $4, 1)`, accountID, "credential-"+suffix, authorizationStatus, accountStatus); err != nil {
t.Fatal(err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO content_draft (id, account_id, version, content) VALUES ($1, $2, 1, 'gate test')`,
"draft-gate-"+suffix, accountID); err != nil {
t.Fatal(err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version)
VALUES ($1, $2, 1, $3, 1, 1)`, "confirmation-gate-"+suffix, accountID, "draft-gate-"+suffix); err != nil {
t.Fatal(err)
}
if binding {
exitID := "exit-gate-" + suffix
if _, err := tx.ExecContext(ctx, `INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
VALUES ($1, $1, 'test-gateway', '1', '{"seed":3}')`, accountID); err != nil {
t.Fatal(err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO network_exit (id, protocol, host, port, health_status)
VALUES ($1, 'socks5', '127.0.0.1', 1080, $2)`, exitID, exitStatus); err != nil {
t.Fatal(err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id, version)
VALUES ($1, $2, $2, $3, 1)`, "binding-"+suffix, accountID, exitID); err != nil {
t.Fatal(err)
}
if runtimeLease != "" {
interval := "1 minute"
if runtimeLease == "expired" {
interval = "-1 second"
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until)
VALUES ($1, $2, $3, 1, $4, now() + $5::interval)`, "runtime-gate-"+suffix, accountID,
"binding-"+suffix, "container-gate-"+suffix, interval); err != nil {
t.Fatal(err)
}
}
}
if versionChanged {
if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET version = 2 WHERE account_id = $1`, accountID); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
confirmationVersion := int64(1)
if confirmationChanged {
confirmationVersion = 2
}
if _, _, err := store.Enqueue(ctx, Task{
ID: taskID, IdempotencyKey: "idempotency-gate-" + suffix, AccountID: accountID, AccountVersion: 1,
DraftID: "draft-gate-" + suffix, DraftVersion: 1, ConfirmationID: "confirmation-gate-" + suffix,
ConfirmationVersion: confirmationVersion,
}); err != nil {
t.Fatal(err)
}
return taskID
}
func assertTaskGate(t *testing.T, store *Store, taskID, wantState, wantReason string) {
t.Helper()
var state string
if err := store.db.QueryRow(`SELECT state FROM operation_task WHERE id = $1`, taskID).Scan(&state); err != nil || state != wantState {
t.Fatalf("task gate state: got=%q want=%q err=%v", state, wantState, err)
}
var attempts int
if err := store.db.QueryRow(`SELECT count(*) FROM execution_attempt WHERE task_id = $1`, taskID).Scan(&attempts); err != nil || attempts != 0 {
t.Fatalf("gated task executed: attempts=%d err=%v", attempts, err)
}
var reason string
if err := store.db.QueryRow(`SELECT reason_code FROM audit_event WHERE task_id = $1 AND event_type = 'task_quarantined' ORDER BY id DESC LIMIT 1`, taskID).Scan(&reason); err != nil || reason != wantReason {
t.Fatalf("task gate reason: got=%q want=%q err=%v", reason, wantReason, err)
}
}
func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) {
t.Helper()
for _, migrationFile := range []struct {
version int
name string
}{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}} {
}{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}, {6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}} {
var applied bool
if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil {
t.Fatal(err)