fix(sleep): request-triggered wake, create-time config, agent release assets (#62)

- 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>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 22:40:35 +02:00
1 parent 604319ee06
commit c5562109bf
7 files changed
+107 -31

No files matched your search

+19 -2
View File
@@ -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,
)
+63 -23
View File
@@ -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 := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
// 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 := `<!doctype html><html><head><meta http-equiv="refresh" content="0;url=/cgi-bin/wake"></head><body></body></html>`
// 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
}
+11 -1
View File
@@ -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()
+1 -1
View File
@@ -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})
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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)
+11 -2
View File
@@ -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!"