Skip to content

Commit 134924c

Browse files
fix(scaletozero): skip scale-to-zero toggle for loopback connections (#151)
## Summary Loopback requests — such as internal health checks / playwright daemon connections — were toggling the scale-to-zero control file, keeping VMs alive indefinitely and driving up usage costs. **Changes:** - Middleware now detects loopback source addresses and passes them through without touching scale-to-zero state - Added info-level logging around control-file writes for better observability - Added unit tests for the middleware and the loopback detection helper <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small, localized change to request middleware behavior plus additional logging and tests; primary risk is mis-detecting client IPs when `RemoteAddr` doesn’t reflect the true origin behind proxies. > > **Overview** > Prevents internal/loopback HTTP traffic from affecting VM scale-to-zero behavior by having the `scaletozero` HTTP middleware bypass `Disable`/`Enable` when `RemoteAddr` is a loopback IP (via new `isLoopbackAddr`). > > Adds unit coverage for the middleware behavior (external vs loopback, and disable error handling) and increases controller observability by logging info-level messages when the scale-to-zero control file is missing or successfully written. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8cee14f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 94e69e7 commit 134924c

File tree

4 files changed

+138
-1
lines changed

4 files changed

+138
-1
lines changed

server/cmd/api/api/process.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,6 @@ func (s *ApiService) ProcessResize(ctx context.Context, request oapi.ProcessResi
624624
return oapi.ProcessResize200JSONResponse(oapi.OkResponse{Ok: true}), nil
625625
}
626626

627-
628627
// writeJSON writes a JSON response with the given status code.
629628
// Unlike http.Error, this sets the correct Content-Type for JSON.
630629
func writeJSON(w http.ResponseWriter, status int, body string) {

server/lib/scaletozero/middleware.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@ package scaletozero
22

33
import (
44
"context"
5+
"net"
56
"net/http"
67

78
"github.com/onkernel/kernel-images/server/lib/logger"
89
)
910

1011
// Middleware returns a standard net/http middleware that disables scale-to-zero
1112
// at the start of each request and re-enables it after the handler completes.
13+
// Connections from loopback addresses are ignored and do not affect the
14+
// scale-to-zero state.
1215
func Middleware(ctrl Controller) func(http.Handler) http.Handler {
1316
return func(next http.Handler) http.Handler {
1417
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
if isLoopbackAddr(r.RemoteAddr) {
19+
next.ServeHTTP(w, r)
20+
return
21+
}
22+
1523
if err := ctrl.Disable(r.Context()); err != nil {
1624
logger.FromContext(r.Context()).Error("failed to disable scale-to-zero", "error", err)
1725
http.Error(w, "failed to disable scale-to-zero", http.StatusInternalServerError)
@@ -23,3 +31,17 @@ func Middleware(ctrl Controller) func(http.Handler) http.Handler {
2331
})
2432
}
2533
}
34+
35+
// isLoopbackAddr reports whether addr is a loopback address.
36+
// addr may be an "ip:port" pair or a bare IP.
37+
func isLoopbackAddr(addr string) bool {
38+
host, _, err := net.SplitHostPort(addr)
39+
if err != nil {
40+
host = addr
41+
}
42+
ip := net.ParseIP(host)
43+
if ip == nil {
44+
return false
45+
}
46+
return ip.IsLoopback()
47+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package scaletozero
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestMiddlewareDisablesAndEnablesForExternalAddr(t *testing.T) {
13+
t.Parallel()
14+
mock := &mockScaleToZeroer{}
15+
handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
w.WriteHeader(http.StatusOK)
17+
}))
18+
19+
req := httptest.NewRequest(http.MethodGet, "/", nil)
20+
req.RemoteAddr = "203.0.113.50:12345"
21+
rec := httptest.NewRecorder()
22+
23+
handler.ServeHTTP(rec, req)
24+
25+
assert.Equal(t, http.StatusOK, rec.Code)
26+
assert.Equal(t, 1, mock.disableCalls)
27+
assert.Equal(t, 1, mock.enableCalls)
28+
}
29+
30+
func TestMiddlewareSkipsLoopbackAddrs(t *testing.T) {
31+
t.Parallel()
32+
33+
loopbackAddrs := []struct {
34+
name string
35+
addr string
36+
}{
37+
{"loopback-v4", "127.0.0.1:8080"},
38+
{"loopback-v6", "[::1]:8080"},
39+
}
40+
41+
for _, tc := range loopbackAddrs {
42+
t.Run(tc.name, func(t *testing.T) {
43+
t.Parallel()
44+
mock := &mockScaleToZeroer{}
45+
var called bool
46+
handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
called = true
48+
w.WriteHeader(http.StatusOK)
49+
}))
50+
51+
req := httptest.NewRequest(http.MethodGet, "/", nil)
52+
req.RemoteAddr = tc.addr
53+
rec := httptest.NewRecorder()
54+
55+
handler.ServeHTTP(rec, req)
56+
57+
assert.True(t, called, "handler should still be called")
58+
assert.Equal(t, http.StatusOK, rec.Code)
59+
assert.Equal(t, 0, mock.disableCalls, "should not disable for loopback addr")
60+
assert.Equal(t, 0, mock.enableCalls, "should not enable for loopback addr")
61+
})
62+
}
63+
}
64+
65+
func TestMiddlewareDisableError(t *testing.T) {
66+
t.Parallel()
67+
mock := &mockScaleToZeroer{disableErr: assert.AnError}
68+
var called bool
69+
handler := Middleware(mock)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+
called = true
71+
}))
72+
73+
req := httptest.NewRequest(http.MethodGet, "/", nil)
74+
req.RemoteAddr = "203.0.113.50:12345"
75+
rec := httptest.NewRecorder()
76+
77+
handler.ServeHTTP(rec, req)
78+
79+
assert.False(t, called, "handler should not be called on disable error")
80+
assert.Equal(t, http.StatusInternalServerError, rec.Code)
81+
assert.Equal(t, 0, mock.enableCalls)
82+
}
83+
84+
func TestIsLoopbackAddr(t *testing.T) {
85+
t.Parallel()
86+
87+
tests := []struct {
88+
addr string
89+
loopback bool
90+
}{
91+
// Loopback
92+
{"127.0.0.1:80", true},
93+
{"[::1]:80", true},
94+
{"127.0.0.1", true},
95+
{"::1", true},
96+
// Non-loopback
97+
{"10.0.0.1:80", false},
98+
{"172.16.0.1:80", false},
99+
{"192.168.1.1:80", false},
100+
{"203.0.113.50:80", false},
101+
{"8.8.8.8:53", false},
102+
{"[2001:db8::1]:80", false},
103+
// Unparseable
104+
{"not-an-ip:80", false},
105+
{"", false},
106+
}
107+
108+
for _, tc := range tests {
109+
t.Run(tc.addr, func(t *testing.T) {
110+
t.Parallel()
111+
require.Equal(t, tc.loopback, isLoopbackAddr(tc.addr))
112+
})
113+
}
114+
}

server/lib/scaletozero/scaletozero.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func (c *unikraftCloudController) Enable(ctx context.Context) error {
3838
func (c *unikraftCloudController) write(ctx context.Context, char string) error {
3939
if _, err := os.Stat(c.path); err != nil {
4040
if os.IsNotExist(err) {
41+
logger.FromContext(ctx).Info("scale-to-zero control file not found, skipping write", "path", c.path, "value", char)
4142
return nil
4243
}
4344
logger.FromContext(ctx).Error("failed to stat scale-to-zero control file", "path", c.path, "err", err)
@@ -54,6 +55,7 @@ func (c *unikraftCloudController) write(ctx context.Context, char string) error
5455
logger.FromContext(ctx).Error("failed to write scale-to-zero control file", "path", c.path, "err", err)
5556
return err
5657
}
58+
logger.FromContext(ctx).Info("scale-to-zero control file written", "path", c.path, "value", char)
5759
return nil
5860
}
5961

0 commit comments

Comments
 (0)