Skip to content
Draft
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
30 changes: 22 additions & 8 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,22 +290,36 @@ sequenceDiagram
DRP->>DRP: swap credentials, rewrite path → artifact registry
end
C->>API: POST /v3/templates (register build: cpu, ram) → Postgres env_builds
API->>API: resolve team default free-disk target
C->>API: POST /v2/templates/{id}/builds/{buildID} (recipe: steps, start/ready cmd)
API->>TM: gRPC TemplateCreate(TemplateConfig)
API->>TM: gRPC TemplateCreate(internal build config + build-start max disk size)
TM->>TM: pull image → inject envd/provisioning → extract ext4 rootfs
TM->>FC: boot VM per phase: provision → user steps
TM->>TM: resize disk on host
opt free-disk restoration enabled
TM->>TM: resize disk on host
end
TM->>FC: boot VM per phase: finalize → optimize
TM->>OS: upload layers + final {buildID}/memfile, rootfs.ext4, snapfile, metadata
API->>TM: poll TemplateBuildStatus
API->>API: mark build ready in Postgres
TM->>TM: read final rootfs header and evaluate team maximum
alt maximum exceeded and enforcement enabled
TM->>OS: delete failed build artifacts
TM-->>API: build failed
else within limit or observe-only
API->>TM: poll TemplateBuildStatus
API->>API: persist floored total size and mark build ready in Postgres
end
```

Builds are **layered** (`pkg/template/build/phases/`): base → user → one layer per recipe step →
resize disk → finalize → optimize. Each layer is hashed and cached, so rebuilds only re-run changed
steps. Resize disk grows the quiescent rootfs on the host; the other non-cached phases run in a real
Firecracker VM and their pause-diffs become layers. The optimize phase records which memory pages a
fresh resume touches, producing prefetch hints that speed up future sandbox starts.
optional free-disk restoration → finalize → optimize. Each layer is hashed and cached, so rebuilds
only re-run changed steps. Free-disk restoration grows the quiescent rootfs on the host. After all
phases and uploads complete, the builder reads the final rootfs header once. It uses the exact logical
byte size both for the team-maximum decision and to derive the floored MiB value returned for
`env_builds.total_disk_size_mb`. The check adds attributes to the existing build span and rejects an
excess only when `build-enforce-max-disk-size` is enabled; rejection follows the normal failed-build
path and deletes the uploaded build prefix. The non-cached VM phases produce pause-diffs as layers.
The optimize phase records which memory pages a fresh resume touches, producing prefetch hints that
speed up future sandbox starts.

## Deployment topology

Expand Down
4 changes: 2 additions & 2 deletions packages/api/internal/db/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func GetTeamByID(ctx context.Context, db *authdb.Client, teamID uuid.UUID) (*typ
return nil, fmt.Errorf("failed to get team by ID: %w", err)
}

return types.NewTeam(&result.Team, &result.TeamLimit), nil
return types.NewTeam(&result.Team, &result.TeamLimitsV2), nil
}

func GetTeamsByUser(ctx context.Context, db *authdb.Client, userID uuid.UUID) ([]*types.TeamWithDefault, error) {
Expand All @@ -28,7 +28,7 @@ func GetTeamsByUser(ctx context.Context, db *authdb.Client, userID uuid.UUID) ([
teamsWithLimits := make([]*types.TeamWithDefault, 0, len(teams))
for _, team := range teams {
teamsWithLimits = append(teamsWithLimits, &types.TeamWithDefault{
Team: types.NewTeam(&team.Team, &team.TeamLimit),
Team: types.NewTeam(&team.Team, &team.TeamLimitsV2),
IsDefault: team.IsDefault,
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ func (a *APIStore) PostTemplatesTemplateIDBuildsBuildID(c *gin.Context, template
build.StartCmd,
build.Vcpu,
build.FreeDiskSizeMb,
&team.Limits.MaxDiskSizeMb,
build.RamMb,
build.ReadyCmd,
&fromImage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templa
body.StartCmd,
build.Vcpu,
build.FreeDiskSizeMb,
&team.Limits.MaxDiskSizeMb,
build.RamMb,
body.ReadyCmd,
body.FromImage,
Expand Down
4 changes: 3 additions & 1 deletion packages/api/internal/template-manager/create_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ func (tm *TemplateManager) CreateTemplate(
firecrackerVersion string,
startCommand *string,
vCpuCount,
diskSizeMB,
diskSizeMB int64,
maxDiskSizeMB *int64,
memoryMB int64,
readyCommand *string,
fromImage *string,
Expand Down Expand Up @@ -118,6 +119,7 @@ func (tm *TemplateManager) CreateTemplate(
VCpuCount: int32(vCpuCount),
MemoryMB: int32(memoryMB),
DiskSizeMB: int32(diskSizeMB),
MaxDiskSizeMB: maxDiskSizeMB,
KernelVersion: kernelVersion,
FirecrackerVersion: firecrackerVersion,
HugePages: features.HasHugePages(),
Expand Down
2 changes: 1 addition & 1 deletion packages/api/internal/template/register_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func RegisterBuild(
Vcpu: cpuCount,
KernelVersion: data.KernelVersion,
FirecrackerVersion: data.FirecrackerVersion,
FreeDiskSizeMb: data.Team.Limits.DiskMb,
FreeDiskSizeMb: data.Team.Limits.DefaultFreeDiskSizeMb,
StartCmd: data.StartCmd,
ReadyCmd: data.ReadyCmd,
Dockerfile: new(data.Dockerfile),
Expand Down
6 changes: 3 additions & 3 deletions packages/auth/pkg/auth/auth_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func (s *authStoreImpl) GetTeamByHashedAPIKey(ctx context.Context, hashedKey str
}
}()

team := types.NewTeam(&result.Team, &result.TeamLimit)
team := types.NewTeam(&result.Team, &result.TeamLimitsV2)

return team, nil
}
Expand All @@ -66,7 +66,7 @@ func (s *authStoreImpl) GetTeamByID(ctx context.Context, teamID uuid.UUID) (*typ
return nil, err
}

team := types.NewTeam(&result.Team, &result.TeamLimit)
team := types.NewTeam(&result.Team, &result.TeamLimitsV2)

return team, nil
}
Expand All @@ -92,7 +92,7 @@ func (s *authStoreImpl) GetTeamByIDAndUserID(ctx context.Context, userID uuid.UU
return nil, err
}

team := types.NewTeam(&result.Team, &result.TeamLimit)
team := types.NewTeam(&result.Team, &result.TeamLimitsV2)

return team, nil
}
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/pkg/auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestAdminTeamAuthenticatorSetsTeamContext(t *testing.T) {

ctx := context.Background()
teamID := uuid.New()
team := types.NewTeam(&authqueries.Team{ID: teamID}, &authqueries.TeamLimit{})
team := types.NewTeam(&authqueries.Team{ID: teamID}, &authqueries.TeamLimitsV2{})

req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
req.Header.Set(HeaderTeamID, teamID.String())
Expand Down
20 changes: 10 additions & 10 deletions packages/auth/pkg/auth/team_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,31 @@ func TestEnforceBlockedTeam(t *testing.T) {
name: "not blocked passes",
method: http.MethodPost,
fullPath: "/sandboxes",
team: types.NewTeam(&authqueries.Team{IsBlocked: false}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: false}, &authqueries.TeamLimitsV2{}),
wantHandlerRan: true,
wantStatus: http.StatusOK,
},
{
name: "blocked and allowlisted get passes",
method: http.MethodGet,
fullPath: "/sandboxes",
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimitsV2{}),
wantHandlerRan: true,
wantStatus: http.StatusOK,
},
{
name: "blocked and allowlisted delete passes",
method: http.MethodDelete,
fullPath: "/sandboxes/:sandboxID",
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimitsV2{}),
wantHandlerRan: true,
wantStatus: http.StatusOK,
},
{
name: "blocked and non-allowlisted post denied",
method: http.MethodPost,
fullPath: "/sandboxes",
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimitsV2{}),
wantHandlerRan: false,
wantStatus: http.StatusForbidden,
wantBodyHas: "team is blocked: " + reason,
Expand All @@ -77,7 +77,7 @@ func TestEnforceBlockedTeam(t *testing.T) {
name: "blocked and method mismatch on allowlisted path denied",
method: http.MethodPost,
fullPath: "/sandboxes/:sandboxID",
team: types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimitsV2{}),
wantHandlerRan: false,
wantStatus: http.StatusForbidden,
wantBodyHas: "team is blocked",
Expand All @@ -102,7 +102,7 @@ func TestEnforceBlockedTeam(t *testing.T) {
func TestEnforceBlockedTeam_AllowlistParameterization(t *testing.T) {
t.Parallel()

team := types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimit{})
team := types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimitsV2{})
const method, path = http.MethodGet, "/teams/:teamID/members"

permissive := BlockedTeamAllowlist{method: {path: {}}}
Expand All @@ -125,10 +125,10 @@ func TestCheckTeamAccess(t *testing.T) {
http.MethodGet: {"/sandboxes": {}},
}

cleanTeam := types.NewTeam(&authqueries.Team{}, &authqueries.TeamLimit{})
bannedTeam := types.NewTeam(&authqueries.Team{IsBanned: true}, &authqueries.TeamLimit{})
blockedTeam := types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{})
bannedAndBlockedTeam := types.NewTeam(&authqueries.Team{IsBanned: true, IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{})
cleanTeam := types.NewTeam(&authqueries.Team{}, &authqueries.TeamLimitsV2{})
bannedTeam := types.NewTeam(&authqueries.Team{IsBanned: true}, &authqueries.TeamLimitsV2{})
blockedTeam := types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimitsV2{})
bannedAndBlockedTeam := types.NewTeam(&authqueries.Team{IsBanned: true, IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimitsV2{})

wantForbidden := func(t *testing.T, err error) {
t.Helper()
Expand Down
8 changes: 4 additions & 4 deletions packages/auth/pkg/auth/team_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,24 @@ func TestCheckTeamBlocked(t *testing.T) {
},
{
name: "not blocked",
team: types.NewTeam(&authqueries.Team{IsBlocked: false}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: false}, &authqueries.TeamLimitsV2{}),
wantErr: false,
},
{
name: "blocked without reason",
team: types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimitsV2{}),
wantErr: true,
wantMsgHas: "team is blocked",
},
{
name: "blocked with reason",
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimitsV2{}),
wantErr: true,
wantMsgHas: reason,
},
{
name: "blocked with empty reason pointer",
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &empty}, &authqueries.TeamLimit{}),
team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &empty}, &authqueries.TeamLimitsV2{}),
wantErr: true,
wantMsgHas: "team is blocked",
},
Expand Down
5 changes: 4 additions & 1 deletion packages/auth/pkg/types/limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ type TeamLimits struct {

MaxVcpu int64
MaxRamMb int64
DiskMb int64
// Deprecated: use DefaultFreeDiskSizeMb or MaxDiskSizeMb according to the operation.
DiskMb int64
DefaultFreeDiskSizeMb int64
MaxDiskSizeMb int64

EventsTTLDays int64
}
20 changes: 11 additions & 9 deletions packages/auth/pkg/types/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,24 @@ func (t *Team) TeamID() string {
}

func newTeamLimits(
teamLimits *authqueries.TeamLimit,
teamLimits *authqueries.TeamLimitsV2,
) *TeamLimits {
return &TeamLimits{
SandboxConcurrency: int64(teamLimits.ConcurrentSandboxes),
BuildConcurrency: int64(teamLimits.ConcurrentTemplateBuilds),
MaxLengthHours: teamLimits.MaxLengthHours,
MaxVcpu: int64(teamLimits.MaxVcpu),
MaxRamMb: int64(teamLimits.MaxRamMb),
DiskMb: int64(teamLimits.DiskMb),
EventsTTLDays: int64(teamLimits.EventsTtlDays),
SandboxConcurrency: int64(teamLimits.ConcurrentSandboxes),
BuildConcurrency: int64(teamLimits.ConcurrentTemplateBuilds),
MaxLengthHours: teamLimits.MaxLengthHours,
MaxVcpu: int64(teamLimits.MaxVcpu),
MaxRamMb: int64(teamLimits.MaxRamMb),
DiskMb: int64(teamLimits.DiskMb),
DefaultFreeDiskSizeMb: teamLimits.DefaultFreeDiskSizeMb,
MaxDiskSizeMb: teamLimits.MaxDiskSizeMb,
EventsTTLDays: int64(teamLimits.EventsTtlDays),
}
Comment on lines 20 to 30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

To support the nullable DefaultFreeDiskSizeMb and MaxDiskSizeMb fields (which should be pointers *int64 in authqueries.TeamLimitsV2), update newTeamLimits to safely dereference them and default to 0 if they are nil. This prevents potential nil-pointer dereference panics and ensures backward compatibility for existing tiers.

	var defaultFreeDiskSizeMb int64
	if teamLimits.DefaultFreeDiskSizeMb != nil {
		defaultFreeDiskSizeMb = *teamLimits.DefaultFreeDiskSizeMb
	}
	var maxDiskSizeMb int64
	if teamLimits.MaxDiskSizeMb != nil {
		maxDiskSizeMb = *teamLimits.MaxDiskSizeMb
	}

	return &TeamLimits{
		SandboxConcurrency:    int64(teamLimits.ConcurrentSandboxes),
		BuildConcurrency:      int64(teamLimits.ConcurrentTemplateBuilds),
		MaxLengthHours:        teamLimits.MaxLengthHours,
		MaxVcpu:               int64(teamLimits.MaxVcpu),
		MaxRamMb:              int64(teamLimits.MaxRamMb),
		DiskMb:                int64(teamLimits.DiskMb),
		DefaultFreeDiskSizeMb: defaultFreeDiskSizeMb,
		MaxDiskSizeMb:         maxDiskSizeMb,
		EventsTTLDays:         int64(teamLimits.EventsTtlDays),
	}

}

func NewTeam(
team *authqueries.Team,
teamLimits *authqueries.TeamLimit,
teamLimits *authqueries.TeamLimitsV2,
) *Team {
return &Team{
Team: team,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- +goose Up
-- +goose StatementBegin

ALTER TABLE "public"."tiers"
-- Free-rootfs target in the existing MiB convention.
ADD COLUMN "default_free_disk_size_mb" bigint,
-- Total logical-rootfs ceiling before active add-ons.
ADD COLUMN "max_disk_size_mb" bigint;

ALTER TABLE "public"."addons"
-- Total-ceiling increment in the existing MiB convention.
ADD COLUMN "extra_max_disk_size_mb" bigint;

ALTER TABLE "public"."tiers"
ADD CONSTRAINT "tiers_default_free_disk_size_mb_check"
CHECK (default_free_disk_size_mb >= 0),
ADD CONSTRAINT "tiers_max_disk_size_mb_check"
CHECK (max_disk_size_mb > 0),
ADD CONSTRAINT "tiers_default_free_disk_size_lte_max_check"
CHECK (default_free_disk_size_mb <= max_disk_size_mb);

ALTER TABLE "public"."addons"
ADD CONSTRAINT "addons_extra_max_disk_size_mb_check"
CHECK (extra_max_disk_size_mb >= 0);

-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin

ALTER TABLE "public"."tiers"
DROP CONSTRAINT IF EXISTS "tiers_default_free_disk_size_lte_max_check",
DROP CONSTRAINT IF EXISTS "tiers_max_disk_size_mb_check",
DROP CONSTRAINT IF EXISTS "tiers_default_free_disk_size_mb_check",
DROP COLUMN IF EXISTS "max_disk_size_mb",
DROP COLUMN IF EXISTS "default_free_disk_size_mb";

ALTER TABLE "public"."addons"
DROP CONSTRAINT IF EXISTS "addons_extra_max_disk_size_mb_check",
DROP COLUMN IF EXISTS "extra_max_disk_size_mb";

-- +goose StatementEnd
40 changes: 40 additions & 0 deletions packages/db/migrations/20260714091415_create_team_limits_v2.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- +goose Up
-- +goose StatementBegin

CREATE VIEW "public"."team_limits_v2"
WITH (security_invoker=on) AS
SELECT
t.id,
tier.max_length_hours,
(tier.concurrent_instances + a.extra_concurrent_sandboxes) AS concurrent_sandboxes,
(tier.concurrent_template_builds + a.extra_concurrent_template_builds) AS concurrent_template_builds,
(tier.max_vcpu + a.extra_max_vcpu) AS max_vcpu,
(tier.max_ram_mb + a.extra_max_ram_mb) AS max_ram_mb,
(tier.disk_mb + a.extra_disk_mb) AS disk_mb,
(tier.events_ttl_days + a.extra_events_ttl_days) AS events_ttl_days,
(tier.default_free_disk_size_mb + a.extra_disk_mb)::bigint AS default_free_disk_size_mb,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: HIGH

team_limits_v2.default_free_disk_size_mb is computed from nullable tiers.default_free_disk_size_mb (NULL + extra_disk_mb stays NULL), while downstream auth queries scan this column into non-null int64 fields. For teams on existing tier rows without a backfill/default, that scan can fail and break team resolution on authenticated request paths.

Impact: authenticated traffic for affected teams can fail in auth/team-loading paths (availability/DoS) until data is backfilled or the view expression is made NULL-safe.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit c1406f8. Configure here.

(tier.max_disk_size_mb + a.extra_max_disk_size_mb)::bigint AS max_disk_size_mb
Comment on lines +6 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Migration 20260714091414 adds tiers.default_free_disk_size_mb and tiers.max_disk_size_mb as nullable bigint columns with no DEFAULT and no backfill for existing rows. Because the team_limits_v2 view (20260714091415) does not COALESCE the tier-side value before adding the addon delta, every pre-existing tier yields NULL for these two columns, and since the auth-path generated model (packages/db/pkg/auth/queries/models.go) types them as non-pointer int64 while get_team.sql.go scans directly into that field, pgx will error scanning NULL into it. This breaks GetTeamWithTierByAPIKey/ByTeamID/ByTeamAndUser/GetTeamsWithUsersTeamsWithTier for every existing team as soon as this migration deploys, taking down API-key auth and team lookups system-wide until every tier row is manually backfilled.

Extended reasoning...

The bug: Migration 20260714091414_add_template_disk_entitlements.sql adds tiers.default_free_disk_size_mb and tiers.max_disk_size_mb as bigint columns with no DEFAULT and no backfill UPDATE for existing rows. The only guardrails are CHECK constraints (>= 0, > 0, <= max), and in SQL a CHECK evaluates to UNKNOWN (and therefore passes) when the operand is NULL. Every tier row that existed before this migration — i.e. every tier in every production/self-hosted deployment — therefore keeps these two columns as NULL after the migration runs. Nothing else in this PR (or in packages/db/migrations/) issues a backfill for them.

Where it manifests: The very next migration, 20260714091415_create_team_limits_v2.sql, defines the team_limits_v2 view as:

(tier.default_free_disk_size_mb + a.extra_disk_mb)::bigint AS default_free_disk_size_mb,
(tier.max_disk_size_mb + a.extra_max_disk_size_mb)::bigint AS max_disk_size_mb

The addon-side operand (a.extra_disk_mb, a.extra_max_disk_size_mb) is wrapped in COALESCE(..., 0) inside the lateral join, but the tier-side operand is not. In SQL, NULL + n = NULL, so for any team on a non-backfilled tier this view produces NULL in both columns — even when the team has zero addons.

Why the generated code can't absorb a NULL: The sqlc.yaml override comment even acknowledges the nullability problem ("sqlc does not infer nullability for these view expressions") and forces these two columns to *int64 — but that override only applies to the testutils sqlc config, not the authqueries config used by the real auth path. Concretely:

  • packages/db/pkg/testutils/queries/models.go: DefaultFreeDiskSizeMb *int64, MaxDiskSizeMb *int64 (correct, pointer).
  • packages/db/pkg/auth/queries/models.go (TeamLimitsV2, used by real auth): DefaultFreeDiskSizeMb int64, MaxDiskSizeMb int64 (non-pointer).

And get_team.sql.go scans directly into the non-pointer field in all four generated queries: GetTeamWithTierByAPIKey, GetTeamWithTierByTeamAndUser, GetTeamWithTierByTeamID, GetTeamsWithUsersTeamsWithTier (e.g. &i.TeamLimitsV2.DefaultFreeDiskSizeMb). pgx v5 returns a scan error when the destination is a non-pointer int64 and the column value is SQL NULL.

This non-pointer typing isn't just a stale codegen artifact that a re-run would silently fix either: packages/auth/pkg/types/teams.go's newTeamLimits assigns teamLimits.DefaultFreeDiskSizeMb straight into TeamLimits.DefaultFreeDiskSizeMb (also int64, no conversion), so the code depends on it staying a plain int64 to compile.

Step-by-step proof:

  1. Deploy runs migration 20260714091414: existing tier row base_v1 (from migration 20231220094836) gets default_free_disk_size_mb = NULL, max_disk_size_mb = NULL. The CHECK constraints pass because the operands are NULL.
  2. Migration 20260714091415 creates team_limits_v2. For any team on base_v1, evaluating the view: tier.default_free_disk_size_mb is NULL, a.extra_disk_mb is COALESCE(SUM(...), 0) = 0 (no addons) → NULL + 0 = NULL. Same for max_disk_size_mb.
  3. A request hits an authenticated endpoint. authStoreImpl.GetTeamByHashedAPIKey calls s.authDB.Read.GetTeamWithTierByAPIKey(ctx, hashedKey), which runs the generated query and executes row.Scan(..., &i.TeamLimitsV2.DefaultFreeDiskSizeMb, &i.TeamLimitsV2.MaxDiskSizeMb).
  4. pgx tries to write SQL NULL into a non-pointer int64 destination and returns an error (e.g. "cannot scan NULL into *int64" is fine, but this is a non-pointer int64, which errors as an invalid Scan target). The error propagates up through GetTeamByHashedAPIKeyfmt.Errorf("failed to get team from API key: %w", err).
  5. Every API-key-authenticated request for that team now fails at the auth layer. Because there is no backfill, this affects every pre-existing team as soon as the migration deploys — not just an edge case.

Why this isn't caught by the PR's own test: disk_entitlements_migration_test.go inserts a brand-new tier row with default_free_disk_size_mb and max_disk_size_mb explicitly populated (8000/30000), so it never exercises the NULL/pre-existing-tier path that production will hit.

Fix: Either (a) add COALESCE(tier.default_free_disk_size_mb, <sentinel>) / COALESCE(tier.max_disk_size_mb, <sentinel>) in the view (matching whatever sentinel makes sense, e.g. falling back to the legacy disk_mb), or (b) add a backfill UPDATE ... WHERE default_free_disk_size_mb IS NULL (and same for max_disk_size_mb) followed by SET NOT NULL, consistent with how prior tier-column additions in this repo (e.g. migration 20240219190940) handle backfill. Either fix should be paired with correcting the authqueries sqlc override so the Go type reflects the DB's actual nullability guarantee.

FROM "public"."teams" t
JOIN "public"."tiers" tier ON t.tier = tier.id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(extra_concurrent_sandboxes), 0)::bigint AS extra_concurrent_sandboxes,
COALESCE(SUM(extra_concurrent_template_builds), 0)::bigint AS extra_concurrent_template_builds,
COALESCE(SUM(extra_max_vcpu), 0)::bigint AS extra_max_vcpu,
COALESCE(SUM(extra_max_ram_mb), 0)::bigint AS extra_max_ram_mb,
COALESCE(SUM(extra_disk_mb), 0)::bigint AS extra_disk_mb,
COALESCE(SUM(extra_events_ttl_days), 0)::bigint AS extra_events_ttl_days,
COALESCE(SUM(COALESCE(extra_max_disk_size_mb, extra_disk_mb)), 0)::bigint AS extra_max_disk_size_mb
FROM "public"."addons" addon
WHERE addon.team_id = t.id
AND addon.valid_from <= now()
AND (addon.valid_to IS NULL OR addon.valid_to > now())
) a ON true;

-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin

DROP VIEW IF EXISTS "public"."team_limits_v2";

-- +goose StatementEnd
Loading
Loading