diff --git a/go.mod b/go.mod index ad7c7624..4793f47b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module instant.dev go 1.25.0 -toolchain go1.25.10 +toolchain go1.25.11 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index aad2bd1d..fc5f0944 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -51,6 +51,29 @@ import ( // "agent locks the staging app to the office IP", not corporate networking. const maxAllowedIPs = 32 +// maxTarballBytes caps direct source uploads at 10 MB (was 50 MB). A 10 MB +// gzipped source tar is ample for app code; larger almost always means +// vendored deps / build output / binaries that belong in the build, not the +// upload. Over-cap requests get a 413 + an agent_action nudging the caller to +// deploy from a prebuilt image (source=image) instead of uploading source. +// Enforced at the handler (NOT the Fiber global BodyLimit, which stays 50 MiB +// for /stacks/new's multi-service aggregate + webhook routes). +const maxTarballBytes = 10 << 20 + +// enforceTarballCap returns a 413 response (and ErrResponseWritten) when an +// uploaded tarball exceeds maxTarballBytes, with a routed agent_action +// (codeToAgentAction["tarball_too_large"]) nudging the caller to slim the +// upload or deploy a prebuilt image. Shared by /deploy/new and its +// redeploy=true branch so the cap can never drift between the two. Returns +// nil when the upload is within the cap. +func enforceTarballCap(c *fiber.Ctx, fh *multipart.FileHeader) error { + if fh.Size > maxTarballBytes { + return respondError(c, fiber.StatusRequestEntityTooLarge, "tarball_too_large", + fmt.Sprintf("Tarball must be at most 10 MB; yours is %d MB. Slim the upload (exclude node_modules/.git/build output) or deploy a prebuilt image instead of uploading source.", fh.Size>>20)) + } + return nil +} + // errCodeDeploymentNotRedeployable is the error code returned by POST // /deploy/:id/redeploy when the deployment is in a terminal status // (expired / deleted / stopped). Redeploying such a row would resurrect an @@ -583,9 +606,8 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { "Multipart field 'tarball' is required") } fh := tarballs[0] - if fh.Size > 50<<20 { - return respondError(c, fiber.StatusBadRequest, "tarball_too_large", - "Tarball must be at most 50 MB") + if err := enforceTarballCap(c, fh); err != nil { + return err } f, err := openMultipartFile(fh) if err != nil { @@ -1495,6 +1517,9 @@ func (h *DeployHandler) Redeploy(c *fiber.Ctx) error { "Multipart field 'tarball' is required") } fh := tarballs[0] + // NOTE: the redeploy path keeps the 50 MB cap for now; the 10 MB + // enforceTarballCap cap is applied on the primary /deploy/new path in P1. + // Tightening redeploy + /stacks/new to 10 MB is the P1.1 fast-follow. if fh.Size > 50<<20 { return respondError(c, fiber.StatusBadRequest, "tarball_too_large", "Tarball must be at most 50 MB") diff --git a/internal/handlers/deploy_tarball_cap_integration_test.go b/internal/handlers/deploy_tarball_cap_integration_test.go new file mode 100644 index 00000000..7499cbd5 --- /dev/null +++ b/internal/handlers/deploy_tarball_cap_integration_test.go @@ -0,0 +1,60 @@ +package handlers_test + +// deploy_tarball_cap_integration_test.go — end-to-end 10 MB cap on /deploy/new +// (2026-06-03). Posts an over-cap tarball and asserts 413 + tarball_too_large + +// the routed agent_action. Covers the New handler's enforceTarballCap call site. + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +func TestDeployNew_OversizedTarball_413(t *testing.T) { + daDeployNeedsDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamIDStr := testhelpers.MustCreateTeamDB(t, db, "pro") + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamIDStr, "big@example.com") + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy") + defer cleanApp() + + // Build a multipart body whose tarball part is just over the 10 MB cap. + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + require.NoError(t, mw.WriteField("name", "too-big-app")) + fw, err := mw.CreateFormFile("tarball", "app.tar.gz") + require.NoError(t, err) + _, err = fw.Write(make([]byte, (10<<20)+1)) // 10 MiB + 1 byte + require.NoError(t, err) + require.NoError(t, mw.Close()) + + req := httptest.NewRequest(http.MethodPost, "/deploy/new", buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("X-Forwarded-For", "10.41.0.9") + resp, err := app.Test(req, 15000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "over-cap tarball must be 413") + var env struct { + Error string `json:"error"` + AgentAction string `json:"agent_action"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&env)) + assert.Equal(t, "tarball_too_large", env.Error) + assert.Contains(t, env.AgentAction, "prebuilt image", "413 must carry the slim/image agent_action") +} diff --git a/internal/handlers/deploy_tarball_cap_test.go b/internal/handlers/deploy_tarball_cap_test.go new file mode 100644 index 00000000..617d8f7f --- /dev/null +++ b/internal/handlers/deploy_tarball_cap_test.go @@ -0,0 +1,50 @@ +package handlers + +// deploy_tarball_cap_test.go — white-box test for the 10 MB deploy upload cap +// (2026-06-03). enforceTarballCap only reads fh.Size, so the 413 path is +// covered without allocating an oversized buffer. + +import ( + "mime/multipart" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/valyala/fasthttp" +) + +func TestEnforceTarballCap_OverCap_413WithAgentAction(t *testing.T) { + app := fiber.New() + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + + err := enforceTarballCap(ctx, &multipart.FileHeader{Filename: "big.tar.gz", Size: 23 << 20}) + if err == nil { + t.Fatal("expected an error for an over-cap tarball") + } + if got := ctx.Response().StatusCode(); got != fiber.StatusRequestEntityTooLarge { + t.Fatalf("want 413 Payload Too Large, got %d", got) + } + body := string(ctx.Response().Body()) + for _, want := range []string{`"tarball_too_large"`, `"agent_action"`, "10 MB", "23 MB", "prebuilt image"} { + if !strings.Contains(body, want) { + t.Errorf("413 body missing %q; got: %s", want, body) + } + } +} + +func TestEnforceTarballCap_WithinCap_Nil(t *testing.T) { + app := fiber.New() + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + + if err := enforceTarballCap(ctx, &multipart.FileHeader{Filename: "ok.tar.gz", Size: 5 << 20}); err != nil { + t.Fatalf("a within-cap tarball must pass, got %v", err) + } + // exactly at the cap is allowed (strictly-greater check) + app2ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(app2ctx) + if err := enforceTarballCap(app2ctx, &multipart.FileHeader{Size: maxTarballBytes}); err != nil { + t.Fatalf("a tarball exactly at the cap must pass, got %v", err) + } +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 27815a40..cafceb11 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -1048,7 +1048,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user reading the deployment tarball failed mid-upload. Retry the upload with a clean tarball — see https://instanode.dev/docs/deploy.", }, "tarball_too_large": { - AgentAction: "Tell the user the deployment tarball exceeded the 50 MiB cap. Trim node_modules / build artefacts and retry — see https://instanode.dev/docs/deploy.", + AgentAction: "Tell the user the deploy upload exceeds the 10MB cap. Slim it — exclude node_modules, .git, and build output (use a .dockerignore). For large projects, deploy a prebuilt image instead of uploading source: see https://instanode.dev/docs/deploy.", }, "no_services": { AgentAction: "Tell the user the stack manifest declared no services. Add at least one service block — see https://instanode.dev/docs/stacks.", diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index b6a6206d..220a5f56 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -3098,7 +3098,7 @@ const openAPISpec = `{ "DeployRequest": { "type": "object", "properties": { - "tarball": { "type": "string", "format": "binary", "description": "gzipped tar archive containing the Dockerfile + source (max 50 MB). When MINIO_ENDPOINT is configured the build context is uploaded to MinIO and kaniko pulls it via the S3 path; otherwise it falls back to a k8s Secret which caps at ~1 MiB." }, + "tarball": { "type": "string", "format": "binary", "description": "gzipped tar archive containing the Dockerfile + source (max 10 MB). Over the cap returns 413 tarball_too_large with an agent_action — slim the upload (exclude node_modules/.git/build output) or deploy a prebuilt image instead of uploading source. When MINIO_ENDPOINT is configured the build context is uploaded to MinIO and kaniko pulls it via the S3 path; otherwise it falls back to a k8s Secret which caps at ~1 MiB." }, "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]*$", "description": "REQUIRED. Short human-readable label for this deployment (1-64 chars after trimming; must start with a letter or digit, then letters/digits/spaces/underscores/hyphens). Missing/empty → 400 name_required. Bad format/length → 400 invalid_name." }, "port": { "type": "integer", "description": "Container port (default 8080)" }, "env": { "type": "string", "description": "Environment scope (production / staging / dev / ...). Defaults to 'development' when omitted (migration 026 — the resolved env is echoed back as 'environment' on the response so callers know which bucket they landed in)." }, diff --git a/openapi.snapshot.json b/openapi.snapshot.json index 1bd74dee..0fe4a850 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -855,7 +855,7 @@ "type": "string" }, "tarball": { - "description": "gzipped tar archive containing the Dockerfile + source (max 50 MB). When MINIO_ENDPOINT is configured the build context is uploaded to MinIO and kaniko pulls it via the S3 path; otherwise it falls back to a k8s Secret which caps at ~1 MiB.", + "description": "gzipped tar archive containing the Dockerfile + source (max 10 MB). Over the cap returns 413 tarball_too_large with an agent_action — slim the upload (exclude node_modules/.git/build output) or deploy a prebuilt image instead of uploading source. When MINIO_ENDPOINT is configured the build context is uploaded to MinIO and kaniko pulls it via the S3 path; otherwise it falls back to a k8s Secret which caps at ~1 MiB.", "format": "binary", "type": "string" },