diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go
index 5461c72d..c28e88fa 100644
--- a/agent/internal/agent/agent.go
+++ b/agent/internal/agent/agent.go
@@ -2,6 +2,7 @@ package agent
import (
"sync"
+ "sync/atomic"
"time"
"techulus/cloud-agent/internal/build"
@@ -50,35 +51,44 @@ type ActualState struct {
}
type Agent struct {
- state AgentState
- stateMutex sync.RWMutex
- reconcileRequested chan struct{}
- statusReportRequested chan string
- refreshMutex sync.Mutex
- pendingExpectedStateRefresh bool
- workMutex sync.Mutex
- activeWorkItem *agenthttp.WorkQueueItem
- pendingWorkResults []agenthttp.CompletedWorkItem
- deploymentErrorMutex sync.Mutex
- pendingDeploymentErrors []agenthttp.DeploymentError
- Client *agenthttp.Client
- Reconciler *reconcile.Reconciler
- Config *Config
- PublicIP string
- PrivateIP string
- DataDir string
- expectedState *agenthttp.ExpectedState
- processingStart time.Time
- LogCollector *logs.Collector
- TraefikLogCollector *logs.TraefikCollector
- MetricsSender MetricsSender
- Builder *build.Builder
- isBuilding bool
- buildMutex sync.Mutex
- currentBuildID string
- IsProxy bool
- dnsInSync bool
- DisableDNS bool
+ state AgentState
+ stateMutex sync.RWMutex
+ reconcileRequested chan struct{}
+ statusReportRequested chan string
+ refreshMutex sync.Mutex
+ pendingExpectedStateRefresh bool
+ workMutex sync.Mutex
+ activeWorkItem *agenthttp.WorkQueueItem
+ pendingWorkResults []agenthttp.CompletedWorkItem
+ deploymentErrorMutex sync.Mutex
+ pendingDeploymentErrors []agenthttp.DeploymentError
+ deployLockMutex sync.Mutex
+ deploymentDeployLocks map[string]*sync.Mutex
+ serverlessMutex sync.Mutex
+ pendingServerlessTransitions []agenthttp.ServerlessTransition
+ pendingServerlessSleep map[string]struct{}
+ pendingServerlessWake map[string]struct{}
+ expectedStateMutex sync.RWMutex
+ latestExpectedState *agenthttp.ExpectedState
+ Client *agenthttp.Client
+ Reconciler *reconcile.Reconciler
+ Config *Config
+ PublicIP string
+ PrivateIP string
+ DataDir string
+ expectedState *agenthttp.ExpectedState
+ processingStart time.Time
+ LogCollector *logs.Collector
+ TraefikLogCollector *logs.TraefikCollector
+ MetricsSender MetricsSender
+ Builder *build.Builder
+ isBuilding bool
+ buildMutex sync.Mutex
+ currentBuildID string
+ IsProxy bool
+ serverlessGatewayRunning atomic.Bool
+ dnsInSync bool
+ DisableDNS bool
}
func NewAgent(
@@ -94,21 +104,24 @@ func NewAgent(
disableDNS bool,
) *Agent {
return &Agent{
- state: StateIdle,
- reconcileRequested: make(chan struct{}, 1),
- statusReportRequested: make(chan string, 1),
- Client: client,
- Reconciler: reconciler,
- Config: config,
- PublicIP: publicIP,
- PrivateIP: privateIP,
- DataDir: dataDir,
- LogCollector: logCollector,
- TraefikLogCollector: traefikLogCollector,
- MetricsSender: metricsSender,
- Builder: builder,
- IsProxy: isProxy,
- DisableDNS: disableDNS,
+ state: StateIdle,
+ reconcileRequested: make(chan struct{}, 1),
+ statusReportRequested: make(chan string, 1),
+ Client: client,
+ Reconciler: reconciler,
+ Config: config,
+ PublicIP: publicIP,
+ PrivateIP: privateIP,
+ DataDir: dataDir,
+ LogCollector: logCollector,
+ TraefikLogCollector: traefikLogCollector,
+ MetricsSender: metricsSender,
+ Builder: builder,
+ IsProxy: isProxy,
+ DisableDNS: disableDNS,
+ deploymentDeployLocks: map[string]*sync.Mutex{},
+ pendingServerlessSleep: map[string]struct{}{},
+ pendingServerlessWake: map[string]struct{}{},
}
}
diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go
index 4ab0fc78..6d083884 100644
--- a/agent/internal/agent/drift.go
+++ b/agent/internal/agent/drift.go
@@ -23,6 +23,7 @@ const (
actionStopUnexpectedContainer reconcileActionKind = "stop_unexpected_container"
actionRemoveUnexpectedContainer reconcileActionKind = "remove_unexpected_container"
actionDeployMissingContainer reconcileActionKind = "deploy_missing_container"
+ actionStopExpectedContainer reconcileActionKind = "stop_expected_container"
actionStartContainer reconcileActionKind = "start_container"
actionRedeployContainer reconcileActionKind = "redeploy_container"
actionUpdateDNS reconcileActionKind = "update_dns"
@@ -102,6 +103,8 @@ func (a *Agent) handleIdle() {
if fromCache {
log.Printf("[idle] using cached state (CP unreachable)")
}
+ a.SetLatestExpectedState(expected)
+ a.ReconcilePendingServerlessTransitionsWithExpected(expected, fromCache)
actual, err := a.getActualState()
if err != nil {
@@ -250,6 +253,9 @@ func (a *Agent) planReconcile(expected *agenthttp.ExpectedState, actual *ActualS
for id, exp := range expectedMap {
if _, exists := actualMap[id]; !exists {
+ if desiredContainerState(exp) == "stopped" || a.HasPendingServerlessSleep(id) || a.HasPendingServerlessWake(id) {
+ continue
+ }
expectedContainer := exp
actions = append(actions, reconcileAction{
Kind: actionDeployMissingContainer,
@@ -264,26 +270,43 @@ func (a *Agent) planReconcile(expected *agenthttp.ExpectedState, actual *ActualS
if act, exists := actualMap[id]; exists {
expectedContainer := exp
actualContainer := act
- if act.State == "created" || act.State == "exited" {
+
+ if a.HasPendingServerlessWake(id) {
+ continue
+ }
+ if desiredContainerState(exp) == "stopped" || a.HasPendingServerlessSleep(id) {
+ if shouldStopDesiredStoppedContainer(act.State) {
+ actions = append(actions, reconcileAction{
+ Kind: actionStopExpectedContainer,
+ Description: fmt.Sprintf("STOP %s (desired state: stopped)", exp.Name),
+ DeploymentID: id,
+ Expected: &expectedContainer,
+ Actual: &actualContainer,
+ })
+ }
+ continue
+ }
+
+ if normalizeImage(exp.Image) != normalizeImage(act.Image) {
actions = append(actions, reconcileAction{
- Kind: actionStartContainer,
- Description: fmt.Sprintf("START %s (state: %s)", exp.Name, act.State),
+ Kind: actionRedeployContainer,
+ Description: fmt.Sprintf("REDEPLOY %s (image: %s → %s)", exp.Name, act.Image, exp.Image),
DeploymentID: id,
Expected: &expectedContainer,
Actual: &actualContainer,
})
- } else if act.State != "running" {
+ } else if act.State == "created" || act.State == "exited" {
actions = append(actions, reconcileAction{
- Kind: actionRedeployContainer,
- Description: fmt.Sprintf("REDEPLOY %s (state: %s)", exp.Name, act.State),
+ Kind: actionStartContainer,
+ Description: fmt.Sprintf("START %s (state: %s)", exp.Name, act.State),
DeploymentID: id,
Expected: &expectedContainer,
Actual: &actualContainer,
})
- } else if normalizeImage(exp.Image) != normalizeImage(act.Image) {
+ } else if act.State != "running" {
actions = append(actions, reconcileAction{
Kind: actionRedeployContainer,
- Description: fmt.Sprintf("REDEPLOY %s (image: %s → %s)", exp.Name, act.Image, exp.Image),
+ Description: fmt.Sprintf("REDEPLOY %s (state: %s)", exp.Name, act.State),
DeploymentID: id,
Expected: &expectedContainer,
Actual: &actualContainer,
@@ -389,16 +412,32 @@ func normalizeImage(image string) string {
return image + digest
}
+func desiredContainerState(container agenthttp.ExpectedContainer) string {
+ if container.DesiredState == "stopped" {
+ return "stopped"
+ }
+ return "running"
+}
+
+func shouldStopDesiredStoppedContainer(state string) bool {
+ switch state {
+ case "created", "exited", "stopped":
+ return false
+ default:
+ return true
+ }
+}
+
func (a *Agent) applyReconcileAction(action reconcileAction) error {
log.Printf("[reconcile] %s", action.Description)
switch action.Kind {
- case actionStopOrphanNoDeploymentID, actionStopUnexpectedContainer:
+ case actionStopOrphanNoDeploymentID, actionStopUnexpectedContainer, actionStopExpectedContainer:
if action.Actual == nil {
return fmt.Errorf("missing actual container for %s", action.Kind)
}
if err := container.Stop(action.Actual.ID); err != nil {
- return fmt.Errorf("failed to stop orphan container: %w", err)
+ return fmt.Errorf("failed to stop container: %w", err)
}
return nil
@@ -433,7 +472,7 @@ func (a *Agent) applyReconcileAction(action reconcileAction) error {
if action.Expected == nil {
return fmt.Errorf("missing expected container for %s", action.Kind)
}
- if err := a.Reconciler.Deploy(*action.Expected); err != nil {
+ if err := a.DeployExpectedContainer(*action.Expected); err != nil {
return fmt.Errorf("failed to deploy container: %w", err)
}
return nil
@@ -447,7 +486,7 @@ func (a *Agent) applyReconcileAction(action reconcileAction) error {
if err := container.Stop(action.Actual.ID); err != nil {
log.Printf("[reconcile] warning: failed to stop old container: %v", err)
}
- if err := a.Reconciler.Deploy(*action.Expected); err != nil {
+ if err := a.DeployExpectedContainer(*action.Expected); err != nil {
return fmt.Errorf("failed to redeploy container: %w", err)
}
}
@@ -460,7 +499,7 @@ func (a *Agent) applyReconcileAction(action reconcileAction) error {
if err := container.Stop(action.Actual.ID); err != nil {
log.Printf("[reconcile] warning: failed to stop old container: %v", err)
}
- if err := a.Reconciler.Deploy(*action.Expected); err != nil {
+ if err := a.DeployExpectedContainer(*action.Expected); err != nil {
return fmt.Errorf("failed to redeploy container: %w", err)
}
return nil
diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go
index e7459e0d..98c5169b 100644
--- a/agent/internal/agent/reporting.go
+++ b/agent/internal/agent/reporting.go
@@ -18,6 +18,8 @@ import (
var Version = "dev"
+const serverlessGatewayCapability = "serverless_gateway"
+
var (
agentStartTime = time.Now()
lastHealthCollect time.Time
@@ -51,8 +53,9 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
report.NetworkHealth = health.CollectNetworkHealth("wg0")
report.ContainerHealth = health.CollectContainerHealth()
report.AgentHealth = &agenthttp.AgentHealth{
- Version: Version,
- UptimeSecs: int64(time.Since(agentStartTime).Seconds()),
+ Version: Version,
+ UptimeSecs: int64(time.Since(agentStartTime).Seconds()),
+ Capabilities: a.agentCapabilities(),
}
lastHealthCollect = time.Now()
log.Printf("[health] collected: cpu=%.1f%%, mem=%.1f%%, disk=%.1f%%, network=%v, containers=%d running",
@@ -68,6 +71,9 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
if c.DeploymentID == "" {
continue
}
+ if a.ShouldSuppressServerlessContainerReport(c.DeploymentID) {
+ continue
+ }
status := "stopped"
if c.State == "running" {
@@ -95,6 +101,13 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
return report
}
+func (a *Agent) agentCapabilities() []string {
+ if !a.IsProxy || !a.serverlessGatewayRunning.Load() {
+ return nil
+ }
+ return []string{serverlessGatewayCapability}
+}
+
func (a *Agent) RecordDeploymentError(deploymentID string, err error) {
if deploymentID == "" || err == nil {
return
diff --git a/agent/internal/agent/run.go b/agent/internal/agent/run.go
index cbeb2d62..58ead8ae 100644
--- a/agent/internal/agent/run.go
+++ b/agent/internal/agent/run.go
@@ -6,6 +6,7 @@ import (
"time"
"techulus/cloud-agent/internal/container"
+ "techulus/cloud-agent/internal/serverless"
)
func (a *Agent) Run(ctx context.Context) {
@@ -32,6 +33,15 @@ func (a *Agent) Run(ctx context.Context) {
a.TraefikLogCollector.Start()
}
+ if a.IsProxy {
+ gateway := serverless.NewGateway(a)
+ if err := gateway.Start(ctx); err != nil {
+ log.Printf("[serverless-gateway] failed to start: %v", err)
+ } else {
+ a.serverlessGatewayRunning.Store(true)
+ }
+ }
+
var cleanupTickerC <-chan time.Time
if a.Builder != nil {
cleanupTicker := time.NewTicker(BuildCleanupInterval)
@@ -109,12 +119,14 @@ func (a *Agent) reportStatus(reason string) {
report := a.BuildStatusReport(true)
reportedDeploymentErrorCount := len(report.DeploymentErrors)
completed, active := a.SnapshotWorkStatus()
- response, err := a.Client.ReportStatus(report, completed, active)
+ serverlessTransitions := a.SnapshotServerlessTransitions()
+ response, err := a.Client.ReportStatus(report, completed, active, serverlessTransitions)
if err != nil {
log.Printf("[status] failed to report (%s): %v", reason, err)
return
}
a.ClearReportedDeploymentErrors(reportedDeploymentErrorCount)
+ a.ClearReportedServerlessTransitions(len(serverlessTransitions))
a.AcknowledgeWorkResults(response.AcceptedWorkItemResults, response.RejectedWorkItemResults)
a.LogRejectedActiveWorkItems(response.RejectedActiveWorkItems)
a.AcceptLeasedWorkItems(response.WorkItems)
diff --git a/agent/internal/agent/serverless.go b/agent/internal/agent/serverless.go
new file mode 100644
index 00000000..9b033cc5
--- /dev/null
+++ b/agent/internal/agent/serverless.go
@@ -0,0 +1,243 @@
+package agent
+
+import (
+ "log"
+ "sync"
+
+ "techulus/cloud-agent/internal/container"
+ agenthttp "techulus/cloud-agent/internal/http"
+)
+
+func (a *Agent) SetLatestExpectedState(state *agenthttp.ExpectedState) {
+ a.expectedStateMutex.Lock()
+ defer a.expectedStateMutex.Unlock()
+ a.latestExpectedState = state
+}
+
+func (a *Agent) ExpectedState() *agenthttp.ExpectedState {
+ a.expectedStateMutex.RLock()
+ defer a.expectedStateMutex.RUnlock()
+ return a.latestExpectedState
+}
+
+func (a *Agent) DeployServerlessContainer(expected agenthttp.ExpectedContainer) error {
+ return a.withDeploymentDeployLock(expected.DeploymentID, func() error {
+ containers, err := container.List()
+ if err == nil {
+ for _, actual := range containers {
+ if actual.DeploymentID != expected.DeploymentID {
+ continue
+ }
+ if normalizeImage(actual.Image) != normalizeImage(expected.Image) {
+ log.Printf(
+ "[serverless] recreate deployment %s because image changed (%s -> %s)",
+ Truncate(expected.DeploymentID, 8),
+ actual.Image,
+ expected.Image,
+ )
+ return a.Reconciler.Deploy(expected)
+ }
+ if actual.State == "running" {
+ return nil
+ }
+ log.Printf(
+ "[serverless] starting stopped container %s for deployment %s",
+ Truncate(actual.ID, 12),
+ Truncate(expected.DeploymentID, 8),
+ )
+ if err := container.Start(actual.ID); err != nil {
+ log.Printf(
+ "[serverless] start failed for deployment %s, recreating: %v",
+ Truncate(expected.DeploymentID, 8),
+ err,
+ )
+ return a.Reconciler.Deploy(expected)
+ }
+ return nil
+ }
+ }
+ return a.Reconciler.Deploy(expected)
+ })
+}
+
+func (a *Agent) DeployExpectedContainer(expected agenthttp.ExpectedContainer) error {
+ return a.withDeploymentDeployLock(expected.DeploymentID, func() error {
+ containers, err := container.List()
+ if err == nil {
+ for _, actual := range containers {
+ if actual.DeploymentID != expected.DeploymentID || actual.State != "running" {
+ continue
+ }
+ if normalizeImage(actual.Image) == normalizeImage(expected.Image) {
+ return nil
+ }
+ }
+ }
+ return a.Reconciler.Deploy(expected)
+ })
+}
+
+func (a *Agent) withDeploymentDeployLock(deploymentID string, fn func() error) error {
+ a.deployLockMutex.Lock()
+ if a.deploymentDeployLocks == nil {
+ a.deploymentDeployLocks = map[string]*sync.Mutex{}
+ }
+ lock, ok := a.deploymentDeployLocks[deploymentID]
+ if !ok {
+ lock = &sync.Mutex{}
+ a.deploymentDeployLocks[deploymentID] = lock
+ }
+ a.deployLockMutex.Unlock()
+
+ lock.Lock()
+ defer lock.Unlock()
+ return fn()
+}
+
+func (a *Agent) StopServerlessContainer(containerID string) error {
+ return container.Stop(containerID)
+}
+
+func (a *Agent) ListServerlessContainers() ([]container.Container, error) {
+ return container.List()
+}
+
+func (a *Agent) GetServerlessContainerHealth(containerID string) string {
+ return container.GetHealthStatus(containerID)
+}
+
+func (a *Agent) QueueServerlessTransition(transition agenthttp.ServerlessTransition) {
+ if transition.Type == "" || transition.DeploymentID == "" {
+ return
+ }
+
+ a.serverlessMutex.Lock()
+ if a.pendingServerlessSleep == nil {
+ a.pendingServerlessSleep = map[string]struct{}{}
+ }
+ if a.pendingServerlessWake == nil {
+ a.pendingServerlessWake = map[string]struct{}{}
+ }
+ switch transition.Type {
+ case "sleep":
+ a.pendingServerlessSleep[transition.DeploymentID] = struct{}{}
+ delete(a.pendingServerlessWake, transition.DeploymentID)
+ case "wake_started":
+ delete(a.pendingServerlessSleep, transition.DeploymentID)
+ a.pendingServerlessWake[transition.DeploymentID] = struct{}{}
+ case "wake_failed":
+ delete(a.pendingServerlessWake, transition.DeploymentID)
+ }
+ a.pendingServerlessTransitions = append(
+ a.pendingServerlessTransitions,
+ transition,
+ )
+ a.serverlessMutex.Unlock()
+
+ a.RequestStatusReport("serverless " + transition.Type)
+}
+
+func (a *Agent) SnapshotServerlessTransitions() []agenthttp.ServerlessTransition {
+ a.serverlessMutex.Lock()
+ defer a.serverlessMutex.Unlock()
+ return append([]agenthttp.ServerlessTransition(nil), a.pendingServerlessTransitions...)
+}
+
+func (a *Agent) ClearReportedServerlessTransitions(count int) {
+ if count <= 0 {
+ return
+ }
+
+ a.serverlessMutex.Lock()
+ defer a.serverlessMutex.Unlock()
+ if count > len(a.pendingServerlessTransitions) {
+ count = len(a.pendingServerlessTransitions)
+ }
+ a.pendingServerlessTransitions = a.pendingServerlessTransitions[count:]
+}
+
+func (a *Agent) HasPendingServerlessSleep(deploymentID string) bool {
+ a.serverlessMutex.Lock()
+ defer a.serverlessMutex.Unlock()
+ _, ok := a.pendingServerlessSleep[deploymentID]
+ return ok
+}
+
+func (a *Agent) HasPendingServerlessWake(deploymentID string) bool {
+ a.serverlessMutex.Lock()
+ defer a.serverlessMutex.Unlock()
+ _, ok := a.pendingServerlessWake[deploymentID]
+ return ok
+}
+
+func (a *Agent) ShouldSuppressServerlessContainerReport(deploymentID string) bool {
+ a.serverlessMutex.Lock()
+ _, pendingSleep := a.pendingServerlessSleep[deploymentID]
+ _, pendingWake := a.pendingServerlessWake[deploymentID]
+ a.serverlessMutex.Unlock()
+
+ if pendingWake {
+ return false
+ }
+ if pendingSleep {
+ return true
+ }
+
+ a.expectedStateMutex.RLock()
+ defer a.expectedStateMutex.RUnlock()
+ if a.latestExpectedState == nil {
+ return false
+ }
+ for _, expected := range a.latestExpectedState.Containers {
+ if expected.DeploymentID == deploymentID {
+ return expected.DesiredState == "stopped"
+ }
+ }
+ return false
+}
+
+func (a *Agent) ReconcilePendingServerlessTransitionsWithExpected(state *agenthttp.ExpectedState, fromCache bool) {
+ if state == nil || fromCache {
+ return
+ }
+
+ desiredByDeploymentID := map[string]string{}
+ for _, expected := range state.Containers {
+ desiredByDeploymentID[expected.DeploymentID] = expected.DesiredState
+ }
+
+ a.serverlessMutex.Lock()
+ defer a.serverlessMutex.Unlock()
+ pendingSleepTransitions := map[string]struct{}{}
+ pendingWakeTransitions := map[string]struct{}{}
+ for _, transition := range a.pendingServerlessTransitions {
+ switch transition.Type {
+ case "sleep":
+ pendingSleepTransitions[transition.DeploymentID] = struct{}{}
+ case "wake_started":
+ pendingWakeTransitions[transition.DeploymentID] = struct{}{}
+ }
+ }
+
+ for deploymentID := range a.pendingServerlessSleep {
+ if _, stillReporting := pendingSleepTransitions[deploymentID]; stillReporting {
+ continue
+ }
+ delete(a.pendingServerlessSleep, deploymentID)
+ desiredState, ok := desiredByDeploymentID[deploymentID]
+ if ok && desiredState == "running" {
+ log.Printf("[serverless] sleep transition for deployment %s is not reflected in expected state; allowing reconcile", Truncate(deploymentID, 8))
+ }
+ }
+
+ for deploymentID := range a.pendingServerlessWake {
+ if _, stillReporting := pendingWakeTransitions[deploymentID]; stillReporting {
+ continue
+ }
+ delete(a.pendingServerlessWake, deploymentID)
+ desiredState, ok := desiredByDeploymentID[deploymentID]
+ if !ok || desiredState != "running" {
+ log.Printf("[serverless] wake transition for deployment %s is not reflected in expected state; allowing reconcile", Truncate(deploymentID, 8))
+ }
+ }
+}
diff --git a/agent/internal/agent/serverless_test.go b/agent/internal/agent/serverless_test.go
new file mode 100644
index 00000000..683570ec
--- /dev/null
+++ b/agent/internal/agent/serverless_test.go
@@ -0,0 +1,86 @@
+package agent
+
+import (
+ "slices"
+ "testing"
+
+ "techulus/cloud-agent/internal/container"
+ agenthttp "techulus/cloud-agent/internal/http"
+)
+
+func TestPendingServerlessWakeDoesNotStopStaleStoppedExpectedContainer(t *testing.T) {
+ agent := &Agent{
+ DisableDNS: true,
+ pendingServerlessSleep: map[string]struct{}{},
+ pendingServerlessWake: map[string]struct{}{
+ "dep_serverless": {},
+ },
+ }
+ expected := &agenthttp.ExpectedState{
+ Containers: []agenthttp.ExpectedContainer{
+ {
+ DeploymentID: "dep_serverless",
+ ServiceID: "svc_1",
+ Name: "svc_1-dep_serverless",
+ DesiredState: "stopped",
+ Image: "nginx",
+ },
+ },
+ }
+ actual := &ActualState{
+ Containers: []container.Container{
+ {
+ ID: "ctr_serverless",
+ Name: "svc_1-dep_serverless",
+ Image: "nginx",
+ State: "running",
+ DeploymentID: "dep_serverless",
+ },
+ },
+ }
+
+ for _, action := range agent.planReconcile(expected, actual) {
+ if action.DeploymentID == "dep_serverless" {
+ t.Fatalf("planReconcile returned action for pending wake: %+v", action)
+ }
+ }
+}
+
+func TestServerlessGatewayCapabilityRequiresStartedGateway(t *testing.T) {
+ agent := &Agent{IsProxy: true}
+
+ if slices.Contains(agent.agentCapabilities(), serverlessGatewayCapability) {
+ t.Fatal("proxy reported serverless gateway capability before gateway start")
+ }
+
+ agent.serverlessGatewayRunning.Store(true)
+ if !slices.Contains(agent.agentCapabilities(), serverlessGatewayCapability) {
+ t.Fatal("proxy did not report serverless gateway capability after gateway start")
+ }
+
+ agent.IsProxy = false
+ if slices.Contains(agent.agentCapabilities(), serverlessGatewayCapability) {
+ t.Fatal("worker reported serverless gateway capability")
+ }
+}
+
+func TestPendingServerlessWakeDoesNotSuppressContainerReport(t *testing.T) {
+ agent := &Agent{
+ pendingServerlessSleep: map[string]struct{}{},
+ pendingServerlessWake: map[string]struct{}{
+ "dep_serverless": {},
+ },
+ latestExpectedState: &agenthttp.ExpectedState{
+ Containers: []agenthttp.ExpectedContainer{
+ {
+ DeploymentID: "dep_serverless",
+ DesiredState: "stopped",
+ },
+ },
+ },
+ }
+
+ if agent.ShouldSuppressServerlessContainerReport("dep_serverless") {
+ t.Fatal("container report was suppressed while wake transition is pending")
+ }
+}
diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go
index 7189f791..ec529e9a 100644
--- a/agent/internal/http/client.go
+++ b/agent/internal/http/client.go
@@ -73,6 +73,7 @@ type ExpectedContainer struct {
ServiceID string `json:"serviceId"`
ServiceName string `json:"serviceName"`
Name string `json:"name"`
+ DesiredState string `json:"desiredState"`
Image string `json:"image"`
IPAddress string `json:"ipAddress"`
Ports []PortMapping `json:"ports"`
@@ -94,6 +95,25 @@ type Upstream struct {
Weight int `json:"weight"`
}
+type ServerlessUpstream struct {
+ DeploymentID string `json:"deploymentId"`
+ ServerID string `json:"serverId"`
+ Url string `json:"url"`
+ Local bool `json:"local"`
+ AlwaysOn bool `json:"alwaysOn"`
+}
+
+type ServerlessRoute struct {
+ ServiceID string `json:"serviceId"`
+ Domain string `json:"domain"`
+ Port int `json:"port"`
+ SleepAfterSeconds int `json:"sleepAfterSeconds"`
+ WakeTimeoutSeconds int `json:"wakeTimeoutSeconds"`
+ MinReadyReplicas int `json:"minReadyReplicas"`
+ LocalDeploymentIDs []string `json:"localDeploymentIds"`
+ Upstreams []ServerlessUpstream `json:"upstreams"`
+}
+
type TraefikRoute struct {
ID string `json:"id"`
Domain string `json:"domain"`
@@ -138,6 +158,9 @@ type ExpectedState struct {
Dns struct {
Records []DnsRecord `json:"records"`
} `json:"dns"`
+ Serverless struct {
+ Routes []ServerlessRoute `json:"routes"`
+ } `json:"serverless"`
Traefik struct {
HttpRoutes []TraefikRoute `json:"httpRoutes"`
TCPRoutes []TraefikTCPRoute `json:"tcpRoutes"`
@@ -245,8 +268,9 @@ type Resources struct {
}
type AgentHealth struct {
- Version string `json:"version"`
- UptimeSecs int64 `json:"uptimeSecs"`
+ Version string `json:"version"`
+ UptimeSecs int64 `json:"uptimeSecs"`
+ Capabilities []string `json:"capabilities,omitempty"`
}
type StatusReport struct {
@@ -274,6 +298,13 @@ type ActiveWorkItem struct {
Attempt int `json:"attempt"`
}
+type ServerlessTransition struct {
+ Type string `json:"type"`
+ DeploymentID string `json:"deploymentId"`
+ ContainerID string `json:"containerId,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
type BuildDetails struct {
Build struct {
ID string `json:"id"`
@@ -377,7 +408,7 @@ type StatusResponse struct {
WorkItems []WorkQueueItem `json:"workItems"`
}
-func (c *Client) ReportStatus(report *StatusReport, completed []CompletedWorkItem, active []ActiveWorkItem) (*StatusResponse, error) {
+func (c *Client) ReportStatus(report *StatusReport, completed []CompletedWorkItem, active []ActiveWorkItem, serverlessTransitions []ServerlessTransition) (*StatusResponse, error) {
payload := map[string]interface{}{
"statusReport": report,
}
@@ -387,6 +418,9 @@ func (c *Client) ReportStatus(report *StatusReport, completed []CompletedWorkIte
if len(active) > 0 {
payload["activeWorkItems"] = active
}
+ if len(serverlessTransitions) > 0 {
+ payload["serverlessTransitions"] = serverlessTransitions
+ }
body, err := json.Marshal(payload)
if err != nil {
diff --git a/agent/internal/serverless/gateway.go b/agent/internal/serverless/gateway.go
new file mode 100644
index 00000000..163ddbcc
--- /dev/null
+++ b/agent/internal/serverless/gateway.go
@@ -0,0 +1,988 @@
+package serverless
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "techulus/cloud-agent/internal/container"
+ agenthttp "techulus/cloud-agent/internal/http"
+)
+
+const (
+ GatewayPort = 18080
+ upstreamCacheTTL = 10 * time.Second
+)
+
+var (
+ wakePollInterval = 500 * time.Millisecond
+ idleTimerSeedInterval = 15 * time.Second
+)
+
+type Gateway struct {
+ runtime Runtime
+ counter uint64
+ server *http.Server
+ mu sync.Mutex
+ activityMu sync.Mutex
+
+ upstreamCache map[string]cachedUpstreams
+ wakeCalls map[string]*wakeCall
+ activities map[string]*activityState
+}
+
+type Runtime interface {
+ ExpectedState() *agenthttp.ExpectedState
+ DeployServerlessContainer(agenthttp.ExpectedContainer) error
+ StopServerlessContainer(containerID string) error
+ ListServerlessContainers() ([]container.Container, error)
+ GetServerlessContainerHealth(containerID string) string
+ QueueServerlessTransition(agenthttp.ServerlessTransition)
+}
+
+type cachedUpstreams struct {
+ upstreams []agenthttp.ServerlessUpstream
+ expiresAt time.Time
+}
+
+type wakeCall struct {
+ done chan struct{}
+ upstreams []agenthttp.ServerlessUpstream
+ err error
+}
+
+type activityState struct {
+ mu sync.Mutex
+ activeRequests int
+ sleepTimer *time.Timer
+}
+
+func NewGateway(runtime Runtime) *Gateway {
+ return &Gateway{
+ runtime: runtime,
+ upstreamCache: map[string]cachedUpstreams{},
+ wakeCalls: map[string]*wakeCall{},
+ activities: map[string]*activityState{},
+ }
+}
+
+func (g *Gateway) Start(ctx context.Context) error {
+ addr := fmt.Sprintf("127.0.0.1:%d", GatewayPort)
+ listener, err := net.Listen("tcp", addr)
+ if err != nil {
+ return fmt.Errorf("failed to listen on %s: %w", addr, err)
+ }
+
+ g.server = &http.Server{
+ Handler: g,
+ ReadHeaderTimeout: 30 * time.Second,
+ }
+
+ go func() {
+ <-ctx.Done()
+ g.stopAllActivities()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := g.server.Shutdown(shutdownCtx); err != nil {
+ log.Printf("[serverless-gateway] shutdown error: %v", err)
+ }
+ }()
+
+ go func() {
+ log.Printf("[serverless-gateway] listening on %s", addr)
+ if err := g.server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Printf("[serverless-gateway] server error: %v", err)
+ }
+ }()
+
+ go func() {
+ g.seedIdleTimers()
+ ticker := time.NewTicker(idleTimerSeedInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ g.seedIdleTimers()
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ host := normalizeHost(r.Host)
+ if host == "" {
+ http.Error(w, "missing host", http.StatusBadRequest)
+ return
+ }
+
+ route := findRoute(g.runtime.ExpectedState(), host)
+ if route == nil {
+ log.Printf("[serverless-gateway] no route metadata for host %s", host)
+ http.Error(w, "service unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ activityKey := serviceActivityKey(route.ServiceID)
+ g.beginActivity(activityKey)
+ defer g.endActivity(activityKey, route.ServiceID, route.SleepAfterSeconds)
+
+ upstreams, err := g.getUpstreams(r.Context(), host)
+ if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return
+ }
+ log.Printf("[serverless-gateway] wake failed for host %s: %v", host, err)
+ http.Error(w, "service unavailable", http.StatusServiceUnavailable)
+ return
+ }
+
+ if len(upstreams) == 0 {
+ log.Printf("[serverless-gateway] no upstreams for host %s", host)
+ http.Error(w, "service unavailable", http.StatusServiceUnavailable)
+ return
+ }
+
+ upstream := upstreams[g.nextIndex(len(upstreams))]
+ target, err := url.Parse("http://" + upstream.Url)
+ if err != nil {
+ log.Printf("[serverless-gateway] invalid upstream %q for host %s: %v", upstream.Url, host, err)
+ http.Error(w, "bad upstream", http.StatusBadGateway)
+ return
+ }
+
+ proxy := httputil.NewSingleHostReverseProxy(target)
+ originalDirector := proxy.Director
+ originalHost := r.Host
+ proxy.Director = func(req *http.Request) {
+ originalDirector(req)
+ req.Host = originalHost
+ req.Header.Set("X-Forwarded-Host", originalHost)
+ req.Header.Set("X-Forwarded-Proto", forwardedProto(r))
+ }
+ proxy.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) {
+ log.Printf("[serverless-gateway] proxy error for host %s to %s: %v", host, upstream.Url, err)
+ g.evictUpstreams(host)
+ http.Error(w, "bad gateway", http.StatusBadGateway)
+ }
+ proxy.ServeHTTP(w, r)
+}
+
+func (g *Gateway) getUpstreams(ctx context.Context, host string) ([]agenthttp.ServerlessUpstream, error) {
+ if upstreams, ok := g.cachedUpstreams(host); ok {
+ return upstreams, nil
+ }
+
+ route := findRoute(g.runtime.ExpectedState(), host)
+ if route == nil {
+ return nil, fmt.Errorf("no serverless route metadata for host %s", host)
+ }
+ wakeKey := routeWakeKey(route)
+ call, owner := g.beginWake(wakeKey)
+ if owner {
+ go func() {
+ upstreams, err := g.resolveUpstreams(host)
+ g.finishWake(wakeKey, call, upstreams, err)
+ }()
+ }
+
+ select {
+ case <-call.done:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+
+ if call.err != nil {
+ return nil, call.err
+ }
+ upstreams := cloneUpstreams(call.upstreams)
+ if len(upstreams) > 0 {
+ g.cacheUpstreams(host, upstreams)
+ }
+ return upstreams, nil
+}
+
+func (g *Gateway) resolveUpstreams(host string) ([]agenthttp.ServerlessUpstream, error) {
+ state := g.runtime.ExpectedState()
+ route := findRoute(state, host)
+ if route == nil {
+ return nil, fmt.Errorf("no serverless route metadata for host %s", host)
+ }
+
+ ready, sleepingLocalIDs, err := g.readyUpstreams(route, state)
+ if err != nil {
+ return nil, err
+ }
+ if len(sleepingLocalIDs) == 0 {
+ if len(ready) > 0 {
+ return ready, nil
+ }
+ return nil, fmt.Errorf("no ready upstreams for host %s", host)
+ }
+
+ wakeStartedAt := time.Now()
+ if len(ready) >= max(1, route.MinReadyReplicas) || (len(ready) > 0 && hasAlwaysOnUpstream(ready)) {
+ log.Printf(
+ "[serverless-gateway] wake requested host=%s deployments=%d ready_upstreams=%d mode=background",
+ host,
+ len(sleepingLocalIDs),
+ len(ready),
+ )
+ go func() {
+ startedIDs := g.wakeLocalDeployments(route, state, sleepingLocalIDs)
+ if len(startedIDs) > 0 {
+ g.waitForWokenDeployments(route, route.WakeTimeoutSeconds, wakeStartedAt, startedIDs)
+ }
+ }()
+ return ready, nil
+ }
+
+ log.Printf(
+ "[serverless-gateway] wake requested host=%s deployments=%d ready_upstreams=%d mode=blocking timeout=%s",
+ host,
+ len(sleepingLocalIDs),
+ len(ready),
+ wakeTimeout(route.WakeTimeoutSeconds),
+ )
+ startedIDs := g.wakeLocalDeployments(route, state, sleepingLocalIDs)
+ if len(startedIDs) == 0 {
+ return nil, fmt.Errorf("failed to start local serverless wake")
+ }
+ return g.waitForReadyUpstreams(route, route.WakeTimeoutSeconds, wakeStartedAt, startedIDs)
+}
+
+func (g *Gateway) readyUpstreams(route *agenthttp.ServerlessRoute, state *agenthttp.ExpectedState) ([]agenthttp.ServerlessUpstream, []string, error) {
+ actualContainers, err := g.runtime.ListServerlessContainers()
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to list local containers: %w", err)
+ }
+
+ expectedByDeploymentID := expectedContainersByDeploymentID(state)
+ actualByDeploymentID := actualContainersByDeploymentID(actualContainers)
+ localIDs := map[string]struct{}{}
+ for _, deploymentID := range route.LocalDeploymentIDs {
+ localIDs[deploymentID] = struct{}{}
+ }
+
+ upstreamsByURL := map[string]agenthttp.ServerlessUpstream{}
+ for _, upstream := range route.Upstreams {
+ if upstream.Local {
+ continue
+ }
+ upstreamsByURL[upstream.Url] = upstream
+ }
+
+ sleepingLocalIDs := []string{}
+ for _, deploymentID := range route.LocalDeploymentIDs {
+ expected, ok := expectedByDeploymentID[deploymentID]
+ if !ok {
+ continue
+ }
+ actual, isRunning := actualByDeploymentID[deploymentID]
+ if isRunning && g.isContainerReady(actual, expected) {
+ if upstream, ok := localUpstream(route, expected); ok {
+ upstreamsByURL[upstream.Url] = upstream
+ }
+ continue
+ }
+ if expected.DesiredState == "stopped" {
+ sleepingLocalIDs = append(sleepingLocalIDs, deploymentID)
+ }
+ }
+
+ upstreams := make([]agenthttp.ServerlessUpstream, 0, len(upstreamsByURL))
+ for _, upstream := range upstreamsByURL {
+ if upstream.Local {
+ if _, ok := localIDs[upstream.DeploymentID]; !ok {
+ continue
+ }
+ }
+ upstreams = append(upstreams, upstream)
+ }
+ sortUpstreams(upstreams)
+ return upstreams, sleepingLocalIDs, nil
+}
+
+func (g *Gateway) wakeLocalDeployments(route *agenthttp.ServerlessRoute, state *agenthttp.ExpectedState, deploymentIDs []string) []string {
+ expectedByDeploymentID := expectedContainersByDeploymentID(state)
+ startedIDs := []string{}
+ for _, deploymentID := range deploymentIDs {
+ expected, ok := expectedByDeploymentID[deploymentID]
+ if !ok {
+ log.Printf(
+ "[serverless-gateway] wake skipped host=%s deployment=%s reason=missing_expected_state",
+ route.Domain,
+ deploymentID,
+ )
+ continue
+ }
+ deployStartedAt := time.Now()
+ log.Printf(
+ "[serverless-gateway] wake starting host=%s deployment=%s service=%s container=%s",
+ route.Domain,
+ deploymentID,
+ expected.ServiceID,
+ expected.Name,
+ )
+ g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{
+ Type: "wake_started",
+ DeploymentID: deploymentID,
+ })
+ if err := g.runtime.DeployServerlessContainer(expected); err != nil {
+ log.Printf(
+ "[serverless-gateway] wake failed host=%s deployment=%s service=%s latency=%s error=%v",
+ route.Domain,
+ deploymentID,
+ expected.ServiceID,
+ roundDuration(time.Since(deployStartedAt)),
+ err,
+ )
+ g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{
+ Type: "wake_failed",
+ DeploymentID: deploymentID,
+ Error: err.Error(),
+ })
+ continue
+ }
+ startedIDs = append(startedIDs, deploymentID)
+ log.Printf(
+ "[serverless-gateway] wake container started host=%s deployment=%s service=%s latency=%s",
+ route.Domain,
+ deploymentID,
+ expected.ServiceID,
+ roundDuration(time.Since(deployStartedAt)),
+ )
+ }
+ g.evictUpstreams(route.Domain)
+ return startedIDs
+}
+
+func (g *Gateway) waitForReadyUpstreams(route *agenthttp.ServerlessRoute, wakeTimeoutSeconds int, startedAt time.Time, wokenDeploymentIDs []string) ([]agenthttp.ServerlessUpstream, error) {
+ timeout := wakeTimeout(wakeTimeoutSeconds)
+ deadline := startedAt.Add(timeout)
+
+ for {
+ state := g.runtime.ExpectedState()
+ ready, sleepingLocalIDs, err := g.readyUpstreams(route, state)
+ if err != nil {
+ return nil, err
+ }
+ if len(ready) >= max(1, route.MinReadyReplicas) || (len(ready) > 0 && len(sleepingLocalIDs) == 0) {
+ pendingIDs := pendingWakeDeploymentIDs(wokenDeploymentIDs, ready)
+ if len(pendingIDs) > 0 {
+ go g.waitForWokenDeployments(route, route.WakeTimeoutSeconds, startedAt, pendingIDs)
+ }
+ log.Printf(
+ "[serverless-gateway] wake ready host=%s upstreams=%d latency=%s",
+ route.Domain,
+ len(ready),
+ roundDuration(time.Since(startedAt)),
+ )
+ return ready, nil
+ }
+ if time.Now().After(deadline) {
+ pendingIDs := pendingWakeDeploymentIDs(wokenDeploymentIDs, ready)
+ g.queueWakeTimeouts(route, pendingIDs, startedAt)
+ if len(ready) > 0 {
+ log.Printf(
+ "[serverless-gateway] wake partially ready host=%s upstreams=%d latency=%s",
+ route.Domain,
+ len(ready),
+ roundDuration(time.Since(startedAt)),
+ )
+ return ready, nil
+ }
+ log.Printf(
+ "[serverless-gateway] wake timed out host=%s latency=%s",
+ route.Domain,
+ roundDuration(time.Since(startedAt)),
+ )
+ return nil, fmt.Errorf("timed out waiting for local serverless wake")
+ }
+ time.Sleep(wakePollInterval)
+ }
+}
+
+func (g *Gateway) waitForWokenDeployments(route *agenthttp.ServerlessRoute, wakeTimeoutSeconds int, startedAt time.Time, wokenDeploymentIDs []string) {
+ timeout := wakeTimeout(wakeTimeoutSeconds)
+ deadline := startedAt.Add(timeout)
+
+ for {
+ state := g.runtime.ExpectedState()
+ ready, _, err := g.readyUpstreams(route, state)
+ if err != nil {
+ log.Printf("[serverless-gateway] wake monitor failed host=%s error=%v", route.Domain, err)
+ return
+ }
+ pendingIDs := pendingWakeDeploymentIDs(wokenDeploymentIDs, ready)
+ if len(pendingIDs) == 0 {
+ log.Printf(
+ "[serverless-gateway] wake ready host=%s deployments=%d latency=%s",
+ route.Domain,
+ len(wokenDeploymentIDs),
+ roundDuration(time.Since(startedAt)),
+ )
+ return
+ }
+ if time.Now().After(deadline) {
+ g.queueWakeTimeouts(route, pendingIDs, startedAt)
+ return
+ }
+ time.Sleep(wakePollInterval)
+ }
+}
+
+func pendingWakeDeploymentIDs(wokenDeploymentIDs []string, ready []agenthttp.ServerlessUpstream) []string {
+ readyIDs := map[string]struct{}{}
+ for _, upstream := range ready {
+ if upstream.Local {
+ readyIDs[upstream.DeploymentID] = struct{}{}
+ }
+ }
+
+ pendingIDs := []string{}
+ for _, deploymentID := range wokenDeploymentIDs {
+ if _, ok := readyIDs[deploymentID]; !ok {
+ pendingIDs = append(pendingIDs, deploymentID)
+ }
+ }
+ return pendingIDs
+}
+
+func (g *Gateway) queueWakeTimeouts(route *agenthttp.ServerlessRoute, deploymentIDs []string, startedAt time.Time) {
+ for _, deploymentID := range deploymentIDs {
+ errMessage := fmt.Sprintf("timed out waiting %s for local serverless wake", roundDuration(time.Since(startedAt)))
+ log.Printf(
+ "[serverless-gateway] wake timed out host=%s deployment=%s service=%s latency=%s",
+ route.Domain,
+ deploymentID,
+ route.ServiceID,
+ roundDuration(time.Since(startedAt)),
+ )
+ g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{
+ Type: "wake_failed",
+ DeploymentID: deploymentID,
+ Error: errMessage,
+ })
+ }
+}
+
+func (g *Gateway) isContainerReady(actual container.Container, expected agenthttp.ExpectedContainer) bool {
+ if actual.State != "running" {
+ return false
+ }
+ if expected.HealthCheck == nil || expected.HealthCheck.Cmd == "" {
+ return true
+ }
+ health := g.runtime.GetServerlessContainerHealth(actual.ID)
+ return health == "healthy" || health == "none"
+}
+
+func (g *Gateway) cachedUpstreams(host string) ([]agenthttp.ServerlessUpstream, bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ cached, ok := g.upstreamCache[host]
+ if !ok || time.Now().After(cached.expiresAt) {
+ if ok {
+ delete(g.upstreamCache, host)
+ }
+ return nil, false
+ }
+
+ return cloneUpstreams(cached.upstreams), true
+}
+
+func (g *Gateway) beginWake(wakeKey string) (*wakeCall, bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ if call, ok := g.wakeCalls[wakeKey]; ok {
+ return call, false
+ }
+
+ call := &wakeCall{done: make(chan struct{})}
+ g.wakeCalls[wakeKey] = call
+ return call, true
+}
+
+func (g *Gateway) finishWake(wakeKey string, call *wakeCall, upstreams []agenthttp.ServerlessUpstream, err error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ call.upstreams = cloneUpstreams(upstreams)
+ call.err = err
+ delete(g.wakeCalls, wakeKey)
+ close(call.done)
+}
+
+func (g *Gateway) cacheUpstreams(host string, upstreams []agenthttp.ServerlessUpstream) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.upstreamCache[host] = cachedUpstreams{
+ upstreams: cloneUpstreams(upstreams),
+ expiresAt: time.Now().Add(upstreamCacheTTL),
+ }
+}
+
+func (g *Gateway) evictUpstreams(host string) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ delete(g.upstreamCache, host)
+}
+
+func (g *Gateway) evictServiceUpstreams(serviceID string) {
+ state := g.runtime.ExpectedState()
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if state == nil {
+ g.upstreamCache = map[string]cachedUpstreams{}
+ return
+ }
+ for _, route := range state.Serverless.Routes {
+ if route.ServiceID == serviceID {
+ delete(g.upstreamCache, normalizeHost(route.Domain))
+ }
+ }
+}
+
+func (g *Gateway) seedIdleTimers() {
+ state := g.runtime.ExpectedState()
+ if state == nil {
+ return
+ }
+ actualContainers, err := g.runtime.ListServerlessContainers()
+ if err != nil {
+ log.Printf("[serverless-gateway] failed to seed idle timers: %v", err)
+ return
+ }
+ actualByDeploymentID := actualContainersByDeploymentID(actualContainers)
+ seenServices := map[string]struct{}{}
+ for _, route := range state.Serverless.Routes {
+ if _, seen := seenServices[route.ServiceID]; seen {
+ continue
+ }
+ seenServices[route.ServiceID] = struct{}{}
+ if !hasRunningLocalDeployment(route.LocalDeploymentIDs, actualByDeploymentID) {
+ continue
+ }
+ g.scheduleSleepTimer(serviceActivityKey(route.ServiceID), route.ServiceID, route.SleepAfterSeconds)
+ }
+}
+
+func (g *Gateway) scheduleSleepTimer(key string, serviceID string, sleepAfterSeconds int) {
+ activity := g.activity(key)
+ activity.mu.Lock()
+ defer activity.mu.Unlock()
+ if activity.activeRequests > 0 || activity.sleepTimer != nil {
+ return
+ }
+ delay := sleepDelay(sleepAfterSeconds)
+ activity.sleepTimer = time.AfterFunc(delay, func() {
+ g.sleepService(serviceID)
+ })
+}
+
+func (g *Gateway) activity(key string) *activityState {
+ g.activityMu.Lock()
+ defer g.activityMu.Unlock()
+
+ activity, ok := g.activities[key]
+ if !ok {
+ activity = &activityState{}
+ g.activities[key] = activity
+ }
+ return activity
+}
+
+func (g *Gateway) beginActivity(key string) {
+ activity := g.activity(key)
+ activity.mu.Lock()
+ defer activity.mu.Unlock()
+
+ if activity.sleepTimer != nil {
+ activity.sleepTimer.Stop()
+ activity.sleepTimer = nil
+ }
+ activity.activeRequests += 1
+}
+
+func (g *Gateway) endActivity(key string, serviceID string, sleepAfterSeconds int) {
+ activity := g.activity(key)
+ activity.mu.Lock()
+ defer activity.mu.Unlock()
+
+ if activity.activeRequests > 0 {
+ activity.activeRequests -= 1
+ }
+ if activity.activeRequests > 0 {
+ return
+ }
+
+ if activity.sleepTimer != nil {
+ activity.sleepTimer.Stop()
+ }
+ delay := sleepDelay(sleepAfterSeconds)
+ activity.sleepTimer = time.AfterFunc(delay, func() {
+ g.sleepService(serviceID)
+ })
+}
+
+func (g *Gateway) sleepHost(host string) {
+ route := findRoute(g.runtime.ExpectedState(), host)
+ if route == nil {
+ log.Printf("[serverless-gateway] sleep skipped host=%s reason=missing_route_metadata", host)
+ return
+ }
+ g.sleepService(route.ServiceID)
+}
+
+func (g *Gateway) sleepService(serviceID string) {
+ activityKey := serviceActivityKey(serviceID)
+ activity := g.activity(activityKey)
+ activity.mu.Lock()
+ if activity.activeRequests > 0 {
+ activeRequests := activity.activeRequests
+ activity.mu.Unlock()
+ log.Printf(
+ "[serverless-gateway] sleep skipped service=%s reason=active_requests active=%d",
+ serviceID,
+ activeRequests,
+ )
+ return
+ }
+ activity.sleepTimer = nil
+ activity.mu.Unlock()
+
+ state := g.runtime.ExpectedState()
+ route := findRouteByServiceID(state, serviceID)
+ if route == nil {
+ log.Printf("[serverless-gateway] sleep skipped service=%s reason=missing_route_metadata", serviceID)
+ return
+ }
+ localDeploymentIDs := localDeploymentIDsForService(state, serviceID)
+ log.Printf(
+ "[serverless-gateway] sleep timer fired service=%s deployments=%d",
+ serviceID,
+ len(localDeploymentIDs),
+ )
+
+ actualContainers, err := g.runtime.ListServerlessContainers()
+ if err != nil {
+ log.Printf("[serverless-gateway] failed to list containers before sleep for service %s: %v", serviceID, err)
+ return
+ }
+
+ expectedByDeploymentID := expectedContainersByDeploymentID(state)
+ actualByDeploymentID := actualContainersByDeploymentID(actualContainers)
+ for _, deploymentID := range localDeploymentIDs {
+ expected, ok := expectedByDeploymentID[deploymentID]
+ if !ok {
+ log.Printf(
+ "[serverless-gateway] sleep skipped service=%s deployment=%s reason=missing_expected_state",
+ serviceID,
+ deploymentID,
+ )
+ continue
+ }
+ actual, ok := actualByDeploymentID[deploymentID]
+ if !ok || actual.State != "running" {
+ reason := "no_local_container"
+ if ok {
+ reason = "container_not_running"
+ }
+ log.Printf(
+ "[serverless-gateway] sleep skipped service=%s deployment=%s reason=%s",
+ serviceID,
+ deploymentID,
+ reason,
+ )
+ continue
+ }
+ if expected.DesiredState != "stopped" && !g.isContainerReady(actual, expected) {
+ log.Printf(
+ "[serverless-gateway] sleep skipped service=%s deployment=%s reason=container_not_ready",
+ serviceID,
+ deploymentID,
+ )
+ continue
+ }
+
+ activity.mu.Lock()
+ if activity.activeRequests > 0 {
+ activeRequests := activity.activeRequests
+ activity.mu.Unlock()
+ log.Printf(
+ "[serverless-gateway] sleep skipped service=%s deployment=%s reason=active_requests active=%d",
+ serviceID,
+ deploymentID,
+ activeRequests,
+ )
+ return
+ }
+ sleepStartedAt := time.Now()
+ if expected.DesiredState != "stopped" {
+ log.Printf(
+ "[serverless-gateway] sleep starting service=%s deployment=%s container=%s",
+ serviceID,
+ deploymentID,
+ actual.ID,
+ )
+ g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{
+ Type: "sleep",
+ DeploymentID: deploymentID,
+ ContainerID: actual.ID,
+ })
+ } else {
+ log.Printf(
+ "[serverless-gateway] sleep stop starting service=%s deployment=%s container=%s reason=already_expected_stopped",
+ serviceID,
+ deploymentID,
+ actual.ID,
+ )
+ }
+ if err := g.runtime.StopServerlessContainer(actual.ID); err != nil {
+ activity.mu.Unlock()
+ log.Printf(
+ "[serverless-gateway] sleep failed service=%s deployment=%s container=%s latency=%s error=%v",
+ serviceID,
+ deploymentID,
+ actual.ID,
+ roundDuration(time.Since(sleepStartedAt)),
+ err,
+ )
+ continue
+ }
+ g.evictServiceUpstreams(serviceID)
+ activity.mu.Unlock()
+ log.Printf(
+ "[serverless-gateway] sleep complete service=%s deployment=%s container=%s latency=%s",
+ serviceID,
+ deploymentID,
+ actual.ID,
+ roundDuration(time.Since(sleepStartedAt)),
+ )
+ }
+ g.evictServiceUpstreams(serviceID)
+}
+
+func (g *Gateway) stopAllActivities() {
+ g.activityMu.Lock()
+ activities := make([]*activityState, 0, len(g.activities))
+ for _, activity := range g.activities {
+ activities = append(activities, activity)
+ }
+ g.activityMu.Unlock()
+
+ for _, activity := range activities {
+ activity.mu.Lock()
+ if activity.sleepTimer != nil {
+ activity.sleepTimer.Stop()
+ activity.sleepTimer = nil
+ }
+ activity.mu.Unlock()
+ }
+}
+
+func findRoute(state *agenthttp.ExpectedState, host string) *agenthttp.ServerlessRoute {
+ if state == nil {
+ return nil
+ }
+ normalizedHost := normalizeHost(host)
+ for i := range state.Serverless.Routes {
+ if normalizeHost(state.Serverless.Routes[i].Domain) == normalizedHost {
+ return &state.Serverless.Routes[i]
+ }
+ }
+ return nil
+}
+
+func findRouteByServiceID(state *agenthttp.ExpectedState, serviceID string) *agenthttp.ServerlessRoute {
+ if state == nil {
+ return nil
+ }
+ for i := range state.Serverless.Routes {
+ if state.Serverless.Routes[i].ServiceID == serviceID {
+ return &state.Serverless.Routes[i]
+ }
+ }
+ return nil
+}
+
+func localDeploymentIDsForService(state *agenthttp.ExpectedState, serviceID string) []string {
+ if state == nil {
+ return nil
+ }
+ ids := map[string]struct{}{}
+ for _, route := range state.Serverless.Routes {
+ if route.ServiceID != serviceID {
+ continue
+ }
+ for _, deploymentID := range route.LocalDeploymentIDs {
+ ids[deploymentID] = struct{}{}
+ }
+ }
+ deploymentIDs := make([]string, 0, len(ids))
+ for deploymentID := range ids {
+ deploymentIDs = append(deploymentIDs, deploymentID)
+ }
+ sort.Strings(deploymentIDs)
+ return deploymentIDs
+}
+
+func routeWakeKey(route *agenthttp.ServerlessRoute) string {
+ deploymentIDs := append([]string(nil), route.LocalDeploymentIDs...)
+ sort.Strings(deploymentIDs)
+ return fmt.Sprintf(
+ "%s:%d:%s",
+ route.ServiceID,
+ route.Port,
+ strings.Join(deploymentIDs, ","),
+ )
+}
+
+func expectedContainersByDeploymentID(state *agenthttp.ExpectedState) map[string]agenthttp.ExpectedContainer {
+ containersByDeploymentID := map[string]agenthttp.ExpectedContainer{}
+ if state == nil {
+ return containersByDeploymentID
+ }
+ for _, expected := range state.Containers {
+ containersByDeploymentID[expected.DeploymentID] = expected
+ }
+ return containersByDeploymentID
+}
+
+func actualContainersByDeploymentID(containers []container.Container) map[string]container.Container {
+ containersByDeploymentID := map[string]container.Container{}
+ for _, actual := range containers {
+ if actual.DeploymentID != "" {
+ containersByDeploymentID[actual.DeploymentID] = actual
+ }
+ }
+ return containersByDeploymentID
+}
+
+func localUpstream(route *agenthttp.ServerlessRoute, expected agenthttp.ExpectedContainer) (agenthttp.ServerlessUpstream, bool) {
+ if expected.IPAddress != "" {
+ return agenthttp.ServerlessUpstream{
+ DeploymentID: expected.DeploymentID,
+ Url: fmt.Sprintf("%s:%d", expected.IPAddress, route.Port),
+ Local: true,
+ }, true
+ }
+
+ for _, port := range expected.Ports {
+ if port.ContainerPort == route.Port && port.HostPort > 0 {
+ return agenthttp.ServerlessUpstream{
+ DeploymentID: expected.DeploymentID,
+ Url: fmt.Sprintf("127.0.0.1:%d", port.HostPort),
+ Local: true,
+ }, true
+ }
+ }
+ return agenthttp.ServerlessUpstream{}, false
+}
+
+func hasAlwaysOnUpstream(upstreams []agenthttp.ServerlessUpstream) bool {
+ for _, upstream := range upstreams {
+ if upstream.AlwaysOn {
+ return true
+ }
+ }
+ return false
+}
+
+func hasRunningLocalDeployment(deploymentIDs []string, actualByDeploymentID map[string]container.Container) bool {
+ for _, deploymentID := range deploymentIDs {
+ if actual, ok := actualByDeploymentID[deploymentID]; ok && actual.State == "running" {
+ return true
+ }
+ }
+ return false
+}
+
+func sortUpstreams(upstreams []agenthttp.ServerlessUpstream) {
+ sort.Slice(upstreams, func(i, j int) bool {
+ return compareUpstream(upstreams[i], upstreams[j]) < 0
+ })
+}
+
+func compareUpstream(a, b agenthttp.ServerlessUpstream) int {
+ if a.AlwaysOn != b.AlwaysOn {
+ if a.AlwaysOn {
+ return -1
+ }
+ return 1
+ }
+ if a.Local != b.Local {
+ if a.Local {
+ return -1
+ }
+ return 1
+ }
+ return strings.Compare(a.Url, b.Url)
+}
+
+func wakeTimeout(wakeTimeoutSeconds int) time.Duration {
+ timeout := time.Duration(wakeTimeoutSeconds) * time.Second
+ if timeout <= 0 {
+ return 5 * time.Minute
+ }
+ return timeout
+}
+
+func sleepDelay(sleepAfterSeconds int) time.Duration {
+ delay := time.Duration(sleepAfterSeconds) * time.Second
+ if delay <= 0 {
+ return 5 * time.Minute
+ }
+ return delay
+}
+
+func serviceActivityKey(serviceID string) string {
+ return "service:" + serviceID
+}
+
+func roundDuration(duration time.Duration) time.Duration {
+ return duration.Round(time.Millisecond)
+}
+
+func cloneUpstreams(upstreams []agenthttp.ServerlessUpstream) []agenthttp.ServerlessUpstream {
+ return append([]agenthttp.ServerlessUpstream(nil), upstreams...)
+}
+
+func (g *Gateway) nextIndex(length int) int {
+ if length <= 1 {
+ return 0
+ }
+ return int(atomic.AddUint64(&g.counter, 1)-1) % length
+}
+
+func normalizeHost(host string) string {
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ return strings.ToLower(strings.TrimSpace(h))
+ }
+ return strings.ToLower(strings.TrimSpace(host))
+}
+
+func forwardedProto(r *http.Request) string {
+ if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
+ return proto
+ }
+ if r.TLS != nil {
+ return "https"
+ }
+ return "http"
+}
diff --git a/agent/internal/serverless/gateway_test.go b/agent/internal/serverless/gateway_test.go
new file mode 100644
index 00000000..5bf165b2
--- /dev/null
+++ b/agent/internal/serverless/gateway_test.go
@@ -0,0 +1,412 @@
+package serverless
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "techulus/cloud-agent/internal/container"
+ agenthttp "techulus/cloud-agent/internal/http"
+)
+
+type fakeRuntime struct {
+ mu sync.Mutex
+ state *agenthttp.ExpectedState
+ containers []container.Container
+ transitions []agenthttp.ServerlessTransition
+ stopped []string
+ deployCalls int
+ deployErr error
+ afterList func()
+ healthStatus string
+ deployStarted chan struct{}
+ deployStartedOnce sync.Once
+ allowDeploy chan struct{}
+}
+
+func (f *fakeRuntime) ExpectedState() *agenthttp.ExpectedState {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.state
+}
+
+func (f *fakeRuntime) DeployServerlessContainer(expected agenthttp.ExpectedContainer) error {
+ if f.deployStarted != nil {
+ f.deployStartedOnce.Do(func() {
+ close(f.deployStarted)
+ })
+ }
+ if f.allowDeploy != nil {
+ <-f.allowDeploy
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.deployCalls += 1
+ if f.deployErr != nil {
+ return f.deployErr
+ }
+ for i, actual := range f.containers {
+ if actual.DeploymentID == expected.DeploymentID {
+ f.containers[i].State = "running"
+ return nil
+ }
+ }
+ f.containers = append(f.containers, container.Container{
+ ID: "ctr-" + expected.DeploymentID,
+ State: "running",
+ DeploymentID: expected.DeploymentID,
+ ServiceID: expected.ServiceID,
+ })
+ return nil
+}
+
+func (f *fakeRuntime) StopServerlessContainer(containerID string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.stopped = append(f.stopped, containerID)
+ for i, actual := range f.containers {
+ if actual.ID == containerID {
+ f.containers[i].State = "exited"
+ }
+ }
+ return nil
+}
+
+func (f *fakeRuntime) ListServerlessContainers() ([]container.Container, error) {
+ f.mu.Lock()
+ containers := append([]container.Container(nil), f.containers...)
+ afterList := f.afterList
+ f.mu.Unlock()
+ if afterList != nil {
+ afterList()
+ }
+ return containers, nil
+}
+
+func (f *fakeRuntime) GetServerlessContainerHealth(containerID string) string {
+ if f.healthStatus != "" {
+ return f.healthStatus
+ }
+ return "healthy"
+}
+
+func (f *fakeRuntime) QueueServerlessTransition(transition agenthttp.ServerlessTransition) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.transitions = append(f.transitions, transition)
+}
+
+func (f *fakeRuntime) snapshot() ([]agenthttp.ServerlessTransition, []string, int) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]agenthttp.ServerlessTransition(nil), f.transitions...), append([]string(nil), f.stopped...), f.deployCalls
+}
+
+func (f *fakeRuntime) snapshotContainers() []container.Container {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]container.Container(nil), f.containers...)
+}
+
+func TestGetUpstreamsReturnsWhenFollowerContextIsCancelled(t *testing.T) {
+ state := testExpectedState("stopped")
+ g := NewGateway(&fakeRuntime{state: state})
+ g.wakeCalls[routeWakeKey(&state.Serverless.Routes[0])] = &wakeCall{done: make(chan struct{})}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := g.getUpstreams(ctx, "app.example.com")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestConcurrentDomainsShareOneWakeAttempt(t *testing.T) {
+ state := testExpectedState("stopped")
+ state.Serverless.Routes[0].Upstreams = nil
+ secondRoute := state.Serverless.Routes[0]
+ secondRoute.Domain = "api.example.com"
+ state.Serverless.Routes = append(state.Serverless.Routes, secondRoute)
+ runtime := &fakeRuntime{
+ state: state,
+ deployStarted: make(chan struct{}),
+ allowDeploy: make(chan struct{}),
+ }
+ gateway := NewGateway(runtime)
+
+ errs := make(chan error, 2)
+ go func() {
+ _, err := gateway.getUpstreams(context.Background(), "app.example.com")
+ errs <- err
+ }()
+ select {
+ case <-runtime.deployStarted:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for first wake to start")
+ }
+ go func() {
+ _, err := gateway.getUpstreams(context.Background(), "api.example.com")
+ errs <- err
+ }()
+ time.Sleep(10 * time.Millisecond)
+ close(runtime.allowDeploy)
+
+ for i := 0; i < 2; i++ {
+ if err := <-errs; err != nil {
+ t.Fatalf("getUpstreams failed: %v", err)
+ }
+ }
+ transitions, _, deployCalls := runtime.snapshot()
+ if deployCalls != 1 {
+ t.Fatalf("deployCalls = %d, want 1", deployCalls)
+ }
+ if len(transitions) != 1 || transitions[0].Type != "wake_started" {
+ t.Fatalf("transitions = %+v, want one wake_started", transitions)
+ }
+}
+
+func TestRequestCanUseAlwaysOnWorkerWhileLocalDeploymentWakes(t *testing.T) {
+ runtime := &fakeRuntime{state: testExpectedState("stopped")}
+ gateway := NewGateway(runtime)
+
+ upstreams, err := gateway.getUpstreams(context.Background(), "app.example.com")
+ if err != nil {
+ t.Fatalf("getUpstreams failed: %v", err)
+ }
+ if len(upstreams) != 1 || upstreams[0].Url != "10.0.0.20:3000" {
+ t.Fatalf("upstreams = %+v, want worker upstream", upstreams)
+ }
+
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ transitions, _, deployCalls := runtime.snapshot()
+ if deployCalls == 1 && len(transitions) == 1 && transitions[0].Type == "wake_started" {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ transitions, _, deployCalls := runtime.snapshot()
+ t.Fatalf("deployCalls=%d transitions=%+v, want one background wake", deployCalls, transitions)
+}
+
+func TestSleepingLocalDeploymentWakesAndReturnsLocalUpstream(t *testing.T) {
+ previousPoll := wakePollInterval
+ wakePollInterval = time.Millisecond
+ t.Cleanup(func() {
+ wakePollInterval = previousPoll
+ })
+
+ state := testExpectedState("stopped")
+ state.Serverless.Routes[0].Upstreams = nil
+ runtime := &fakeRuntime{state: state}
+ gateway := NewGateway(runtime)
+
+ upstreams, err := gateway.getUpstreams(context.Background(), "app.example.com")
+ if err != nil {
+ t.Fatalf("getUpstreams failed: %v", err)
+ }
+ if len(upstreams) != 1 || upstreams[0].Url != "10.0.0.10:3000" {
+ t.Fatalf("upstreams = %+v, want local upstream", upstreams)
+ }
+
+ transitions, _, deployCalls := runtime.snapshot()
+ if deployCalls != 1 {
+ t.Fatalf("deployCalls = %d, want 1", deployCalls)
+ }
+ if len(transitions) != 1 || transitions[0].Type != "wake_started" {
+ t.Fatalf("transitions = %+v, want wake_started", transitions)
+ }
+}
+
+func TestSleepingLocalDeploymentStartsExistingStoppedContainer(t *testing.T) {
+ previousPoll := wakePollInterval
+ wakePollInterval = time.Millisecond
+ t.Cleanup(func() {
+ wakePollInterval = previousPoll
+ })
+
+ state := testExpectedState("stopped")
+ state.Serverless.Routes[0].Upstreams = nil
+ runtime := &fakeRuntime{
+ state: state,
+ containers: []container.Container{
+ {
+ ID: "ctr-existing",
+ State: "exited",
+ DeploymentID: "dep_local",
+ ServiceID: "svc_1",
+ },
+ },
+ }
+ gateway := NewGateway(runtime)
+
+ upstreams, err := gateway.getUpstreams(context.Background(), "app.example.com")
+ if err != nil {
+ t.Fatalf("getUpstreams failed: %v", err)
+ }
+ if len(upstreams) != 1 || upstreams[0].Url != "10.0.0.10:3000" {
+ t.Fatalf("upstreams = %+v, want local upstream", upstreams)
+ }
+
+ containers := runtime.snapshotContainers()
+ if len(containers) != 1 {
+ t.Fatalf("containers = %+v, want existing container only", containers)
+ }
+ if containers[0].ID != "ctr-existing" || containers[0].State != "running" {
+ t.Fatalf("container = %+v, want existing container running", containers[0])
+ }
+}
+
+func TestSleepHostStopsLocalContainerAndReportsSleep(t *testing.T) {
+ state := testExpectedState("running")
+ runtime := &fakeRuntime{
+ state: state,
+ containers: []container.Container{
+ {ID: "ctr-local", State: "running", DeploymentID: "dep_local", ServiceID: "svc_1"},
+ },
+ }
+ gateway := NewGateway(runtime)
+
+ gateway.sleepHost("app.example.com")
+
+ transitions, stopped, _ := runtime.snapshot()
+ if len(stopped) != 1 || stopped[0] != "ctr-local" {
+ t.Fatalf("stopped = %+v, want ctr-local", stopped)
+ }
+ if len(transitions) != 1 {
+ t.Fatalf("transitions = %+v, want one sleep transition", transitions)
+ }
+ if transitions[0].Type != "sleep" || transitions[0].DeploymentID != "dep_local" || transitions[0].ContainerID != "ctr-local" {
+ t.Fatalf("transition = %+v, want sleep for dep_local/ctr-local", transitions[0])
+ }
+}
+
+func TestSleepHostRechecksActivityBeforeStoppingContainer(t *testing.T) {
+ state := testExpectedState("running")
+ runtime := &fakeRuntime{
+ state: state,
+ containers: []container.Container{
+ {ID: "ctr-local", State: "running", DeploymentID: "dep_local", ServiceID: "svc_1"},
+ },
+ }
+ gateway := NewGateway(runtime)
+ runtime.afterList = func() {
+ gateway.beginActivity(serviceActivityKey("svc_1"))
+ }
+
+ gateway.sleepHost("app.example.com")
+
+ transitions, stopped, _ := runtime.snapshot()
+ if len(stopped) != 0 {
+ t.Fatalf("stopped = %+v, want no stops while request is active", stopped)
+ }
+ if len(transitions) != 0 {
+ t.Fatalf("transitions = %+v, want no sleep transition while request is active", transitions)
+ }
+}
+
+func TestSleepHostUsesServiceActivityAcrossDomains(t *testing.T) {
+ state := testExpectedState("running")
+ secondRoute := state.Serverless.Routes[0]
+ secondRoute.Domain = "api.example.com"
+ state.Serverless.Routes = append(state.Serverless.Routes, secondRoute)
+ runtime := &fakeRuntime{
+ state: state,
+ containers: []container.Container{
+ {ID: "ctr-local", State: "running", DeploymentID: "dep_local", ServiceID: "svc_1"},
+ },
+ }
+ gateway := NewGateway(runtime)
+ gateway.beginActivity(serviceActivityKey("svc_1"))
+
+ gateway.sleepHost("api.example.com")
+
+ transitions, stopped, _ := runtime.snapshot()
+ if len(stopped) != 0 {
+ t.Fatalf("stopped = %+v, want no stops while sibling domain is active", stopped)
+ }
+ if len(transitions) != 0 {
+ t.Fatalf("transitions = %+v, want no sleep transition while sibling domain is active", transitions)
+ }
+}
+
+func TestWakeTimeoutQueuesWakeFailedTransition(t *testing.T) {
+ previousPoll := wakePollInterval
+ wakePollInterval = time.Millisecond
+ t.Cleanup(func() {
+ wakePollInterval = previousPoll
+ })
+
+ state := testExpectedState("stopped")
+ state.Containers[0].HealthCheck = &agenthttp.HealthCheck{
+ Cmd: "curl http://localhost:3000/health",
+ Interval: 1,
+ Timeout: 1,
+ Retries: 1,
+ }
+ state.Serverless.Routes[0].Upstreams = nil
+ state.Serverless.Routes[0].WakeTimeoutSeconds = 1
+ runtime := &fakeRuntime{
+ state: state,
+ healthStatus: "unhealthy",
+ }
+ gateway := NewGateway(runtime)
+
+ _, err := gateway.getUpstreams(context.Background(), "app.example.com")
+ if err == nil {
+ t.Fatal("getUpstreams succeeded, want timeout error")
+ }
+
+ transitions, _, deployCalls := runtime.snapshot()
+ if deployCalls != 1 {
+ t.Fatalf("deployCalls = %d, want 1", deployCalls)
+ }
+ if len(transitions) != 2 {
+ t.Fatalf("transitions = %+v, want wake_started and wake_failed", transitions)
+ }
+ if transitions[0].Type != "wake_started" || transitions[1].Type != "wake_failed" {
+ t.Fatalf("transitions = %+v, want wake_started then wake_failed", transitions)
+ }
+ if transitions[1].DeploymentID != "dep_local" || transitions[1].Error == "" {
+ t.Fatalf("wake_failed transition = %+v, want dep_local with error", transitions[1])
+ }
+}
+
+func testExpectedState(localDesiredState string) *agenthttp.ExpectedState {
+ state := &agenthttp.ExpectedState{
+ Containers: []agenthttp.ExpectedContainer{
+ {
+ DeploymentID: "dep_local",
+ ServiceID: "svc_1",
+ ServiceName: "api",
+ Name: "svc_1-dep_local",
+ DesiredState: localDesiredState,
+ Image: "nginx",
+ IPAddress: "10.0.0.10",
+ },
+ },
+ }
+ state.Serverless.Routes = []agenthttp.ServerlessRoute{
+ {
+ ServiceID: "svc_1",
+ Domain: "app.example.com",
+ Port: 3000,
+ SleepAfterSeconds: 300,
+ WakeTimeoutSeconds: 5,
+ MinReadyReplicas: 1,
+ LocalDeploymentIDs: []string{"dep_local"},
+ Upstreams: []agenthttp.ServerlessUpstream{
+ {
+ DeploymentID: "dep_worker",
+ ServerID: "server_worker",
+ Url: "10.0.0.20:3000",
+ AlwaysOn: true,
+ },
+ },
+ },
+ }
+ return state
+}
diff --git a/docs/agents/architecture.mdx b/docs/agents/architecture.mdx
index 16e466bd..e9eef776 100644
--- a/docs/agents/architecture.mdx
+++ b/docs/agents/architecture.mdx
@@ -75,3 +75,37 @@ reports, the command can be retried up to the fixed attempt limit.
| `force_cleanup` | Force remove containers for a service |
| `cleanup_volumes` | Remove volume directories for a service |
| `deploy` | Handled through expected-state reconciliation |
+
+## Serverless Gateway
+
+Proxy agents run a local HTTP wake gateway on `127.0.0.1:18080`. The control
+plane emits Traefik routes to this gateway only on proxy nodes that host a local
+proxy deployment for that serverless service. Proxies without a local
+proxy-hosted deployment do not emit a public HTTP route for that service.
+Worker-only serverless services keep normal direct routes because worker
+deployments do not sleep.
+
+This means public DNS or external load balancers must route a serverless
+service's traffic only to proxy nodes that own a local proxy replica for that
+service.
+
+For serverless service traffic, Traefik forwards the public request to the local
+gateway. The gateway resolves the host from expected-state serverless metadata,
+queues `wake_started` as a status transition, starts local proxy-hosted
+containers from expected-state config, waits for ready upstreams, then proxies
+the original request to the selected container over the WireGuard mesh.
+
+The gateway keeps a short upstream cache and collapses concurrent wake requests
+for the same host. Request cancellation is respected while waiting for a shared
+wake result.
+
+The gateway tracks request activity in memory. When a host stays idle for the
+service's configured sleep timeout, the proxy agent removes only its local
+proxy-hosted containers and queues a `sleep` status transition. The control plane
+records the transition and updates dashboard/routing state; it does not run the
+idle timer.
+
+If a status report fails, pending serverless transitions stay queued in memory
+and are retried. A locally slept deployment is also guarded from immediate
+reconcile until a fresh expected-state fetch confirms the control plane recorded
+the sleep or rejects it by continuing to advertise `desiredState: "running"`.
diff --git a/docs/architecture.mdx b/docs/architecture.mdx
index 6272152d..fa9729cb 100644
--- a/docs/architecture.mdx
+++ b/docs/architecture.mdx
@@ -203,6 +203,72 @@ Proxy nodes receive routes and certificates from the control plane:
Worker nodes do not run Traefik.
+### Serverless Containers
+
+Public HTTP services can be configured as serverless. Serverless scale-to-zero is
+proxy-local: deployments placed on proxy nodes may sleep after an idle period,
+then wake on the next public request handled by that proxy. Deployments placed on
+worker nodes stay always on and remain routable.
+
+Serverless uses the same declarative expected-state model as normal
+deployments, but proxy agents own the local lifecycle decision:
+
+- A sleeping deployment keeps `trafficState: "active"` but records
+ `runtimeDesiredState: "stopped"` and `observedPhase: "sleeping"`.
+- Expected state advertises proxy-hosted sleeping containers with
+ `desiredState: "stopped"` so normal reconciliation does not restart them.
+- Expected state advertises worker-hosted deployments with
+ `desiredState: "running"` even when the service is serverless-enabled.
+- The proxy gateway wakes local sleeping deployments from expected-state
+ metadata and reports `wake_started` through the next status report.
+- The proxy agent reports local sleep with a `sleep` status transition after it
+ stops the local container.
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Traefik
+ participant Gateway as Agent wake gateway
+ participant Agent
+ participant CP as Control plane
+ participant Container
+
+ User->>Traefik: Request service domain
+ Traefik->>Gateway: Route to 127.0.0.1:18080
+ Gateway->>Gateway: Resolve host from expected-state metadata
+ Gateway->>Agent: Queue wake_started status transition
+ Agent->>Container: Start local Podman container
+ Agent->>CP: Report wake_started and container status
+ CP->>CP: Record waking/healthy state
+ Gateway->>Container: Proxy original request
+```
+
+Proxy Traefik routes point to the local wake gateway only on proxy nodes that
+host a local proxy deployment for that serverless service. Non-owner proxies do
+not emit a public HTTP route for that service, even when always-on worker
+upstreams exist. Worker-only serverless services use normal direct routes because
+there is nothing local to wake.
+
+Public DNS or external load balancers must therefore send serverless traffic only
+to proxy nodes that own a local proxy replica for that service. Cross-proxy wake
+coordination is intentionally out of scope.
+
+The wake gateway keeps the incoming request open while it starts local proxy
+replicas, unless an always-on worker upstream is already ready. `Min Ready`
+controls how many ready upstreams are preferred before releasing queued requests.
+If fewer replicas become ready and no local wake is still in progress, the
+gateway serves any available ready upstream instead of returning a 503.
+
+Sleep is driven by the proxy gateway's in-memory activity timer. The control
+plane does not scan request activity and does not enqueue sleep work. It accepts
+validated `sleep`, `wake_started`, and `wake_failed` transitions through the
+existing agent status endpoint.
+
+Wake failures are bounded. Transient failures return a deployment to `sleeping`
+so a later request can retry. After repeated failures, the deployment is parked
+as `failed` with a visible failed stage until a redeploy creates fresh
+deployments.
+
### Multiple Proxy Nodes
The platform supports geographically distributed proxy nodes with proximity steering:
diff --git a/docs/services/scaling.mdx b/docs/services/scaling.mdx
index 9e647b9a..46aa96ec 100644
--- a/docs/services/scaling.mdx
+++ b/docs/services/scaling.mdx
@@ -9,6 +9,41 @@ Each service can run multiple replicas across your cluster. Configure how many r
Replica count ranges from 1 to 10 per service.
+## Serverless scaling
+
+Public HTTP services can be configured to sleep when idle on proxy nodes. A
+sleeping deployment keeps active traffic intent, records stopped runtime intent,
+and stops the local container. The next public HTTP request wakes local
+proxy-hosted deployments and is held until an upstream is ready or the wake
+timeout is reached.
+
+Worker-hosted deployments do not sleep. If a serverless-enabled service has only
+worker replicas, it stays always on and routes directly. If a service has both
+proxy and worker replicas, proxy replicas may sleep while worker replicas remain
+routable.
+
+For services with proxy-hosted serverless replicas, public traffic must be sent
+only to proxy nodes that host a local proxy replica for that service. Non-owner
+proxies do not emit a public HTTP route for the service, so DNS or the external
+load balancer must avoid those proxies for that service's domains.
+
+Serverless settings are configured per service:
+
+| Setting | Default | Description |
+| --- | --- | --- |
+| Enable serverless | Off | Allows proxy-hosted HTTP deployments to sleep and wake on request |
+| Sleep after | `300s` | Idle period before running containers are stopped |
+| Wake timeout | `300s` | Maximum time the wake gateway waits for ready upstreams |
+| Min Ready | `1` | Preferred number of ready deployments before queued requests resume |
+
+`Min Ready` does not cap how many local proxy replicas are started. A cold wake
+starts the sleeping local proxy replicas for that host. `Min Ready` only controls
+when held requests can resume. If a worker upstream is already ready, the gateway
+can serve it immediately while local proxy replicas wake in the background.
+
+Serverless services require at least one configured replica. `Min Ready` must be
+between `1` and `10`, and cannot exceed the configured replica count.
+
## Placement
Services use manual placement. Select the target servers and replica counts explicitly before deploying.
@@ -27,3 +62,7 @@ You can also manually lock any service to a specific server by setting the locke
- Stateful services are always pinned to their locked server.
- Stateful services do not automatically fail over to another server.
- Maximum 10 replicas per service.
+- Serverless scaling requires a public HTTP service domain.
+- Sleep and wake are proxy-local. Worker replicas are intentionally always on.
+- Serverless traffic must be routed only to proxy nodes that own a local proxy replica for that service.
+- Proxy agents report sleep and wake transitions through normal status reports.
diff --git a/docs/services/volumes.mdx b/docs/services/volumes.mdx
index ea13e4a5..d516e02e 100644
--- a/docs/services/volumes.mdx
+++ b/docs/services/volumes.mdx
@@ -18,6 +18,8 @@ Each volume has a name and a container path:
When you add a volume, the service automatically becomes **stateful**. Stateful services are locked to a single server and limited to 1 replica so the container always mounts the same local data path. When the last volume is removed, the service reverts to stateless.
+Stateful services can use serverless scaling when they have a public HTTP domain. Because volumes are local to one server, only a proxy-hosted stateful replica can sleep and wake on request. A stateful replica placed on a worker node stays always on.
+
## Volume Backups
Volumes can be backed up to S3-compatible storage on a schedule or on demand.
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index a9ef3af2..3e3865c8 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -2,7 +2,7 @@
import { randomUUID } from "node:crypto";
import cronstrue from "cronstrue";
-import { and, desc, eq, inArray, isNotNull } from "drizzle-orm";
+import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { ZodError, z } from "zod";
import { db } from "@/db";
@@ -31,10 +31,16 @@ import {
import { requireDeveloperRole } from "@/lib/auth";
import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants";
import { deployServiceInternal } from "@/lib/deploy-service";
-import { markDeploymentUndesired } from "@/lib/deployment-status";
+import {
+ isObservedReady,
+ markDeploymentRemoved,
+ runtimeExpectedStates,
+} from "@/lib/deployment-status";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
+import { restoreDrainingDeploymentsForRollback } from "@/lib/inngest/functions/rollout-utils";
import { allocatePort } from "@/lib/port-allocation";
+import { blocksProjectDeletion } from "@/lib/project-deletion";
import {
containerPathSchema,
githubRepoUrlSchema,
@@ -262,24 +268,13 @@ export async function deleteProject(id: string) {
.from(services)
.where(eq(services.projectId, id));
- const activeStatuses = [
- "pending",
- "pulling",
- "starting",
- "healthy",
- "running",
- "stopping",
- ];
-
for (const service of projectServices) {
const activeDeployments = await db
.select()
.from(deployments)
.where(eq(deployments.serviceId, service.id));
- const hasActiveDeployments = activeDeployments.some((d) =>
- activeStatuses.includes(d.status),
- );
+ const hasActiveDeployments = activeDeployments.some(blocksProjectDeletion);
if (hasActiveDeployments) {
throw new Error(
@@ -536,15 +531,7 @@ async function hardDeleteService(serviceId: string) {
.where(eq(deployments.serviceId, serviceId));
for (const dep of allDeployments) {
- if (
- (dep.status === "running" || dep.status === "healthy") &&
- dep.containerId
- ) {
- await db
- .update(deployments)
- .set(markDeploymentUndesired("stopping"))
- .where(eq(deployments.id, dep.id));
-
+ if (isObservedReady(dep.observedPhase) && dep.containerId) {
await enqueueWork(dep.serverId, "stop", {
deploymentId: dep.id,
containerId: dep.containerId,
@@ -627,7 +614,7 @@ export async function deleteService(serviceId: string) {
.where(
and(
eq(deployments.serviceId, serviceId),
- inArray(deployments.status, ["running", "healthy"]),
+ inArray(deployments.observedPhase, ["running", "healthy"]),
),
)
.then((r) => r[0]);
@@ -1013,6 +1000,91 @@ export async function updateServiceSchedule(
return { success: true };
}
+const serverlessSettingsSchema = z.object({
+ enabled: z.boolean(),
+ sleepAfterSeconds: z.number().int().min(60).max(86_400),
+ wakeTimeoutSeconds: z.number().int().min(10).max(900),
+ minReadyReplicas: z.number().int().min(1).max(10),
+});
+
+export async function updateServiceServerlessSettings(
+ serviceId: string,
+ settings: {
+ enabled: boolean;
+ sleepAfterSeconds: number;
+ wakeTimeoutSeconds: number;
+ minReadyReplicas: number;
+ },
+) {
+ await requireDeveloperRole();
+ const validated = serverlessSettingsSchema.parse(settings);
+
+ await db.transaction(async (tx) => {
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`);
+
+ const [service] = await tx
+ .select()
+ .from(services)
+ .where(eq(services.id, serviceId))
+ .limit(1);
+
+ if (!service || service.deletedAt) {
+ throw new Error("Service not found");
+ }
+
+ if (validated.enabled) {
+ const publicHttpPorts = await tx
+ .select({ id: servicePorts.id })
+ .from(servicePorts)
+ .where(
+ and(
+ eq(servicePorts.serviceId, serviceId),
+ eq(servicePorts.isPublic, true),
+ eq(servicePorts.protocol, "http"),
+ isNotNull(servicePorts.domain),
+ ),
+ )
+ .limit(1);
+
+ if (publicHttpPorts.length === 0) {
+ throw new Error(
+ "Serverless services require a public HTTP port with a domain",
+ );
+ }
+
+ const configuredReplicas = await tx
+ .select({ count: serviceReplicas.count })
+ .from(serviceReplicas)
+ .where(eq(serviceReplicas.serviceId, serviceId));
+ const totalConfiguredReplicas = configuredReplicas.reduce(
+ (total, replica) => total + replica.count,
+ 0,
+ );
+
+ if (totalConfiguredReplicas < 1) {
+ throw new Error("Serverless services require at least one replica");
+ }
+ if (validated.minReadyReplicas > totalConfiguredReplicas) {
+ throw new Error(
+ "Minimum ready replicas cannot exceed configured replicas",
+ );
+ }
+ }
+
+ await tx
+ .update(services)
+ .set({
+ serverlessEnabled: validated.enabled,
+ serverlessSleepAfterSeconds: validated.sleepAfterSeconds,
+ serverlessWakeTimeoutSeconds: validated.wakeTimeoutSeconds,
+ serverlessMinReadyReplicas: validated.minReadyReplicas,
+ })
+ .where(eq(services.id, serviceId));
+ });
+
+ return { success: true };
+}
+
export type ServiceConfigUpdate = {
source?: { type: "image"; image: string };
healthCheck?: ServiceHealthCheckConfig | null;
@@ -1147,8 +1219,10 @@ export async function updateServiceConfig(
.delete(serviceReplicas)
.where(eq(serviceReplicas.serviceId, serviceId));
+ let totalReplicas = 0;
for (const replica of config.replicas) {
if (replica.count > 0) {
+ totalReplicas += replica.count;
await db.insert(serviceReplicas).values({
id: randomUUID(),
serviceId,
@@ -1157,6 +1231,15 @@ export async function updateServiceConfig(
});
}
}
+
+ await db
+ .update(services)
+ .set({
+ serverlessMinReadyReplicas: sql`LEAST(${services.serverlessMinReadyReplicas}, ${Math.max(1, totalReplicas)})`,
+ })
+ .where(
+ and(eq(services.id, serviceId), eq(services.serverlessEnabled, true)),
+ );
}
return { success: true };
@@ -1164,31 +1247,32 @@ export async function updateServiceConfig(
export async function stopService(serviceId: string) {
await requireDeveloperRole();
- const runningDeployments = await db
+ const desiredDeployments = await db
.select()
.from(deployments)
.where(
and(
eq(deployments.serviceId, serviceId),
- eq(deployments.status, "running"),
+ inArray(deployments.runtimeDesiredState, runtimeExpectedStates),
),
);
- for (const dep of runningDeployments) {
- if (!dep.containerId) continue;
-
+ for (const dep of desiredDeployments) {
+ // User stop is teardown; runtimeDesiredState "stopped" is reserved for
+ // serverless sleep where the deployment must remain wakeable.
await db
.update(deployments)
- .set(markDeploymentUndesired("stopping"))
+ .set(markDeploymentRemoved())
.where(eq(deployments.id, dep.id));
+ if (!dep.containerId) continue;
await enqueueWork(dep.serverId, "stop", {
deploymentId: dep.id,
containerId: dep.containerId,
});
}
- return { success: true, count: runningDeployments.length };
+ return { success: true, count: desiredDeployments.length };
}
export async function restartService(serviceId: string) {
@@ -1204,7 +1288,7 @@ export async function restartService(serviceId: string) {
.where(eq(deployments.serviceId, serviceId));
const deploymentsToRestart = runningDeployments.filter(
- (d) => d.status === "running" && d.containerId,
+ (d) => isObservedReady(d.observedPhase) && d.containerId,
);
if (deploymentsToRestart.length === 0) {
@@ -1256,15 +1340,7 @@ export async function abortRollout(serviceId: string) {
);
}
- await db
- .update(deployments)
- .set({ status: "running" })
- .where(
- and(
- eq(deployments.serviceId, serviceId),
- eq(deployments.status, "draining"),
- ),
- );
+ await restoreDrainingDeploymentsForRollback(serviceId);
const rolloutDeployments =
activeRolloutIds.length > 0
@@ -1429,16 +1505,7 @@ export async function removeServiceVolume(volumeId: string) {
.from(deployments)
.where(eq(deployments.serviceId, volume[0].serviceId));
- const runningStatuses = [
- "pending",
- "pulling",
- "starting",
- "healthy",
- "running",
- ];
- const hasRunning = activeDeployments.some((d) =>
- runningStatuses.includes(d.status),
- );
+ const hasRunning = activeDeployments.some(blocksProjectDeletion);
if (hasRunning) {
throw new Error("Stop the service before removing volumes");
}
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
index c014b426..77700a12 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
@@ -12,6 +12,7 @@ import { ReplicasSection } from "@/components/service/details/replicas-section";
import { ResourceLimitsSection } from "@/components/service/details/resource-limits-section";
import { ScheduleSection } from "@/components/service/details/schedule-section";
import { SecretsSection } from "@/components/service/details/secrets-section";
+import { ServerlessSection } from "@/components/service/details/serverless-section";
import { SourceSection } from "@/components/service/details/source-section";
import { StartCommandSection } from "@/components/service/details/start-command-section";
import { TCPProxySection } from "@/components/service/details/tcp-proxy-section";
@@ -46,7 +47,7 @@ export default function ConfigurationPage() {
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
- deployment.status as (typeof ACTIVE_DELETE_BACKUP_STATUSES)[number],
+ deployment.observedPhase as (typeof ACTIVE_DELETE_BACKUP_STATUSES)[number],
) && !!deployment.containerId,
);
const hasVolumes = (service.volumes?.length ?? 0) > 0;
@@ -98,6 +99,8 @@ export default function ConfigurationPage() {
+
+
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx
index 2357c275..278395c1 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx
@@ -46,6 +46,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { getServiceDeploymentActionState } from "@/lib/service-deployment-actions";
type ConfirmAction = "redeploy" | "stop" | "delete" | null;
@@ -121,20 +122,7 @@ export default function DeploymentsPage() {
},
};
- const hasRunningDeployments = service.deployments.some(
- (d) => d.status === "running",
- );
- const hasStoppedOrFailedDeployments =
- !hasRunningDeployments &&
- service.deployments.some(
- (d) =>
- d.status === "stopped" ||
- d.status === "failed" ||
- d.status === "rolled_back",
- );
- const canStartAll =
- hasStoppedOrFailedDeployments &&
- (service.configuredReplicas || []).length > 0;
+ const deploymentActions = getServiceDeploymentActionState(service);
return (
@@ -163,8 +151,8 @@ export default function DeploymentsPage() {
projectSlug={projectSlug}
envName={envName}
actions={
- service.deployments.length > 0 ? (
- hasRunningDeployments ? (
+ deploymentActions.hasDeployments ? (
+ deploymentActions.hasExpectedDeployments ? (
-
- handleAction(
- "restart",
- () => restartService(service.id),
- "Restart queued",
- )
- }
- >
-
- Restart
-
-
+ {deploymentActions.hasRestartableDeployments ? (
+ <>
+
+ handleAction(
+ "restart",
+ () => restartService(service.id),
+ "Restart queued",
+ )
+ }
+ >
+
+ Restart
+
+
+ >
+ ) : null}
setConfirmAction("stop")}
@@ -221,7 +213,7 @@ export default function DeploymentsPage() {
- ) : canStartAll ? (
+ ) : deploymentActions.canStartAll ? (