diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 84164b26..d28fedd9 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -49,7 +49,7 @@ type ApiService struct { // DevTools upstream manager (Chromium supervisord log tailer) upstreamMgr *devtoolsproxy.UpstreamManager - stz scaletozero.Controller + stz scaletozero.PinnedController // inputMu serializes input-related operations (mouse, keyboard, screenshot) inputMu sync.Mutex @@ -96,7 +96,7 @@ func New( recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, - stz scaletozero.Controller, + stz scaletozero.PinnedController, nekoAuthClient *nekoclient.AuthClient, captureSession *capturesession.CaptureSession, eventStream *events.EventStream, diff --git a/server/cmd/api/api/scaletozero.go b/server/cmd/api/api/scaletozero.go new file mode 100644 index 00000000..9e840914 --- /dev/null +++ b/server/cmd/api/api/scaletozero.go @@ -0,0 +1,24 @@ +package api + +import ( + "context" + + "github.com/kernel/kernel-images/server/lib/logger" + oapi "github.com/kernel/kernel-images/server/lib/oapi" +) + +func (s *ApiService) DisableScaleToZero(ctx context.Context, _ oapi.DisableScaleToZeroRequestObject) (oapi.DisableScaleToZeroResponseObject, error) { + if err := s.stz.Pin(ctx); err != nil { + logger.FromContext(ctx).Error("failed to disable scale-to-zero", "err", err) + return oapi.DisableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to disable scale-to-zero"}}, nil + } + return oapi.DisableScaleToZero204Response{}, nil +} + +func (s *ApiService) EnableScaleToZero(ctx context.Context, _ oapi.EnableScaleToZeroRequestObject) (oapi.EnableScaleToZeroResponseObject, error) { + if err := s.stz.Unpin(ctx); err != nil { + logger.FromContext(ctx).Error("failed to enable scale-to-zero", "err", err) + return oapi.EnableScaleToZero500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to enable scale-to-zero"}}, nil + } + return oapi.EnableScaleToZero204Response{}, nil +} diff --git a/server/e2e/e2e_scaletozero_test.go b/server/e2e/e2e_scaletozero_test.go new file mode 100644 index 00000000..2de99568 --- /dev/null +++ b/server/e2e/e2e_scaletozero_test.go @@ -0,0 +1,60 @@ +package e2e + +import ( + "context" + "net/http" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestScaleToZeroDisableEnable exercises POST /scaletozero/{disable,enable} +// against the real built image. The unikraft control file does not exist +// inside the docker test container, so the underlying scale-to-zero write is +// a no-op — this test validates HTTP wiring, idempotency, and that the +// scale-to-zero middleware coexists with the disable/enable handlers. +func TestScaleToZeroDisableEnable(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + + client, err := c.APIClient() + require.NoError(t, err, "failed to create API client") + + // Idempotent disable. + r1, err := client.DisableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "DisableScaleToZero request failed") + require.Equal(t, http.StatusNoContent, r1.StatusCode(), "unexpected status: %s body=%s", r1.Status(), string(r1.Body)) + + r2, err := client.DisableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "second DisableScaleToZero request failed") + require.Equal(t, http.StatusNoContent, r2.StatusCode(), "unexpected status: %s body=%s", r2.Status(), string(r2.Body)) + + // Normal request must still flow while disabled (scaletozero middleware + // runs on every request — the pin must not deadlock or break it). + readResp, err := client.ReadClipboardWithResponse(ctx) + require.NoError(t, err, "ReadClipboard request failed while disabled") + require.Equal(t, http.StatusOK, readResp.StatusCode(), "unexpected read status while disabled: %s body=%s", readResp.Status(), string(readResp.Body)) + + // Idempotent enable. + r3, err := client.EnableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "EnableScaleToZero request failed") + require.Equal(t, http.StatusNoContent, r3.StatusCode(), "unexpected status: %s body=%s", r3.Status(), string(r3.Body)) + + r4, err := client.EnableScaleToZeroWithResponse(ctx) + require.NoError(t, err, "second EnableScaleToZero request failed") + require.Equal(t, http.StatusNoContent, r4.StatusCode(), "unexpected status: %s body=%s", r4.Status(), string(r4.Body)) +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 28ae96f3..525b2c47 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1645,6 +1645,12 @@ type ClientInterface interface { StopRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DisableScaleToZero request + DisableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // EnableScaleToZero request + EnableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) PatchChromiumFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -2655,6 +2661,30 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques return c.Client.Do(req) } +func (c *Client) DisableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDisableScaleToZeroRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) EnableScaleToZero(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnableScaleToZeroRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewPatchChromiumFlagsRequest calls the generic PatchChromiumFlags builder with application/json body func NewPatchChromiumFlagsRequest(server string, body PatchChromiumFlagsJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -4791,6 +4821,60 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } +// NewDisableScaleToZeroRequest generates requests for DisableScaleToZero +func NewDisableScaleToZeroRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/scaletozero/disable") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewEnableScaleToZeroRequest generates requests for EnableScaleToZero +func NewEnableScaleToZeroRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/scaletozero/enable") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -5054,6 +5138,12 @@ type ClientWithResponsesInterface interface { StopRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) + + // DisableScaleToZeroWithResponse request + DisableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableScaleToZeroResponse, error) + + // EnableScaleToZeroWithResponse request + EnableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableScaleToZeroResponse, error) } type PatchChromiumFlagsResponse struct { @@ -6315,6 +6405,50 @@ func (r StopRecordingResponse) StatusCode() int { return 0 } +type DisableScaleToZeroResponse struct { + Body []byte + HTTPResponse *http.Response + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DisableScaleToZeroResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DisableScaleToZeroResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type EnableScaleToZeroResponse struct { + Body []byte + HTTPResponse *http.Response + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r EnableScaleToZeroResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r EnableScaleToZeroResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + // PatchChromiumFlagsWithBodyWithResponse request with arbitrary body returning *PatchChromiumFlagsResponse func (c *ClientWithResponses) PatchChromiumFlagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchChromiumFlagsResponse, error) { rsp, err := c.PatchChromiumFlagsWithBody(ctx, contentType, body, reqEditors...) @@ -7040,6 +7174,24 @@ func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, bod return ParseStopRecordingResponse(rsp) } +// DisableScaleToZeroWithResponse request returning *DisableScaleToZeroResponse +func (c *ClientWithResponses) DisableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisableScaleToZeroResponse, error) { + rsp, err := c.DisableScaleToZero(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseDisableScaleToZeroResponse(rsp) +} + +// EnableScaleToZeroWithResponse request returning *EnableScaleToZeroResponse +func (c *ClientWithResponses) EnableScaleToZeroWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*EnableScaleToZeroResponse, error) { + rsp, err := c.EnableScaleToZero(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseEnableScaleToZeroResponse(rsp) +} + // ParsePatchChromiumFlagsResponse parses an HTTP response from a PatchChromiumFlagsWithResponse call func ParsePatchChromiumFlagsResponse(rsp *http.Response) (*PatchChromiumFlagsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -9045,6 +9197,58 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err return response, nil } +// ParseDisableScaleToZeroResponse parses an HTTP response from a DisableScaleToZeroWithResponse call +func ParseDisableScaleToZeroResponse(rsp *http.Response) (*DisableScaleToZeroResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DisableScaleToZeroResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseEnableScaleToZeroResponse parses an HTTP response from a EnableScaleToZeroWithResponse call +func ParseEnableScaleToZeroResponse(rsp *http.Response) (*EnableScaleToZeroResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &EnableScaleToZeroResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Update Chromium launch flags and restart @@ -9206,6 +9410,12 @@ type ServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) + // Idempotently disable scale to zero on this VM. + // (POST /scaletozero/disable) + DisableScaleToZero(w http.ResponseWriter, r *http.Request) + // Idempotently enable scale to zero on this VM. + // (POST /scaletozero/enable) + EnableScaleToZero(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -9530,6 +9740,18 @@ func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Idempotently disable scale to zero on this VM. +// (POST /scaletozero/disable) +func (_ Unimplemented) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Idempotently enable scale to zero on this VM. +// (POST /scaletozero/enable) +func (_ Unimplemented) EnableScaleToZero(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -10577,6 +10799,34 @@ func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// DisableScaleToZero operation middleware +func (siw *ServerInterfaceWrapper) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DisableScaleToZero(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// EnableScaleToZero operation middleware +func (siw *ServerInterfaceWrapper) EnableScaleToZero(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.EnableScaleToZero(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -10849,6 +11099,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/scaletozero/disable", wrapper.DisableScaleToZero) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/scaletozero/enable", wrapper.EnableScaleToZero) + }) return r } @@ -13079,6 +13335,54 @@ func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.R return json.NewEncoder(w).Encode(response) } +type DisableScaleToZeroRequestObject struct { +} + +type DisableScaleToZeroResponseObject interface { + VisitDisableScaleToZeroResponse(w http.ResponseWriter) error +} + +type DisableScaleToZero204Response struct { +} + +func (response DisableScaleToZero204Response) VisitDisableScaleToZeroResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DisableScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DisableScaleToZero500JSONResponse) VisitDisableScaleToZeroResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type EnableScaleToZeroRequestObject struct { +} + +type EnableScaleToZeroResponseObject interface { + VisitEnableScaleToZeroResponse(w http.ResponseWriter) error +} + +type EnableScaleToZero204Response struct { +} + +func (response EnableScaleToZero204Response) VisitEnableScaleToZeroResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type EnableScaleToZero500JSONResponse struct{ InternalErrorJSONResponse } + +func (response EnableScaleToZero500JSONResponse) VisitEnableScaleToZeroResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Update Chromium launch flags and restart @@ -13240,6 +13544,12 @@ type StrictServerInterface interface { // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) + // Idempotently disable scale to zero on this VM. + // (POST /scaletozero/disable) + DisableScaleToZero(ctx context.Context, request DisableScaleToZeroRequestObject) (DisableScaleToZeroResponseObject, error) + // Idempotently enable scale to zero on this VM. + // (POST /scaletozero/enable) + EnableScaleToZero(ctx context.Context, request EnableScaleToZeroRequestObject) (EnableScaleToZeroResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -14837,187 +15147,239 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { } } +// DisableScaleToZero operation middleware +func (sh *strictHandler) DisableScaleToZero(w http.ResponseWriter, r *http.Request) { + var request DisableScaleToZeroRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DisableScaleToZero(ctx, request.(DisableScaleToZeroRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DisableScaleToZero") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DisableScaleToZeroResponseObject); ok { + if err := validResponse.VisitDisableScaleToZeroResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// EnableScaleToZero operation middleware +func (sh *strictHandler) EnableScaleToZero(w http.ResponseWriter, r *http.Request) { + var request EnableScaleToZeroRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.EnableScaleToZero(ctx, request.(EnableScaleToZeroRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "EnableScaleToZero") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(EnableScaleToZeroResponseObject); ok { + if err := validResponse.VisitEnableScaleToZeroResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go index 5bc62af8..96cc1610 100644 --- a/server/lib/scaletozero/scaletozero.go +++ b/server/lib/scaletozero/scaletozero.go @@ -20,6 +20,24 @@ type Controller interface { Enable(ctx context.Context) error } +// PinnedController extends Controller with an out-of-band "pin" that holds +// scale-to-zero disabled independently of the request-driven refcount used by +// the HTTP middleware. While the pin is held, request-driven Enable calls do +// not re-enable scale-to-zero; only Unpin can release it. +// +// This is intended for explicit lifecycle control (e.g. a control-plane API +// reserving a VM in a hot pool) where the holder is not tied to an inflight +// HTTP request. +type PinnedController interface { + Controller + // Pin holds scale-to-zero disabled until Unpin is called. The pin is a + // boolean, not a counter: repeated calls are idempotent. + Pin(ctx context.Context) error + // Unpin releases the pin. If no request-driven holders remain, + // scale-to-zero is re-enabled (honoring any configured cooldown). + Unpin(ctx context.Context) error +} + type unikraftCloudController struct { path string } @@ -66,6 +84,8 @@ func NewNoopController() *NoopController { return &NoopController{} } func (NoopController) Disable(context.Context) error { return nil } func (NoopController) Enable(context.Context) error { return nil } +func (NoopController) Pin(context.Context) error { return nil } +func (NoopController) Unpin(context.Context) error { return nil } // Oncer wraps a Controller and ensures that Disable and Enable are called at most once. type Oncer struct { @@ -89,16 +109,17 @@ func (o *Oncer) Enable(ctx context.Context) error { } type DebouncedController struct { - ctrl Controller - cooldown time.Duration - mu sync.Mutex - disabled bool - activeCount int - reenableTimer *time.Timer + ctrl Controller + cooldown time.Duration + mu sync.Mutex + disabled bool + activeCount int + pinned bool + reenableTimer *time.Timer } // NewDebouncedController creates a DebouncedController with no re-enable cooldown. -func NewDebouncedController(ctrl Controller) Controller { +func NewDebouncedController(ctrl Controller) *DebouncedController { return &DebouncedController{ctrl: ctrl} } @@ -106,7 +127,7 @@ func NewDebouncedController(ctrl Controller) Controller { // re-enabling scale-to-zero by the given cooldown after the last active holder // releases. A new Disable call during the cooldown cancels the pending // re-enable, avoiding rapid toggling from sequential requests. -func NewDebouncedControllerWithCooldown(ctrl Controller, cooldown time.Duration) Controller { +func NewDebouncedControllerWithCooldown(ctrl Controller, cooldown time.Duration) *DebouncedController { return &DebouncedController{ctrl: ctrl, cooldown: cooldown} } @@ -141,8 +162,61 @@ func (c *DebouncedController) Enable(ctx context.Context) error { c.activeCount-- } - // nothing to do - if c.activeCount > 0 || !c.disabled { + return c.maybeReenableLocked(ctx) +} + +// Pin sets the out-of-band pin and ensures scale-to-zero is disabled. +// Idempotent: re-pinning while already pinned is a no-op. Cancels any pending +// cooldown timer. +func (c *DebouncedController) Pin(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.reenableTimer != nil { + c.reenableTimer.Stop() + c.reenableTimer = nil + } + + if c.pinned { + return nil + } + + if !c.disabled { + if err := c.ctrl.Disable(ctx); err != nil { + return err + } + c.disabled = true + } + + c.pinned = true + return nil +} + +// Unpin releases the pin. If no request-driven holders remain, scale-to-zero +// is re-enabled (honoring any configured cooldown). Idempotent: calling when no +// pin is held is a no-op. +func (c *DebouncedController) Unpin(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.pinned { + return nil + } + c.pinned = false + + if err := c.maybeReenableLocked(ctx); err != nil { + // Restore the pin so a retry can re-attempt the underlying Enable; + // otherwise the caller has no API-driven recovery path. + c.pinned = true + return err + } + return nil +} + +// maybeReenableLocked re-enables scale-to-zero if no holders (request-driven or +// pin) remain. Caller must hold c.mu. +func (c *DebouncedController) maybeReenableLocked(ctx context.Context) error { + if c.activeCount > 0 || c.pinned || !c.disabled { return nil } @@ -161,7 +235,7 @@ func (c *DebouncedController) Enable(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.activeCount > 0 || !c.disabled { + if c.activeCount > 0 || c.pinned || !c.disabled { return } diff --git a/server/lib/scaletozero/scaletozero_test.go b/server/lib/scaletozero/scaletozero_test.go index 20c3560a..575265dd 100644 --- a/server/lib/scaletozero/scaletozero_test.go +++ b/server/lib/scaletozero/scaletozero_test.go @@ -260,3 +260,132 @@ func TestDebouncedControllerCooldownZeroBehavesLikeOriginal(t *testing.T) { assert.Equal(t, 1, mock.disableCalls) assert.Equal(t, 1, mock.enableCalls) } + +func TestDebouncedControllerPinHoldsAcrossMiddlewareEnable(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + // Pin first. + require.NoError(t, c.Pin(t.Context())) + assert.Equal(t, 1, mock.disableCalls) + + // Simulate a middleware-wrapped request: Disable then Enable. + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) + + // Pin still held, so no Enable should have hit the underlying ctrl. + assert.Equal(t, 1, mock.disableCalls) + assert.Equal(t, 0, mock.enableCalls) + + // Release the pin: Enable fires. + require.NoError(t, c.Unpin(t.Context())) + assert.Equal(t, 1, mock.enableCalls) +} + +func TestDebouncedControllerPinIdempotent(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Pin(t.Context())) + assert.Equal(t, 1, mock.disableCalls) + + require.NoError(t, c.Unpin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) + assert.Equal(t, 1, mock.enableCalls) +} + +func TestDebouncedControllerUnpinWithoutPinNoWrite(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.Unpin(t.Context())) + assert.Equal(t, 0, mock.disableCalls) + assert.Equal(t, 0, mock.enableCalls) +} + +func TestDebouncedControllerUnpinDefersToActiveRequests(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Disable(t.Context())) // simulate inflight request + + // Releasing the pin while a request is inflight must not re-enable. + require.NoError(t, c.Unpin(t.Context())) + assert.Equal(t, 0, mock.enableCalls) + + // Request completes -> Enable fires. + require.NoError(t, c.Enable(t.Context())) + assert.Equal(t, 1, mock.enableCalls) +} + +func TestDebouncedControllerPinCancelsCooldownTimer(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) + + // Drive a request through, putting us into the cooldown window. + require.NoError(t, c.Disable(t.Context())) + require.NoError(t, c.Enable(t.Context())) + + // Pin during the cooldown: should cancel the pending re-enable. + require.NoError(t, c.Pin(t.Context())) + + time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + assert.Equal(t, 1, mock.disableCalls) + assert.Equal(t, 0, mock.enableCalls) + mock.mu.Unlock() + + require.NoError(t, c.Unpin(t.Context())) + time.Sleep(100 * time.Millisecond) + mock.mu.Lock() + assert.Equal(t, 1, mock.enableCalls) + mock.mu.Unlock() +} + +func TestDebouncedControllerUnpinHonorsCooldown(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedControllerWithCooldown(mock, 50*time.Millisecond) + + require.NoError(t, c.Pin(t.Context())) + require.NoError(t, c.Unpin(t.Context())) + + // Cooldown should defer the underlying Enable. + mock.mu.Lock() + assert.Equal(t, 0, mock.enableCalls) + mock.mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + assert.Equal(t, 1, mock.enableCalls) + mock.mu.Unlock() +} + +func TestDebouncedControllerUnpinRetryableAfterEnableFailure(t *testing.T) { + t.Parallel() + mock := &mockScaleToZeroer{} + c := NewDebouncedController(mock) + + require.NoError(t, c.Pin(t.Context())) + + // First Unpin: underlying Enable fails. Pin must remain held so the caller + // can retry; otherwise the controller is stuck in disabled=true forever. + mock.enableErr = assert.AnError + require.Error(t, c.Unpin(t.Context())) + assert.Equal(t, 1, mock.enableCalls) + + // Retry succeeds. + mock.enableErr = nil + require.NoError(t, c.Unpin(t.Context())) + assert.Equal(t, 2, mock.enableCalls) +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 112334b9..c29fd98b 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1334,6 +1334,30 @@ paths: text/event-stream: schema: $ref: "#/components/schemas/PublishedEnvelope" + /scaletozero/disable: + post: + summary: Idempotently disable scale to zero on this VM. + description: > + Prevents the VM from scaling to zero, regardless of HTTP traffic on it. + Calling the API on this VM will wake it because Unikraft will automatically wake for any request to the VM. + operationId: disableScaleToZero + responses: + "204": + description: Scale-to-zero disabled + "500": + $ref: "#/components/responses/InternalError" + /scaletozero/enable: + post: + summary: Idempotently enable scale to zero on this VM. + description: > + Restores normal scale-to-zero behavior on this VM, allowing it to + scale to zero again when idle. Call this when the VM no longer needs to be held awake. + operationId: enableScaleToZero + responses: + "204": + description: Scale-to-zero enabled + "500": + $ref: "#/components/responses/InternalError" components: schemas: Event: