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!"