mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
604319ee06
commit
c5562109bf
7 files changed
+107
-31
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user