Skip to content

Commit 197bd02

Browse files
feat(deploy): scale-to-zero — Scale() compute method + wake endpoint (#54) (#261)
* feat(deploy): scale-to-zero — Scale() compute method + wake endpoint (Task #54) API half of scale-to-zero (idle descheduling). Flag-gated behind DEPLOY_SCALE_TO_ZERO_ENABLED (default OFF) — fully inert when off. - migration 068: deployments.last_activity_at / scaled_to_zero / always_on (+ partial idle-candidate index; backfill last_activity_at from updated_at). - compute.Provider.Scale(appID, replicas): k8s patches Deployment replicas in place (NotFound = no-op so a stale row can't wedge the scaler; idempotent on already-at-target); noop logs + no-ops. - POST /deploy/:id/wake: explicit fast wake — scales back to 1 + clears sleep state. 501 when flag off (no scale, no DB write — proven by flag-off test). Documented cold-start contract (api is not in the request path; transparent wake-on-request needs an activator, out of scope for v1). - model helpers: MarkDeploymentScaledToZero (CAS: healthy + not-zeroed + not-always-on), WakeDeployment, SetDeploymentAlwaysOn; redeploy (MarkDeploymentBuilding) clears scaled_to_zero + bumps last_activity_at. - deploymentToMap surfaces scaled_to_zero/always_on; OpenAPI documents /wake. Tests: k8s Scale (down/wake/idempotent/notfound/get+update errors), noop Scale, wake flag-off 501-inert (panicking provider proves compute is never reached), model CAS/wake/pin/redeploy-clears (DB-gated, run in CI). Awaiting operator enable of DEPLOY_SCALE_TO_ZERO_ENABLED to verify real scale-down in prod. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(openapi): regenerate snapshot for /deploy/:id/wake (#54) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(deploy): wake handler happy-path + error-branch coverage (#54) sqlmock-driven coverage for the flag-ON Wake branches (happy path scale+flip+ re-read, not-found, cross-team 404, scale-failure 503, DB-flip 503) so the 100%-patch gate is satisfied on deploy_wake.go's handler body. The flag-off 501-inert path stays in deploy_wake_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(deploy): add mig-068 scale-to-zero columns to redeploy sqlmock rows FindActiveDeploymentByTeamEnvName / ByAppID now scan 33 columns after mig 068 added last_activity_at, scaled_to_zero, always_on to deployments. The redeploy in-place mock rows still provided 30, so scanDeployment failed with "expected 30 destination arguments in Scan, not 33" and reded TestDeployNew_Redeploy_WrongTeam_DefenceInDepth and TestDeployNew_Redeploy_UpdateStatusError_StillAccepts. Extend deploymentColumnsList + every AddRow tuple in this file (7 mock rows across the ByTeamEnvName and ByAppID query paths) with the 3 new columns in the model's Scan order: sql.NullTime{}, false, false. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(scale-to-zero): cover the 6 patch-coverage-flagged changed lines The patch-coverage gate (100% of changed lines) flagged 6 uncovered lines added by the scale-to-zero feature commits: - config.go 517-518: the DEPLOY_SCALE_TO_ZERO_ENABLED=true branch — add TestLoad_DeployScaleToZeroEnabled (truthy + falsy table, mirrors the DeploySourceGitEnabled test) and register the key in allKeys(). - deploy_wake.go 57-58 / 67 / 101-105: the requireTeam-error arm, the generic GetDeploymentByAppID driver-error (503 fetch_failed) arm, and the post-write re-read-failure fallback (scale+DB already succeeded → 200, not 5xx). Add TestWake_RequireTeamFails, TestWake_FetchDriverError503, TestWake_ReReadFailureFallsBack + a no-auth app helper. - deployment.go 591-592 / 610-611 / 626-627: the fmt.Errorf error returns of MarkDeploymentScaledToZero / WakeDeployment / SetDeploymentAlwaysOn. Add sqlmock-driven *_DriverError tests (the happy + CAS/RowsAffected paths are already covered by the real-DB tests). Test-only; no production code change. Flag remains default-OFF. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(deploy): fix remaining mig-068 mock sites — full-row helper + MarkBuilding SQL Two more mock sites still on the pre-068 shape, surfaced by the model/handler *_Branches + redeploy-CASMiss tests: - coverage_provision_gate_test.go: deploymentMockCols() + deploymentMockRow() (the shared full-deployment mock used by every models *_Branches test) now include last_activity_at / scaled_to_zero / always_on (33 cols, matching scanDeployment) — fixes the "expected 30 destination arguments, not 33" Scan errors in TestGetDeploymentByAppID/ByID/ByTeam/...Branches. - MarkDeploymentBuilding's SQL now also sets scaled_to_zero=false + last_activity_at=now() on redeploy (a redeploy is activity + brings replicas back to 1). Update the ExpectExec regexes that pinned the old SQL: 2 in coverage_deployment_test.go, 5 in deploy_redeploy_inplace_mock_test.go. Completes the rule-16 enumeration of every deployments-row mock site rippled by migration 068. Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(deploy): map POST /deploy/:id/wake in DoneBar route-coverage guard The scale-to-zero wake route is live in the router but had no routeTestMap row, so TestDoneBar_EveryRouteCovered failed ("route POST /deploy/:id/wake has no mapped test and no exemption"). Map it to TestWake_HappyPath — the flag-ON handler suite (deploy_wake_mock_test.go) drives the route through requireTeam + the scale + DB-flip + re-read contract, which the guard's ../handlers AST scan recognises as a covering integration test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy): add agent_action for scale_to_zero_disabled + wake_failed codes TestErrorCode_HasAgentAction caught the two new wake-path error codes (deploy_wake.go) lacking codeToAgentAction entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9a6fc90 commit 197bd02

26 files changed

Lines changed: 1344 additions & 13 deletions

internal/config/config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,15 @@ type Config struct {
197197
// Off → /deploy/new rejects source=git with 501; tarball/image unaffected.
198198
DeploySourceGitEnabled bool
199199

200+
// DeployScaleToZeroEnabled gates scale-to-zero (idle descheduling, Task #54).
201+
// Default FALSE: the worker idle-scaler patches idle Deployments to
202+
// replicas=0 and the api wake path (POST /deploy/:id/wake) brings them back.
203+
// Off → the wake endpoint returns 501 and nothing in the api scales an app;
204+
// the worker idle-scaler is independently gated by its own
205+
// DEPLOY_SCALE_TO_ZERO_ENABLED env so the two services share the flag name.
206+
// Enabling it is an operator action (see infra runbook) after a canary.
207+
DeployScaleToZeroEnabled bool
208+
200209
// ResourceCountCapsEnabled gates per-service resource-count enforcement
201210
// (Task #55). Default FALSE: when off, the count-check block in every
202211
// provision handler (db/vector/cache/nosql/storage) is skipped entirely —
@@ -512,6 +521,16 @@ func Load() *Config {
512521
cfg.DeploySourceGitEnabled = false
513522
}
514523

524+
// DEPLOY_SCALE_TO_ZERO_ENABLED: default FALSE (off until operator canary).
525+
// Shared flag name with the worker idle-scaler; the api half gates the wake
526+
// endpoint + any api-initiated scale, the worker half gates the idle sweep.
527+
switch strings.ToLower(strings.TrimSpace(os.Getenv("DEPLOY_SCALE_TO_ZERO_ENABLED"))) {
528+
case "true", "1", "yes":
529+
cfg.DeployScaleToZeroEnabled = true
530+
default:
531+
cfg.DeployScaleToZeroEnabled = false
532+
}
533+
515534
// RESOURCE_COUNT_CAPS_ENABLED: default FALSE (Task #55). Off → the per-service
516535
// count-check block in every provision handler is skipped (zero behavior
517536
// change). On → over-cap provisions get 402. Operator action after a usage

internal/config/config_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func allKeys() []string {
6363
"METRICS_TOKEN", "DASHBOARD_BASE_URL", "API_PUBLIC_URL",
6464
"DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED",
6565
"DEPLOY_SOURCE_IMAGE_ENABLED", "DEPLOY_SOURCE_GIT_ENABLED",
66+
"DEPLOY_SCALE_TO_ZERO_ENABLED",
6667
"RESOURCE_COUNT_CAPS_ENABLED",
6768
"GITHUB_APP_ENABLED", "GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_PRIVATE_KEY",
6869
"GITHUB_APP_WEBHOOK_SECRET", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET",
@@ -388,6 +389,21 @@ func TestLoad_DeploySourceGitEnabled(t *testing.T) {
388389
}
389390
}
390391

392+
func TestLoad_DeployScaleToZeroEnabled(t *testing.T) {
393+
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
394+
applyBaselineEnv(t, map[string]string{"DEPLOY_SCALE_TO_ZERO_ENABLED": val})
395+
if !Load().DeployScaleToZeroEnabled {
396+
t.Errorf("DEPLOY_SCALE_TO_ZERO_ENABLED=%q should enable", val)
397+
}
398+
}
399+
for _, val := range []string{"false", "0", "no", "maybe", ""} {
400+
applyBaselineEnv(t, map[string]string{"DEPLOY_SCALE_TO_ZERO_ENABLED": val})
401+
if Load().DeployScaleToZeroEnabled {
402+
t.Errorf("DEPLOY_SCALE_TO_ZERO_ENABLED=%q should stay disabled", val)
403+
}
404+
}
405+
}
406+
391407
func TestLoad_ResourceCountCapsEnabled(t *testing.T) {
392408
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
393409
applyBaselineEnv(t, map[string]string{"RESOURCE_COUNT_CAPS_ENABLED": val})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
-- 068_deploy_scale_to_zero.sql — scale-to-zero (idle descheduling) state columns.
2+
--
3+
-- WHY: a deployed-but-idle app costs a full pod's worth of compute even when it
4+
-- serves zero requests. Scale-to-zero (Task #54) lets the worker patch an idle
5+
-- Deployment to replicas=0 (~$0 compute) and wake it back to replicas=1 on
6+
-- demand. This migration adds the per-deployment state the idle-scaler and the
7+
-- wake path read/write. The whole feature is gated behind the
8+
-- DEPLOY_SCALE_TO_ZERO_ENABLED worker env flag (default OFF), so these columns
9+
-- are inert — populated at create-time but acted upon only when an operator
10+
-- enables the flag.
11+
--
12+
-- Columns:
13+
-- last_activity_at TIMESTAMPTZ — floor "last known activity" marker. Set to
14+
-- now() at create-time, bumped on every wake
15+
-- and on redeploy. The idle-scaler descheduals
16+
-- a Deployment only when
17+
-- now() - last_activity_at > idle_threshold.
18+
--
19+
-- v1 NOTE: the api is NOT in the request path
20+
-- (apps are served by k8s Ingress straight to
21+
-- the per-app Service), and no nginx-ingress
22+
-- request-total scrape is wired yet, so the
23+
-- honest "activity" signal v1 captures is
24+
-- deploy / redeploy / explicit-wake events —
25+
-- NOT per-HTTP-request traffic. A follow-up
26+
-- (documented in the worker job header) will
27+
-- wire an ingress request-counter to bump this
28+
-- column on real traffic for true
29+
-- traffic-based idle detection.
30+
--
31+
-- scaled_to_zero BOOLEAN — true while the app is currently descheduled
32+
-- (replicas=0). The wake path reads this to
33+
-- decide whether a scale-up is needed; the
34+
-- dashboard/agent reads it to show "sleeping".
35+
-- The idle-scaler sets it true on scale-down,
36+
-- the wake path sets it false on scale-up.
37+
--
38+
-- always_on BOOLEAN — per-app opt-out. A pinned app (an operator
39+
-- or Pro+ user who wants zero cold-starts) is
40+
-- never descheduled by the idle-scaler. Default
41+
-- false → eligible for scale-to-zero.
42+
--
43+
-- Idempotent + forward-only. Existing rows get last_activity_at backfilled from
44+
-- updated_at (their most recent known activity) so the idle-scaler does not
45+
-- immediately deschedule every pre-existing deploy the first time the flag is
46+
-- turned on; scaled_to_zero / always_on default to false.
47+
48+
ALTER TABLE deployments
49+
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ,
50+
ADD COLUMN IF NOT EXISTS scaled_to_zero BOOLEAN NOT NULL DEFAULT false,
51+
ADD COLUMN IF NOT EXISTS always_on BOOLEAN NOT NULL DEFAULT false;
52+
53+
-- Backfill: seed last_activity_at from updated_at for every pre-existing row so
54+
-- the very first idle-scaler tick after the flag is enabled treats existing
55+
-- deploys as "recently active" rather than immediately idle. New rows set
56+
-- last_activity_at = now() at INSERT time (see CreateDeployment).
57+
UPDATE deployments
58+
SET last_activity_at = COALESCE(updated_at, created_at, now())
59+
WHERE last_activity_at IS NULL;
60+
61+
-- Partial index: the idle-scaler scans for healthy, eligible, not-yet-zeroed
62+
-- deployments ordered by activity. Excluding always_on + already-zeroed +
63+
-- terminal rows keeps the index narrow and the scan cheap.
64+
CREATE INDEX IF NOT EXISTS idx_deployments_idle_candidates
65+
ON deployments (last_activity_at)
66+
WHERE status = 'healthy'
67+
AND scaled_to_zero = false
68+
AND always_on = false;

internal/handlers/deploy.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,11 @@ func deploymentToMapWithDB(d *models.Deployment, db *sql.DB) fiber.Map {
572572
// image_ref is echoed (caller-supplied, no secret); registry_creds is
573573
// NEVER returned — only registry_creds_set lifecycle metadata.
574574
"source": deploymentSourceOrDefault(d.Source),
575+
// Scale-to-zero state (migration 068). scaled_to_zero=true → the app is
576+
// asleep (replicas=0); the dashboard/agent surfaces "sleeping — wake"
577+
// and POSTs /deploy/:id/wake. always_on=true → pinned (never descheduled).
578+
"scaled_to_zero": d.ScaledToZero,
579+
"always_on": d.AlwaysOn,
575580
}
576581
if d.Source == "image" {
577582
m["image_ref"] = d.ImageRef

internal/handlers/deploy_buildfailed_autopsy_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ func (m *mockProvider) Redeploy(_ context.Context, _ string, _ []byte, _ map[str
5656
func (m *mockProvider) UpdateAccessControl(_ context.Context, _ string, _ bool, _ []string) error {
5757
panic("mockProvider.UpdateAccessControl: not expected in this test")
5858
}
59+
func (m *mockProvider) Scale(_ context.Context, _ string, _ int32) error {
60+
panic("mockProvider.Scale: not expected in this test")
61+
}
5962

6063
// mockBuildLogFetcher wraps mockProvider and adds FetchBuildLogs so the handler
6164
// code can type-assert to compute.BuildLogFetcher.

internal/handlers/deploy_redeploy_inplace_mock_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ var deploymentColumnsList = []string{
7777
"expires_at", "ttl_policy", "reminders_sent", "last_reminder_at",
7878
"source", "image_ref", "registry_creds_enc",
7979
"git_url", "git_ref", "git_token_enc",
80+
"last_activity_at", "scaled_to_zero", "always_on",
8081
}
8182

8283
// redeployMockApp wires a minimal Fiber app that drives DeployHandler.New
@@ -256,6 +257,7 @@ func TestDeployNew_Redeploy_WrongTeam_DefenceInDepth(t *testing.T) {
256257
sql.NullTime{}, "permanent", 0, sql.NullTime{}, // ttl_*
257258
"tarball", "", "", // source, image_ref, registry_creds_enc (mig 064)
258259
"", "", "", // git_url, git_ref, git_token_enc (mig 065)
260+
sql.NullTime{}, false, false, // last_activity_at, scaled_to_zero, always_on (mig 068)
259261
))
260262

261263
body, ct := multipartRedeployMockBody(t, map[string]string{
@@ -328,14 +330,15 @@ func TestDeployNew_Redeploy_UpdateStatusError_StillAccepts(t *testing.T) {
328330
sql.NullTime{}, "permanent", 0, sql.NullTime{},
329331
"tarball", "", "", // source, image_ref, registry_creds_enc (mig 064)
330332
"", "", "", // git_url, git_ref, git_token_enc (mig 065)
333+
sql.NullTime{}, false, false, // last_activity_at, scaled_to_zero, always_on (mig 068)
331334
))
332335

333336
// MarkDeploymentBuilding (guarded CAS) → driver error. The handler must
334337
// slog.Warn and CONTINUE (NOT return 5xx) — a driver error is
335338
// non-determinate (we can't tell whether the flip landed), and
336339
// runRedeployAsync will reconcile the row later. Only an explicit 0-row
337340
// CAS miss means "reaped concurrently, return 409".
338-
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
341+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL,\s+scaled_to_zero = false, last_activity_at = now\(\),\s+updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
339342
WithArgs(rowID).
340343
WillReturnError(errMockRedeployDriver)
341344

@@ -420,6 +423,7 @@ func TestDeployNew_Redeploy_EmptyProviderID_Returns409(t *testing.T) {
420423
sql.NullTime{}, "permanent", 0, sql.NullTime{},
421424
"tarball", "", "", // source, image_ref, registry_creds_enc (mig 064)
422425
"", "", "", // git_url, git_ref, git_token_enc (mig 065)
426+
sql.NullTime{}, false, false, // last_activity_at, scaled_to_zero, always_on (mig 068)
423427
))
424428

425429
body, ct := multipartRedeployMockBody(t, map[string]string{
@@ -486,10 +490,11 @@ func TestDeployNew_Redeploy_CASMiss_Returns409(t *testing.T) {
486490
sql.NullTime{}, "permanent", 0, sql.NullTime{},
487491
"tarball", "", "", // source, image_ref, registry_creds_enc (mig 064)
488492
"", "", "", // git_url, git_ref, git_token_enc (mig 065)
493+
sql.NullTime{}, false, false, // last_activity_at, scaled_to_zero, always_on (mig 068)
489494
))
490495

491496
// Guarded CAS matches 0 rows — the reaper won the race. Handler 409s.
492-
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
497+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL,\s+scaled_to_zero = false, last_activity_at = now\(\),\s+updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
493498
WithArgs(rowID).
494499
WillReturnResult(sqlmock.NewResult(0, 0))
495500

@@ -597,10 +602,11 @@ func TestDeployRedeploy_ByID_CASMiss_Returns409(t *testing.T) {
597602
sql.NullTime{}, "permanent", 0, sql.NullTime{},
598603
"tarball", "", "",
599604
"", "", "",
605+
sql.NullTime{}, false, false,
600606
))
601607

602608
// Guarded CAS matches 0 rows — the reaper won the race after the read.
603-
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
609+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL,\s+scaled_to_zero = false, last_activity_at = now\(\),\s+updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
604610
WithArgs(rowID).
605611
WillReturnResult(sqlmock.NewResult(0, 0))
606612

@@ -652,9 +658,10 @@ func TestDeployRedeploy_ByID_CASSuccess_Returns202(t *testing.T) {
652658
sql.NullString{}, sql.NullString{}, "unset", 0,
653659
sql.NullTime{}, "permanent", 0, sql.NullTime{},
654660
"tarball", "", "", "", "", "",
661+
sql.NullTime{}, false, false,
655662
))
656663

657-
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
664+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL,\s+scaled_to_zero = false, last_activity_at = now\(\),\s+updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
658665
WithArgs(rowID).
659666
WillReturnResult(sqlmock.NewResult(0, 1))
660667

@@ -702,10 +709,11 @@ func TestDeployRedeploy_ByID_CASDriverError_StillAccepts(t *testing.T) {
702709
sql.NullString{}, sql.NullString{}, "unset", 0,
703710
sql.NullTime{}, "permanent", 0, sql.NullTime{},
704711
"tarball", "", "", "", "", "",
712+
sql.NullTime{}, false, false,
705713
))
706714

707715
// Guarded CAS → driver error (non-determinate). Handler logs + continues.
708-
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
716+
mock.ExpectExec(`UPDATE deployments\s+SET status = 'building', error_message = NULL,\s+scaled_to_zero = false, last_activity_at = now\(\),\s+updated_at = now\(\)\s+WHERE id = \$1 AND status IN`).
709717
WithArgs(rowID).
710718
WillReturnError(errMockRedeployDriver)
711719

internal/handlers/deploy_stack_internal_coverage_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ func (covPanicProvider) Redeploy(context.Context, string, []byte, map[string]str
6464
func (covPanicProvider) UpdateAccessControl(context.Context, string, bool, []string) error {
6565
panic("covPanicProvider.UpdateAccessControl: not expected")
6666
}
67+
func (covPanicProvider) Scale(context.Context, string, int32) error {
68+
panic("covPanicProvider.Scale: not expected")
69+
}
6770

6871
// covFailProvider's Deploy/Redeploy return a configurable error. It does NOT
6972
// implement BuildLogFetcher, so fetchBuildLogsForAutopsy returns nil

internal/handlers/deploy_teardown_reconciler_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ func (f *fakeTeardownProvider) Redeploy(context.Context, string, []byte, map[str
7575
func (f *fakeTeardownProvider) UpdateAccessControl(context.Context, string, bool, []string) error {
7676
return nil
7777
}
78+
func (f *fakeTeardownProvider) Scale(context.Context, string, int32) error {
79+
return nil
80+
}
7881

7982
func reconcilerRequireDB(t *testing.T) {
8083
t.Helper()

internal/handlers/deploy_wake.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package handlers
2+
3+
// deploy_wake.go — explicit wake path for scale-to-zero (Task #54).
4+
//
5+
// WHY AN EXPLICIT WAKE (v1 design decision)
6+
//
7+
// instanode.dev serves a deployed app via a k8s Ingress on
8+
// *.deployment.instanode.dev that routes straight to the per-app Service in
9+
// the instant-deploy-<appID> namespace. The api process is NOT in the request
10+
// path. Transparent wake-on-request (a request to a sleeping app
11+
// auto-scales it and holds the connection until ready) therefore requires an
12+
// ACTIVATOR proxy in front of every app — KEDA http-add-on or a Knative-style
13+
// activator. That is a significant new dependency and is explicitly out of
14+
// scope for the scale-to-zero v1.
15+
//
16+
// v1 ships scale-DOWN (worker idle-scaler) + this fast EXPLICIT wake:
17+
//
18+
// POST /deploy/:id/wake → scales the app back to replicas=1 and returns once
19+
// the scale patch is accepted by k8s. The pod still needs its normal startup
20+
// time before it serves traffic, so a request that races the wake gets the
21+
// app's own cold-start latency (a brief 502/503 from the ingress until the
22+
// pod is Ready), exactly as a fresh rollout would. Callers/dashboard/agents
23+
// surface "sleeping — wake" and retry the app URL after waking.
24+
//
25+
// COLD-START CONTRACT (documented v1 limitation)
26+
//
27+
// - While scaled_to_zero, the app URL returns the ingress's upstream-down
28+
// response (502/503) because there is no pod. This is the documented v1
29+
// trade-off of explicit wake vs a transparent activator.
30+
// - POST /deploy/:id/wake is idempotent: waking an already-awake app just
31+
// refreshes last_activity_at (so it won't be re-descheduled immediately).
32+
// - The endpoint is gated by DEPLOY_SCALE_TO_ZERO_ENABLED. With the flag OFF
33+
// it returns 501 and performs NO scaling and NO DB writes (flag-off inert).
34+
35+
import (
36+
"errors"
37+
"log/slog"
38+
39+
"github.com/gofiber/fiber/v2"
40+
41+
"instant.dev/internal/middleware"
42+
"instant.dev/internal/models"
43+
)
44+
45+
// Wake handles POST /deploy/:id/wake. It scales a (possibly scaled-to-zero)
46+
// deployment back to replicas=1 and clears the scaled_to_zero flag, returning
47+
// the refreshed deployment. See the file header for the cold-start contract.
48+
func (h *DeployHandler) Wake(c *fiber.Ctx) error {
49+
if !h.cfg.DeployScaleToZeroEnabled {
50+
// Flag OFF → fully inert: no scale call, no DB write.
51+
return respondError(c, fiber.StatusNotImplemented, "scale_to_zero_disabled",
52+
"Scale-to-zero is not enabled on this platform")
53+
}
54+
55+
team, err := h.requireTeam(c)
56+
if err != nil {
57+
return err
58+
}
59+
60+
appID := c.Params("id")
61+
d, err := models.GetDeploymentByAppID(c.Context(), h.db, appID)
62+
if err != nil {
63+
var notFound *models.ErrDeploymentNotFound
64+
if errors.As(err, &notFound) {
65+
return respondError(c, fiber.StatusNotFound, "not_found", "Deployment not found")
66+
}
67+
return respondError(c, fiber.StatusServiceUnavailable, "fetch_failed", "Failed to fetch deployment")
68+
}
69+
70+
if d.TeamID != team.ID {
71+
// 404 not 403: never confirm the existence of another team's deployment.
72+
return respondError(c, fiber.StatusNotFound, "not_found", "Deployment not found")
73+
}
74+
75+
// Scale the k8s Deployment back to 1 replica. A NotFound Deployment is a
76+
// no-op inside compute.Scale (the row may have been torn down), so this only
77+
// errors on a real k8s transport failure — surface it so the caller retries.
78+
if d.ProviderID != "" {
79+
if scaleErr := h.compute.Scale(c.Context(), appID, 1); scaleErr != nil {
80+
slog.Warn("deploy.wake.scale_failed",
81+
"app_id", appID, "provider_id", d.ProviderID, "error", scaleErr,
82+
"request_id", middleware.GetRequestID(c))
83+
return respondError(c, fiber.StatusServiceUnavailable, "wake_failed",
84+
"Failed to wake deployment; please retry")
85+
}
86+
}
87+
88+
// DB half: clear scaled_to_zero + bump last_activity_at so the idle-scaler
89+
// doesn't immediately re-deschedule the just-woken app.
90+
if _, dbErr := models.WakeDeployment(c.Context(), h.db, d.ID); dbErr != nil {
91+
slog.Error("deploy.wake.db_failed",
92+
"app_id", appID, "error", dbErr,
93+
"request_id", middleware.GetRequestID(c))
94+
return respondError(c, fiber.StatusServiceUnavailable, "wake_failed",
95+
"Failed to record wake; please retry")
96+
}
97+
98+
// Re-read so the response reflects the cleared flag + new activity stamp.
99+
fresh, err := models.GetDeploymentByID(c.Context(), h.db, d.ID)
100+
if err != nil {
101+
// The scale + DB write already succeeded; a re-read failure shouldn't
102+
// fail the wake. Fall back to the pre-read row with the fields we just set.
103+
d.ScaledToZero = false
104+
fresh = d
105+
}
106+
107+
slog.Info("deploy.woke",
108+
"app_id", appID, "team_id", team.ID,
109+
"request_id", middleware.GetRequestID(c))
110+
111+
return c.JSON(fiber.Map{
112+
"ok": true,
113+
"message": "Deployment woken — the app will be reachable once its pod is Ready (cold start).",
114+
"deployment": deploymentToMapWithDB(fresh, h.db),
115+
})
116+
}

0 commit comments

Comments
 (0)