From c5562109bfa9f0277a22b5b2b215a99d9550101f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?=
<150935816+Dvorinka@users.noreply.github.com>
Date: Fri, 9 Oct 2026 22:40:35 +0200
Subject: [PATCH] fix(sleep): request-triggered wake, create-time config, agent
release assets (#62)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Wake placeholder fired its wake callback unconditionally at boot, so
sleeping services flapped sleep→wake on every idle timeout. The wake
call now lives in a CGI script the busybox httpd runs on inbound hits;
E404 covers non-root paths. URLs/page travel via env, no quoting.
- Wake endpoint tries ordered candidate addresses (self-name on shared
net, host.docker.internal, edge gateway, probe) — Docker Desktop's VM
can't reach the bridge gateway, so single-URL wake was dead there.
- CreateServiceRequest accepts sleep_enabled/sleep_idle_minutes and the
INSERT persists them — previously update-only, silently dropped at
create. CLI `services create` gains --sleep/--sleep-idle; MCP doc lists
both; OpenAPI node_id description updated for build-on-node.
- Wake goroutine logs spec/reconcile failures instead of a bare return,
and marks the service failed when spec build errors.
- scripts/build-cli.sh ships containr-agent-linux-{amd64,arm64} so the
install-agent.sh GH-releases fallback actually resolves.
Verified live: create-time flags persist and validate (1-1440), service
sleeps on idle and stays asleep, an inbound hit on the placeholder wakes
it (~15s to running), and the placeholder is pruned after reconcile.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
app/backend/internal/api/services.go | 21 ++++-
app/backend/internal/api/sleeper.go | 86 ++++++++++++++-----
app/backend/internal/cli/commands/services.go | 12 ++-
app/backend/internal/mcpserver/server.go | 2 +-
app/frontend/src/generated/api-types.ts | 2 +-
docs/api/openapi.yaml | 2 +-
scripts/build-cli.sh | 13 ++-
7 files changed, 107 insertions(+), 31 deletions(-)
diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go
index b57d1e3..c3fdf10 100644
--- a/app/backend/internal/api/services.go
+++ b/app/backend/internal/api/services.go
@@ -224,6 +224,10 @@ type CreateServiceRequest struct {
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
+ // SleepEnabled/SleepIdleMinutes opt the service into scale-to-zero at
+ // create time — previously update-only, which surprised API/CLI users.
+ SleepEnabled *bool `json:"sleep_enabled"`
+ SleepIdleMinutes *int `json:"sleep_idle_minutes"`
// NodeID pins placement to a node agent; "auto" picks the least-loaded
// online agent, "local"/"" runs on the Containr host.
NodeID string `json:"node_id"`
@@ -511,6 +515,16 @@ func handleCreateService(c *gin.Context) {
sourceType := inferServiceSourceType(service)
+ if req.SleepEnabled != nil {
+ service.SleepEnabled = *req.SleepEnabled
+ }
+ if req.SleepIdleMinutes != nil {
+ if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
+ return
+ }
+ service.SleepIdleMinutes = *req.SleepIdleMinutes
+ }
if req.Spread != nil {
service.Spread = *req.Spread
}
@@ -542,9 +556,10 @@ func handleCreateService(c *gin.Context) {
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
- node_id, spread, placement_tags, created_at, updated_at)
+ node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
+ created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
- $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
+ $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command,
@@ -555,6 +570,8 @@ func handleCreateService(c *gin.Context) {
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
service.Spread,
tagsJSON(service.PlacementTags),
+ service.SleepEnabled,
+ sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
service.CreatedAt, service.UpdatedAt,
)
diff --git a/app/backend/internal/api/sleeper.go b/app/backend/internal/api/sleeper.go
index 66198ee..c55cd0e 100644
--- a/app/backend/internal/api/sleeper.go
+++ b/app/backend/internal/api/sleeper.go
@@ -264,9 +264,20 @@ func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine
}
}
+// wakeCGIScript is installed as /cgi-bin/wake in the wake container. It
+// fires the wake callback on the first reachable candidate URL, in the
+// background, then serves the reloading placeholder page. Deliberately
+// single-quote-free — the file is written through printf '%s'.
+const wakeCGIScript = `#!/bin/sh
+for u in $WAKE_URLS; do wget -q -O /dev/null --timeout=20 "$u" && break; done >/dev/null 2>&1 &
+printf "Content-Type: text/html\r\n\r\n"
+printf "%s" "$WAKE_PAGE"
+`
+
// spawnWakeContainer runs a busybox httpd holding the service's Traefik
-// host rule at higher priority. On boot it calls the wake endpoint, then
-// keeps serving a reloading page until the next reconcile removes it.
+// host rule at higher priority. Inbound hits redirect to /cgi-bin/wake,
+// which calls the internal wake endpoint and serves a reloading page
+// until the reconcile removes the placeholder.
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
edgeNet := ""
for _, n := range networkList(ctx, dc) {
@@ -296,19 +307,25 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
}
- wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet)
+ wakeURLs := wakeEndpointURLs(ctx, dc, apiPort, svc.ID, edgeNet)
page := `
Waking up` +
`` +
`Service is waking up
This Containr service was sleeping and is resuming now. The page reloads automatically.
`
- // httpd without -f daemonizes, so the sequence is: write page, start
- // httpd, fire the wake callback, then hold PID1 on sleep infinity.
+ // The wake callback must fire when a request arrives, not at boot —
+ // firing unconditionally makes the service flap sleep→wake forever.
+ // index.html redirects to /cgi-bin/wake; the CGI fires the callback
+ // (first reachable URL wins) and serves the reloading page.
+ indexPage := ``
+ // httpd without -f daemonizes, so PID1 ends on sleep infinity. URLs
+ // and the page travel via env so the CGI script needs no quoting.
cmd := fmt.Sprintf(
- `mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+
- `httpd -p 80 -h /www && `+
- `(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+
- `sleep infinity`,
- strings.ReplaceAll(page, "'", "'\\''"), wakeURL)
+ `mkdir -p /www/cgi-bin && printf '%%s' '%s' > /www/index.html && `+
+ `printf '%%s' '%s' > /www/cgi-bin/wake && chmod +x /www/cgi-bin/wake && `+
+ `printf '%%s\n' 'E404:/cgi-bin/wake' > /www/httpd.conf && `+
+ `httpd -p 80 -h /www && sleep infinity`,
+ strings.ReplaceAll(indexPage, "'", "'\\''"),
+ strings.ReplaceAll(wakeCGIScript, "'", "'\\''"))
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
@@ -329,9 +346,13 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
}
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
- Name: name,
- Image: wakeImage,
- Cmd: []string{"sh", "-c", cmd},
+ Name: name,
+ Image: wakeImage,
+ Cmd: []string{"sh", "-c", cmd},
+ Env: []string{
+ "WAKE_URLS=" + strings.Join(wakeURLs, " "),
+ "WAKE_PAGE=" + page,
+ },
Labels: labels,
Networks: endpoints,
})
@@ -353,25 +374,41 @@ func networkList(ctx context.Context, dc *docker.Client) []string {
return names
}
-// wakeEndpointURL builds the internal URL the wake container calls. The
-// edge network's gateway reaches the host's published API port; when the
-// API itself is containerized its own name resolves on the shared network.
-func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string {
+// wakeEndpointURLs builds ordered candidate URLs for the wake container to
+// call, deduplicated. No single address covers every topology: a containerized
+// API resolves by name on the shared network; Docker Desktop containers reach
+// the host via host.docker.internal; on native Docker the edge network's
+// gateway routes to host-published ports. busybox wget walks the chain until
+// one answers.
+func wakeEndpointURLs(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) []string {
+ var hosts []string
if dc.OwnNetworkName(ctx) != "" {
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
- name := strings.TrimPrefix(self.Name, "/")
- if name != "" {
- return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
+ if name := strings.TrimPrefix(self.Name, "/"); name != "" {
+ hosts = append(hosts, name)
}
}
}
+ hosts = append(hosts, "host.docker.internal")
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
- return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID)
+ hosts = append(hosts, gw)
}
if probe := dc.HostProbeHost(ctx); probe != "" {
- return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID)
+ hosts = append(hosts, probe)
}
- return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID)
+ seen := map[string]bool{}
+ var urls []string
+ for _, h := range hosts {
+ if h == "" || seen[h] {
+ continue
+ }
+ seen[h] = true
+ urls = append(urls, fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", h, apiPort, serviceID))
+ }
+ if len(urls) == 0 {
+ urls = append(urls, fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID))
+ }
+ return urls
}
func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
@@ -490,10 +527,13 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
defer cancel()
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
+ log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
+ _, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
+ log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return
}
diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go
index dfa00d5..b0c0f7c 100644
--- a/app/backend/internal/cli/commands/services.go
+++ b/app/backend/internal/cli/commands/services.go
@@ -117,6 +117,14 @@ var servicesCreateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetBool("spread"); v {
body["spread"] = v
}
+ if cmd.Flags().Changed("sleep") {
+ v, _ := cmd.Flags().GetBool("sleep")
+ body["sleep_enabled"] = v
+ }
+ if cmd.Flags().Changed("sleep-idle") {
+ v, _ := cmd.Flags().GetInt("sleep-idle")
+ body["sleep_idle_minutes"] = v
+ }
if v, _ := cmd.Flags().GetString("placement-tags"); v != "" {
tags := []string{}
for _, t := range strings.Split(v, ",") {
@@ -520,8 +528,10 @@ func init() {
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)")
- f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded (image services only)")
+ f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded")
f.Bool("spread", false, "distribute replicas across all online schedulable nodes")
+ f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
+ f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)")
uf := servicesUpdateCmd.Flags()
diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go
index 654771c..73149ea 100644
--- a/app/backend/internal/mcpserver/server.go
+++ b/app/backend/internal/mcpserver/server.go
@@ -53,7 +53,7 @@ func init() {
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
- register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, node_id (agent id | 'auto' = least-loaded online node; image services only — remote nodes can't run git builds), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
+ register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts
index e46f523..8ecb85e 100644
--- a/app/frontend/src/generated/api-types.ts
+++ b/app/frontend/src/generated/api-types.ts
@@ -10939,7 +10939,7 @@ export interface components {
sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440, default 15) */
sleep_idle_minutes?: number;
- /** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Remote nodes run registry-pulled images only (no git builds, no Traefik domains). */
+ /** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Git-sourced services build on the node itself (build context ships as an agent-token artifact); rollbacks of locally-built tags ship the image via docker save/load. */
node_id?: string;
/** @description Display name of the pinned node agent (read-only) */
node_name?: string;
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index a19a84a..c42b071 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -7013,7 +7013,7 @@ components:
description: Idle timeout in minutes before sleeping (1-1440, default 15)
node_id:
type: string
- description: Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Remote nodes run registry-pulled images only (no git builds, no Traefik domains).
+ description: Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Git-sourced services build on the node itself (build context ships as an agent-token artifact); rollbacks of locally-built tags ship the image via docker save/load.
node_name:
type: string
description: Display name of the pinned node agent (read-only)
diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh
index cd921e0..b3523c5 100755
--- a/scripts/build-cli.sh
+++ b/scripts/build-cli.sh
@@ -38,8 +38,17 @@ for platform in $PLATFORMS; do
-ldflags "$LDFLAGS" \
-o "bin/${MCP_NAME}" \
./cmd/mcp
-
- echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}"
+done
+
+# Node agents are linux-only — the download endpoint serves the same two
+# arches, and install-agent.sh falls back to these GH release assets.
+for arch in amd64 arm64; do
+ echo "📦 Building agent for linux/${arch}..."
+ CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build \
+ -ldflags "-X main.version=${VERSION}" \
+ -o "bin/containr-agent-linux-${arch}" \
+ ./cmd/agent
+ echo "✅ Built bin/containr-agent-linux-${arch}"
done
echo "🎉 CLI build complete!"