Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
60 changes: 60 additions & 0 deletions internal/handlers/deploy_tarball_cap_integration_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
50 changes: 50 additions & 0 deletions internal/handlers/deploy_tarball_cap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)." },
Expand Down
2 changes: 1 addition & 1 deletion openapi.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading