From dbc87bf34c7ca10bdf19d9bec3e549750494be82 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 9 Oct 2026 00:22:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(services):=20sleep=20mode=20=E2=80=94=20sc?= =?UTF-8?q?ale-to-zero=20on=20idle,=20wake=20on=20traffic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services gain sleep_enabled + sleep_idle_minutes. A 30s sweeper tracks container NetworkIO deltas; a flat counter for the configured window scales the service to zero (status=sleeping, workload containers removed). While slept, a busybox wake container takes over the service's Traefik host rule at priority 100: it serves a self-reloading "waking" page and calls the unauthenticated idempotent GET/POST /api/v1/internal/wake/:id, which reconciles the service back to running. Wake placeholders carry a dedicated label (containr.wake-for) so runtime state never counts them; the sweeper prunes them once the workload is up. The wake URL resolves the API via its container name when containerized, else the edge network's IPAM gateway — bare-metal installs need HOST=0.0.0.0 for the callback. Surfaces: PUT fields on services, POST /services/:id/sleep|wake, CLI --sleep/--sleep-idle + services sleep|wake, 2 MCP tools (89 total), OpenAPI paths/schema, and a Settings > Sleep card with toggle, idle minutes, and manual sleep/wake plus a Sleeping badge. Verified live: idle sweep scaled whoami-web to zero; the wake container served the page on the edge network and its callback woke the service (container recreated, status running, placeholder pruned). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/backend/internal/api/routes.go | 10 + app/backend/internal/api/services.go | 120 +++-- app/backend/internal/api/sleeper.go | 461 ++++++++++++++++++ app/backend/internal/api/sleeper_test.go | 41 ++ app/backend/internal/cli/commands/services.go | 12 +- app/backend/internal/docker/client.go | 15 + app/backend/internal/mcpserver/server.go | 4 +- .../20261016000000_sleep_mode.sql | 11 + .../service/pages/ServiceDetailPage.tsx | 104 +++- app/frontend/src/generated/api-types.ts | 132 +++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 8 +- docs/api/openapi.yaml | 66 +++ docs/guides/CLI.md | 19 + llms.txt | 1 + 15 files changed, 957 insertions(+), 49 deletions(-) create mode 100644 app/backend/internal/api/sleeper.go create mode 100644 app/backend/internal/api/sleeper_test.go create mode 100644 app/backend/migrations_goose/20261016000000_sleep_mode.sql diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 469bcbc..6e445c1 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -98,6 +98,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Start the cron scheduler — executes due cron_jobs via docker exec. StartCronScheduler(context.Background(), db, dockerClient) + // Sleep sweeper — scales idle services to zero and prunes wake containers. + StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) + // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) @@ -158,6 +161,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg c.Set("deployment_engine", deploymentEngine) } c.Set("deploy_queue", deployQueue) + c.Set("api_port", cfg.Port) c.Set("scheduler", scheduler) c.Set("metrics_collector", metricsCollector) c.Set("auto_scaler", autoScaler) @@ -249,6 +253,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg public.POST("/auth/login", handleLogin) public.POST("/auth/register", handleRegister) public.GET("/maintenance", handleMaintenancePage) + + // Wake callback for sleep-mode placeholders. Unauthenticated but + // non-destructive: it only reconciles the service back to running. + public.Any("/internal/wake/:id", handleServiceWake) } // Read routes — public browsing. OptionalAuth resolves a session when @@ -335,6 +343,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.POST("/services/:id/start", handleServiceStart) authed.POST("/services/:id/stop", handleServiceStop) authed.POST("/services/:id/restart", handleServiceRestart) + authed.POST("/services/:id/sleep", handleServiceSleep) + authed.POST("/services/:id/wake", handleServiceWakeNow) authed.POST("/services/:id/redeploy", handleServiceRedeploy) authed.POST("/services/:id/clone", handleCloneService) authed.POST("/services/:id/move", handleMoveService) diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index faa48aa..eeb0e83 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -31,23 +31,37 @@ type Service struct { CPU string `json:"cpu" db:"cpu"` Memory string `json:"memory" db:"memory"` // Runtime spec - Replicas int `json:"replicas" db:"replicas"` - Port int `json:"port" db:"port"` // container port to expose - Domain string `json:"domain" db:"domain"` // public hostname via Traefik - HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port - RestartPolicy string `json:"restart_policy" db:"restart_policy"` - PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time - Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB - Domains []ServiceDomain `json:"domains,omitempty" db:"-"` - MaintenanceMode bool `json:"maintenance_mode" db:"-"` - BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes - Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static - CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"` - MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"` - StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"` - StaticDir string `json:"static_dir,omitempty" db:"static_dir"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Replicas int `json:"replicas" db:"replicas"` + Port int `json:"port" db:"port"` // container port to expose + Domain string `json:"domain" db:"domain"` // public hostname via Traefik + HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port + RestartPolicy string `json:"restart_policy" db:"restart_policy"` + PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time + Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB + Domains []ServiceDomain `json:"domains,omitempty" db:"-"` + MaintenanceMode bool `json:"maintenance_mode" db:"-"` + BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes + Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static + CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"` + MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"` + StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"` + StaticDir string `json:"static_dir,omitempty" db:"static_dir"` + SleepEnabled bool `json:"sleep_enabled" db:"-"` + SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// loadServiceSleep fills the sleep-mode columns, which live outside the +// wide service SELECTs. Missing columns (pre-migration) degrade to off. +func loadServiceSleep(db *database.DB, s *Service) { + var idle int + if err := db.QueryRow( + `SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`, + s.ID, + ).Scan(&s.SleepEnabled, &idle); err == nil { + s.SleepIdleMinutes = idle + } } // ServiceVolume is a volume/bind mount applied to every replica. @@ -131,29 +145,31 @@ type CreateServiceRequest struct { // UpdateServiceRequest represents a request to update a service type UpdateServiceRequest struct { - Name string `json:"name" binding:"omitempty,min=1,max=255"` - Type string `json:"type" binding:"omitempty,oneof=web worker database cron"` - Image string `json:"image"` - Command string `json:"command"` - Environment string `json:"environment" binding:"omitempty,oneof=production preview development"` - GitRepo string `json:"git_repo"` - GitBranch string `json:"git_branch"` - BuildPath string `json:"build_path"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - Replicas *int `json:"replicas"` - Port *int `json:"port"` - Domain *string `json:"domain"` - HealthCheckPath *string `json:"healthcheck_path"` - RestartPolicy string `json:"restart_policy"` - Volumes *[]ServiceVolume `json:"volumes"` - MaintenanceMode *bool `json:"maintenance_mode"` - BasicAuth *[]BasicAuthCred `json:"basic_auth"` - Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"` - CPUReserve *string `json:"cpu_reserve"` - MemoryReserve *string `json:"memory_reserve"` - StaticBuildCmd *string `json:"static_build_cmd"` - StaticDir *string `json:"static_dir"` + Name string `json:"name" binding:"omitempty,min=1,max=255"` + Type string `json:"type" binding:"omitempty,oneof=web worker database cron"` + Image string `json:"image"` + Command string `json:"command"` + Environment string `json:"environment" binding:"omitempty,oneof=production preview development"` + GitRepo string `json:"git_repo"` + GitBranch string `json:"git_branch"` + BuildPath string `json:"build_path"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + Replicas *int `json:"replicas"` + Port *int `json:"port"` + Domain *string `json:"domain"` + HealthCheckPath *string `json:"healthcheck_path"` + RestartPolicy string `json:"restart_policy"` + Volumes *[]ServiceVolume `json:"volumes"` + MaintenanceMode *bool `json:"maintenance_mode"` + BasicAuth *[]BasicAuthCred `json:"basic_auth"` + Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"` + CPUReserve *string `json:"cpu_reserve"` + MemoryReserve *string `json:"memory_reserve"` + StaticBuildCmd *string `json:"static_build_cmd"` + StaticDir *string `json:"static_dir"` + SleepEnabled *bool `json:"sleep_enabled"` + SleepIdleMinutes *int `json:"sleep_idle_minutes"` } // handleGetServices retrieves all services for a project @@ -201,9 +217,10 @@ func handleGetServices(c *gin.Context) { COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''), COALESCE(static_dir, ''), - created_at, updated_at - FROM services - WHERE project_id = $1 + COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15), + created_at, updated_at + FROM services + WHERE project_id = $1 ORDER BY created_at DESC`, projectID, ) @@ -223,6 +240,7 @@ func handleGetServices(c *gin.Context) { &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.RestartPolicy, &service.Builder, &service.CPUReserve, &service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir, + &service.SleepEnabled, &service.SleepIdleMinutes, &service.CreatedAt, &service.UpdatedAt, ) if err != nil { @@ -524,6 +542,7 @@ func handleGetService(c *gin.Context) { }) } service.Domains = loadServiceDomains(db.(*database.DB), service.ID) + loadServiceSleep(db.(*database.DB), &service) maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID) service.MaintenanceMode = maintenance service.BasicAuth = basicAuthUsernames(basicAuth) @@ -665,6 +684,17 @@ func handleUpdateService(c *gin.Context) { if existingService.Builder == "static" && existingService.StaticDir == "" { existingService.StaticDir = "dist" } + loadServiceSleep(db.(*database.DB), &existingService) + if req.SleepEnabled != nil { + existingService.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 + } + existingService.SleepIdleMinutes = *req.SleepIdleMinutes + } for label, check := range map[string]func(string) error{ "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec, @@ -701,7 +731,8 @@ func handleUpdateService(c *gin.Context) { replicas = $11, port = $12, domain = $13, healthcheck_path = $14, restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16, builder = $19, cpu_reserve = $20, memory_reserve = $21, - static_build_cmd = $22, static_dir = $23 + static_build_cmd = $22, static_dir = $23, + sleep_enabled = $24, sleep_idle_minutes = $25 WHERE id = $18`, existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Environment, existingService.GitRepo, existingService.GitBranch, @@ -711,6 +742,7 @@ func handleUpdateService(c *gin.Context) { existingService.UpdatedAt, volumesArg, existingService.ID, existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve, existingService.StaticBuildCmd, existingService.StaticDir, + existingService.SleepEnabled, existingService.SleepIdleMinutes, ) if err != nil { diff --git a/app/backend/internal/api/sleeper.go b/app/backend/internal/api/sleeper.go new file mode 100644 index 0000000..4e4f7f4 --- /dev/null +++ b/app/backend/internal/api/sleeper.go @@ -0,0 +1,461 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + + "containr/internal/database" + "containr/internal/deployment" + "containr/internal/docker" + + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/registry" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Sleep mode: services with sleep_enabled are scaled to zero after +// sleep_idle_minutes without inbound traffic. Idleness is measured as +// NetworkIO byte deltas across the service's containers — we have no +// per-request layer in front of Traefik, so counters are the signal. +// +// When a service sleeps its containers are removed and a tiny busybox +// "wake" container takes over the same Traefik host rule at a higher +// priority: it serves a reloading "waking" page and pings the unauthenticated +// internal wake endpoint, which reconciles the service back to running. +// Wake containers carry a dedicated label (not the service label) so +// RuntimeState and reconcile never count them as replicas; the sweeper +// prunes them once the workload is running again. + +const ( + wakeImage = "busybox:1.36" + wakeLabel = "containr.wake" + wakeForLabel = "containr.wake-for" + wakeSweepInterval = 30 * time.Second +) + +type sleepTracker struct { + mu sync.Mutex + activity map[uuid.UUID]sleepActivity +} + +type sleepActivity struct { + bytes uint64 + idleSince time.Time +} + +var tracker = &sleepTracker{activity: make(map[uuid.UUID]sleepActivity)} + +// StartSleepSweeper polls enabled services and scales idle ones to zero. +func StartSleepSweeper(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) { + if db == nil || dc == nil || engine == nil { + return + } + go func() { + ticker := time.NewTicker(wakeSweepInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sweepSleeping(ctx, db, dc, engine, apiPort) + pruneWakeContainers(ctx, db, dc, engine) + } + } + }() + log.Printf("sleep sweeper: running (interval %s)", wakeSweepInterval) +} + +type sleepCandidate struct { + ID uuid.UUID + ProjectID uuid.UUID + Name string + Status string + IdleMinutes int +} + +func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) { + rows, err := db.QueryContext(ctx, + `SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15) + FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`) + if err != nil { + return + } + var candidates []sleepCandidate + for rows.Next() { + var s sleepCandidate + if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil { + candidates = append(candidates, s) + } + } + rows.Close() + + for _, svc := range candidates { + containers, err := engine.ListServiceContainers(ctx, svc.ID.String()) + if err != nil || len(containers) == 0 { + continue + } + // Ignore wake containers — they're the placeholder, not the workload. + workload := containers[:0] + for _, c := range containers { + if c.Labels[wakeLabel] != "true" { + workload = append(workload, c) + } + } + if len(workload) == 0 { + continue + } + var total uint64 + for _, c := range workload { + total += containerNetBytes(ctx, dc, c.ID) + } + if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) { + putToSleep(ctx, db, dc, engine, svc, apiPort) + } + } +} + +// markIdle returns true when the network counter stopped growing past the +// timeout. First sighting only sets the baseline — no idle without history. +func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool { + tracker.mu.Lock() + defer tracker.mu.Unlock() + a := tracker.activity[id] + if bytes != a.bytes || a.idleSince.IsZero() { + tracker.activity[id] = sleepActivity{bytes: bytes, idleSince: time.Now()} + return false + } + return time.Since(a.idleSince) >= timeout +} + +func containerNetBytes(ctx context.Context, dc *docker.Client, id string) uint64 { + stats, err := dc.GetContainerStats(ctx, id, false) + if err != nil || stats == nil { + return 0 + } + defer stats.Body.Close() + var snap struct { + Networks map[string]struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + } `json:"networks"` + } + if err := json.NewDecoder(stats.Body).Decode(&snap); err != nil { + return 0 + } + var total uint64 + for _, n := range snap.Networks { + total += n.RxBytes + n.TxBytes + } + return total +} + +// putToSleep removes the workload containers, marks the service sleeping, +// and installs the wake placeholder when the service has a public domain. +func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, apiPort int) { + containers, err := engine.ListServiceContainers(ctx, svc.ID.String()) + if err != nil { + return + } + for _, c := range containers { + if c.Labels[wakeLabel] == "true" { + continue + } + _ = dc.StopContainer(ctx, c.ID, nil) + _ = dc.RemoveContainer(ctx, c.ID, true) + } + tracker.mu.Lock() + delete(tracker.activity, svc.ID) + tracker.mu.Unlock() + _, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID) + log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID) + + var domain string + _ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain) + domains := serviceDomainNames(db, svc.ID, domain) + if len(domains) == 0 { + return + } + if err := spawnWakeContainer(ctx, db, dc, engine, svc, domains, apiPort); err != nil { + log.Printf("sleep: wake placeholder for %s failed: %v", svc.Name, err) + } +} + +// 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. +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) { + if n == "containr-network" { + edgeNet = n + } + } + if edgeNet == "" { + return fmt.Errorf("no edge network") + } + router := "svc-" + svc.ID.String()[:8] + sort.Strings(domains) + rules := make([]string, 0, len(domains)) + for _, d := range domains { + rules = append(rules, "Host(`"+d+"`)") + } + labels := map[string]string{ + deployment.LabelManaged: "true", + deployment.LabelProject: svc.ProjectID.String(), + wakeForLabel: svc.ID.String(), + wakeLabel: "true", + "traefik.enable": "true", + "traefik.docker.network": edgeNet, + "traefik.http.routers." + router + ".rule": strings.Join(rules, " || "), + "traefik.http.routers." + router + ".entrypoints": "web", + "traefik.http.routers." + router + ".priority": "100", + "traefik.http.services." + router + ".loadbalancer.server.port": "80", + } + + wakeURL := wakeEndpointURL(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. + 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) + + endpoints := map[string]*network.EndpointSettings{edgeNet: {}} + if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet { + endpoints[ownNet] = &network.EndpointSettings{} + } + + // Pull is best-effort — the image almost always sits in cache. + _ = dc.PullImageWait(ctx, wakeImage, registry.AuthConfig{}) + + // A previous placeholder may linger as Exited — clear the name first. + name := fmt.Sprintf("containr-wake-%s", svc.ID.String()[:8]) + if stale, err := dc.ListContainersFiltered(ctx, filters.NewArgs( + filters.Arg("name", "^/"+name+"$"), + ), true); err == nil { + for _, s := range stale { + _ = dc.RemoveContainer(ctx, s.ID, true) + } + } + + id, err := dc.CreateContainer(ctx, docker.ContainerConfig{ + Name: name, + Image: wakeImage, + Cmd: []string{"sh", "-c", cmd}, + Labels: labels, + Networks: endpoints, + }) + if err != nil { + return err + } + return dc.StartContainer(ctx, id) +} + +func networkList(ctx context.Context, dc *docker.Client) []string { + networks, err := dc.ListNetworks(ctx) + if err != nil { + return nil + } + names := make([]string, 0, len(networks)) + for _, n := range networks { + names = append(names, n.Name) + } + 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 { + 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 gw := networkGateway(ctx, dc, edgeNet); gw != "" { + return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID) + } + if probe := dc.HostProbeHost(ctx); probe != "" { + return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID) + } + return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID) +} + +func networkGateway(ctx context.Context, dc *docker.Client, name string) string { + // ListNetworks does not populate IPAM config — inspect is required. + return dc.NetworkGateway(ctx, name) +} + +func selfID() string { + // Inside a container the hostname is the short container ID. + if hn, err := os.Hostname(); err == nil { + return hn + } + return "" +} + +// pruneWakeContainers removes wake placeholders whose service is healthy +// again — belt & braces over the reconcile drift sweep. +func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine) { + containers, err := dc.ListContainersFiltered(ctx, filters.NewArgs(filters.Arg("label", wakeLabel+"=true")), true) + if err != nil { + return + } + for _, c := range containers { + svcID := c.Labels[wakeForLabel] + if svcID == "" { + continue + } + running, _ := engine.ListServiceContainers(ctx, svcID) + live := 0 + for _, r := range running { + if r.State == "running" { + live++ + } + } + var status string + _ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status) + if live > 0 || status == "stopped" || status == "" { + _ = dc.RemoveContainer(ctx, c.ID, true) + } + } +} + +// handleServiceWake is the unauthenticated internal endpoint the wake +// container hits. It reconciles the service back to running. Waking is +// idempotent and non-destructive, so no token is required; the endpoint +// never mutates configuration. +func handleServiceWake(c *gin.Context) { + dbVal, _ := c.Get("db") + db := dbVal.(*database.DB) + engineVal, _ := c.Get("deployment_engine") + engine, _ := engineVal.(*deployment.DeploymentEngine) + if engine == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"}) + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service id"}) + return + } + var service Service + err = db.QueryRow( + `SELECT s.id, s.project_id, s.name, + COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''), + COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''), + COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''), + COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''), + COALESCE(s.cpu, ''), COALESCE(s.memory, ''), + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), + COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''), + COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''), + COALESCE(s.static_dir, ''), + s.created_at, s.updated_at + FROM services s WHERE s.id = $1`, id, + ).Scan( + &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, + &service.Image, &service.Command, &service.Environment, &service.GitRepo, + &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.Builder, &service.CPUReserve, + &service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir, + &service.CreatedAt, &service.UpdatedAt, + ) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "service not found"}) + return + } + if service.Status == "running" || service.Status == "deployed" { + c.JSON(http.StatusOK, gin.H{"status": service.Status}) + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + spec, err := serviceRuntimeSpec(db, service) + if err != nil { + return + } + state, err := engine.ReconcileService(ctx, spec) + if err != nil { + _, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) + return + } + status := "running" + if state != nil { + status = state.Status + persistPublishedPort(db, service.ID, state.Ports) + } + _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) + }() + c.JSON(http.StatusAccepted, gin.H{"status": "waking"}) +} + +// handleServiceSleep puts a service to sleep immediately (owner+ only). +func handleServiceSleep(c *gin.Context) { + dbVal, _ := c.Get("db") + db := dbVal.(*database.DB) + engineVal, _ := c.Get("deployment_engine") + engine, _ := engineVal.(*deployment.DeploymentEngine) + dcVal, _ := c.Get("docker_client") + dc, _ := dcVal.(*docker.Client) + if engine == nil || dc == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"}) + return + } + service, ok := loadOwnedService(c, db) + if !ok { + return + } + var port int + _ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port) + svc := sleepCandidate{ + ID: service.ID, + ProjectID: service.ProjectID, + Name: service.Name, + Status: service.Status, + } + go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c)) + c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"}) +} + +// handleServiceWakeNow wakes a sleeping service on demand (owner+ only). +func handleServiceWakeNow(c *gin.Context) { + if _, ok := loadOwnedService(c, c.MustGet("db").(*database.DB)); !ok { + return + } + handleServiceWake(c) +} + +// apiPortFromContext pulls the configured HTTP port for the wake callback URL. +func apiPortFromContext(c *gin.Context) int { + if v, exists := c.Get("api_port"); exists { + if p, ok := v.(int); ok { + return p + } + } + return 8080 +} diff --git a/app/backend/internal/api/sleeper_test.go b/app/backend/internal/api/sleeper_test.go new file mode 100644 index 0000000..9e5d31c --- /dev/null +++ b/app/backend/internal/api/sleeper_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestMarkIdleTracksCounter(t *testing.T) { + id := uuid.New() + timeout := 50 * time.Millisecond + + // First sighting sets the baseline only. + if markIdle(id, 100, timeout) { + t.Fatal("slept on first sighting") + } + // Counter growth resets the idle clock. + if markIdle(id, 200, timeout) { + t.Fatal("slept while counter grew") + } + // Flat counter within the window doesn't sleep. + if markIdle(id, 200, timeout) { + t.Fatal("slept before timeout") + } + time.Sleep(60 * time.Millisecond) + if !markIdle(id, 200, timeout) { + t.Fatal("did not sleep after timeout") + } +} + +func TestMarkIdleZeroCounterSleeps(t *testing.T) { + id := uuid.New() + timeout := 20 * time.Millisecond + + markIdle(id, 0, timeout) + time.Sleep(30 * time.Millisecond) + if !markIdle(id, 0, timeout) { + t.Fatal("zero-traffic service never slept") + } +} diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index 44c561b..0c61134 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -195,6 +195,14 @@ var servicesUpdateCmd = &cobra.Command{ } body["volumes"] = volumes } + 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 cmd.Flags().Changed("maintenance") { v, _ := cmd.Flags().GetString("maintenance") body["maintenance_mode"] = v == "on" || v == "true" @@ -504,8 +512,10 @@ func init() { uf.String("memory-reserve", "", "soft memory reservation (empty clears)") uf.String("static-cmd", "", "build command for the static builder (empty clears)") uf.String("static-dir", "", "output dir for the static builder (empty clears)") + uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)") + uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") - for _, a := range []string{"start", "stop", "restart", "redeploy"} { + for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} { verb := a ServicesCmd.AddCommand(&cobra.Command{ Use: verb + " ", diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index 64359ca..b1d08f1 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -371,6 +371,21 @@ func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error { return c.cli.NetworkRemove(ctx, networkID) } +// NetworkGateway returns the IPAM gateway of a network — the address +// containers on it can use to reach host-published ports. +func (c *Client) NetworkGateway(ctx context.Context, networkID string) string { + resp, err := c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{}) + if err != nil { + return "" + } + for _, cfg := range resp.IPAM.Config { + if cfg.Gateway != "" { + return cfg.Gateway + } + } + return "" +} + // ConnectNetwork connects a container to a network func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error { return c.cli.NetworkConnect(ctx, networkID, containerID, &config) diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index ec9e20f..633ed4d 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -58,12 +58,14 @@ func init() { 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}) register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false}) + register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false}) + register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false}) register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false}) register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) - register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields).", "PUT", "/services/{id}", []string{"id"}, nil, false}) + register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes.", "PUT", "/services/{id}", []string{"id"}, nil, false}) // Domains register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) diff --git a/app/backend/migrations_goose/20261016000000_sleep_mode.sql b/app/backend/migrations_goose/20261016000000_sleep_mode.sql new file mode 100644 index 0000000..254edb2 --- /dev/null +++ b/app/backend/migrations_goose/20261016000000_sleep_mode.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_idle_minutes INTEGER NOT NULL DEFAULT 15; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE services DROP COLUMN IF EXISTS sleep_enabled; +ALTER TABLE services DROP COLUMN IF EXISTS sleep_idle_minutes; +-- +goose StatementEnd diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index d03e1ef..8815f60 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -33,6 +33,8 @@ import { updateService, restartService, stopService, + sleepService, + wakeService, getServiceRuntime, type CronJobEntity, type ServiceVolume, @@ -459,6 +461,14 @@ export function ServiceDetailPage() { mutationFn: () => stopService(serviceId), onSuccess: invalidateService, }); + const sleepMutation = useMutation({ + mutationFn: () => sleepService(serviceId), + onSuccess: invalidateService, + }); + const wakeMutation = useMutation({ + mutationFn: () => wakeService(serviceId), + onSuccess: invalidateService, + }); const updateServiceMutation = useMutation({ mutationFn: (input: Parameters[1]) => updateService(serviceId, input), onSuccess: () => { @@ -481,6 +491,24 @@ export function ServiceDetailPage() { const [domainInput, setDomainInput] = useState(''); const [domainChecks, setDomainChecks] = useState | null>(null); const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); + const [sleepForm, setSleepForm] = useState<{ enabled: boolean; minutes: string; dirty: boolean }>({ + enabled: false, + minutes: '15', + dirty: false, + }); + const sleepFormInitRef = useRef(''); + useEffect(() => { + const svc = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; + const key = svc ? `${svc.id}:${svc.sleepEnabled}:${svc.sleepIdleMinutes}` : ''; + if (svc && key !== sleepFormInitRef.current) { + sleepFormInitRef.current = key; + setSleepForm({ + enabled: Boolean(svc.sleepEnabled), + minutes: String(svc.sleepIdleMinutes ?? 15), + dirty: false, + }); + } + }, [isDemoMode, serviceId, serviceQuery.data]); const [buildForm, setBuildForm] = useState<{ builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string; } | null>(null); @@ -689,7 +717,7 @@ export function ServiceDetailPage() { className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`} > {service.status === 'running' && } - {service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'} + {service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : service.status === 'sleeping' ? 'Sleeping' : 'Stopped'}
@@ -2339,6 +2367,80 @@ export function ServiceDetailPage() {
)} + {!isDemoMode && ( +
+
+
+

Sleep

+

+ Scale to zero after idle minutes without traffic. Requests resume the service automatically. +

+
+ {service.status === 'sleeping' ? ( + + ) : ( + + )} +
+
+ + + + {sleepForm.dirty && ( + unsaved + )} +
+
+ )} + {!isDemoMode && (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index dbc1a9a..04c756c 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -932,6 +932,130 @@ export interface paths { patch?: never; trace?: never; }; + "/services/{id}/sleep": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Sleep service + * @description Scale the service to zero immediately. When it has public domains a wake placeholder holds the route and resumes the service on the next request. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sleep initiated */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/services/{id}/wake": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Wake service + * @description Reconcile a sleeping service back to running on demand + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Wake initiated */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/internal/wake/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Wake callback (internal) + * @description Unauthenticated wake endpoint hit by the busybox wake placeholder while a slept service resumes. Idempotent and non-destructive. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Wake accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/services/{id}/redeploy": { parameters: { query?: never; @@ -8936,6 +9060,10 @@ export interface components { static_build_cmd?: string; /** @description Output directory for the static builder (default dist) */ static_dir?: string; + /** @description Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while the service resumes */ + sleep_enabled?: boolean; + /** @description Idle timeout in minutes before sleeping (1-1440, default 15) */ + sleep_idle_minutes?: number; /** @description Computed public URL (published port or domain) */ public_url?: string; /** @@ -9063,6 +9191,10 @@ export interface components { static_build_cmd?: string; /** @description Output directory for the static builder (empty string clears) */ static_dir?: string; + /** @description Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while resuming */ + sleep_enabled?: boolean; + /** @description Idle timeout in minutes before sleeping (1-1440) */ + sleep_idle_minutes?: number; }; ServiceDomain: { /** Format: uuid */ diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 84d7970..d1b90a4 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/CLI.md", "section": "guides", "title": "Containr CLI", - "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 51f3121..14309a3 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -63,6 +63,8 @@ export type ServiceEntity = { memoryReserve?: string; staticBuildCmd?: string; staticDir?: string; + sleepEnabled?: boolean; + sleepIdleMinutes?: number; publicUrl?: string; }; @@ -611,6 +613,8 @@ function normalizeService(service: RawService): ServiceEntity | null { memoryReserve: service.memory_reserve, staticBuildCmd: service.static_build_cmd, staticDir: service.static_dir, + sleepEnabled: service.sleep_enabled, + sleepIdleMinutes: service.sleep_idle_minutes, publicUrl: service.public_url, }; } @@ -1290,13 +1294,15 @@ export async function getServiceRuntime(serviceId: string): Promise { +async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart' | 'sleep' | 'wake'): Promise { await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' }); } export const startService = (serviceId: string) => serviceAction(serviceId, 'start'); export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop'); export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart'); +export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep'); +export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake'); export async function redeployService(serviceId: string): Promise { const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, { diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 92e2ded..fab24f7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -649,6 +649,60 @@ paths: '200': description: Service restarted + /services/{id}/sleep: + post: + tags: + - Services + summary: Sleep service + description: Scale the service to zero immediately. When it has public domains a wake placeholder holds the route and resumes the service on the next request. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '202': + description: Sleep initiated + + /services/{id}/wake: + post: + tags: + - Services + summary: Wake service + description: Reconcile a sleeping service back to running on demand + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '202': + description: Wake initiated + + /internal/wake/{id}: + get: + tags: + - Services + summary: Wake callback (internal) + description: Unauthenticated wake endpoint hit by the busybox wake placeholder while a slept service resumes. Idempotent and non-destructive. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '202': + description: Wake accepted + '404': + description: Service not found + /services/{id}/redeploy: post: tags: @@ -5763,6 +5817,12 @@ components: static_dir: type: string description: Output directory for the static builder (default dist) + sleep_enabled: + type: boolean + description: Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while the service resumes + sleep_idle_minutes: + type: integer + description: Idle timeout in minutes before sleeping (1-1440, default 15) public_url: type: string description: Computed public URL (published port or domain) @@ -5934,6 +5994,12 @@ components: static_dir: type: string description: Output directory for the static builder (empty string clears) + sleep_enabled: + type: boolean + description: Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while resuming + sleep_idle_minutes: + type: integer + description: Idle timeout in minutes before sleeping (1-1440) ServiceDomain: type: object diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 28dc402..b831a60 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -159,6 +159,25 @@ instance-level defaults used at service creation; `app_settings.capacity_policy=block` fails deployments whose requested memory exceeds node capacity (default `warn` logs only). +## Sleep mode (scale-to-zero) + +Services can scale to zero after an idle window and resume on traffic: + +```bash +containr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout +containr services update --sleep=false # disable +containr services sleep # sleep now +containr services wake # resume now +``` + +Idleness is measured as flat container network I/O over the configured +window (swept every 30s). Sleeping removes the workload containers and — +when the service has domains — installs a busybox placeholder that holds +the Traefik route at higher priority, serves a reloading "waking" page, +and pings `GET /api/v1/internal/wake/:id` to reconcile the service back. +The API must be reachable from containers for the callback (containerized +deploys work via the shared network; bare-metal needs `HOST=0.0.0.0`). + ## Clone, move, registries `services clone` duplicates config, volumes, domains, access gates, diff --git a/llms.txt b/llms.txt index c7f0627..15b8259 100644 --- a/llms.txt +++ b/llms.txt @@ -25,6 +25,7 @@ Base URL: `https:///api/v1`. OpenAPI: `docs/api/openapi.yaml`. - **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists. - **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases//` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it. - **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup `. +- **Sleep mode**: `PUT /services/{id} {sleep_enabled, sleep_idle_minutes}` scales a service to zero after N idle minutes (network-byte deltas, 30s sweep). Status becomes `sleeping`. A busybox wake container then holds the service's Traefik host rule at priority 100, serves a reloading "waking" page, and hits `GET /api/v1/internal/wake/{id}` (unauthenticated, idempotent) which reconciles the service back. `POST /services/{id}/sleep|wake` for manual control. Requires the API reachable from containers (containerized deploy or `HOST=0.0.0.0`). - **Template graphs (v2)**: a template `config` with `services[]` deploys a multi-service stack. Members declare `key`, `type` (`web|worker|cron|database`), `runtime`, `environment`, `depends_on`. Env values support expressions: `{{secret}}`, `{{secret(N)}}`, `{{random:N}}`, `{{VAR}}`, `{{VAR:-default}}`, `{{service..}}` (props: `host`, `port`, `url`, `user`, `password`, `database`). `database` members become managed databases attached to the project network under their key — siblings resolve them by name. `POST /templates/{id}/plan` dry-resolves; `POST /templates/deploy` deploys an inline graph; `POST /templates/import/compose` converts docker-compose YAML (known db images → managed databases). Service↔service refs `${{svc.KEY}}` also resolve at runtime (HOST/PORT or env vars). - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.