1
0
forked from wrenn/wrenn

Add disk_size_mb, auto-expand base images, admin templates endpoint

Disk sizing:
- Add disk_size_mb column to sandboxes table (default 20480 = 20GB)
- Add disk_size_mb to CreateSandboxRequest proto, passed through the
  full chain: service → RPC → host agent → sandbox manager → devicemapper
- devicemapper.CreateSnapshot takes separate cowSizeBytes param so the
  sparse CoW file can be sized independently from the origin
- EnsureImageSizes() runs at host agent startup: expands any base image
  smaller than 20GB via truncate + resize2fs (sparse, no extra physical
  disk). Sandboxes then get the full 20GB via fast dm-snapshot path
- FlattenRootfs shrinks output images with resize2fs -M so stored
  templates are compact; EnsureImageSizes re-expands on next startup

Admin templates visibility:
- Add GET /v1/admin/templates endpoint listing all templates across teams
- Frontend admin templates page uses listAdminTemplates() instead of
  team-scoped listSnapshots()
- Platform templates (team_id = all-zeros UUID) now visible to all teams:
  GetTemplateByTeam, ListTemplatesByTeam, ListTemplatesByTeamAndType
  queries include platform team_id in WHERE clause
This commit is contained in:
2026-03-26 23:45:41 +06:00
parent 4ddd494160
commit c0d6381bbe
19 changed files with 241 additions and 42 deletions

View File

@ -59,6 +59,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Expand base images to the standard disk size (sparse, no extra physical
// disk). This ensures dm-snapshot sandboxes see the full size from boot.
imagesDir := filepath.Join(rootDir, "images")
if err := sandbox.EnsureImageSizes(imagesDir, sandbox.DefaultDiskSizeMB); err != nil {
slog.Error("failed to expand base images", "error", err)
os.Exit(1)
}
cfg := sandbox.Config{ cfg := sandbox.Config{
KernelPath: filepath.Join(rootDir, "kernels", "vmlinux"), KernelPath: filepath.Join(rootDir, "kernels", "vmlinux"),
ImagesDir: filepath.Join(rootDir, "images"), ImagesDir: filepath.Join(rootDir, "images"),

View File

@ -144,6 +144,7 @@ CREATE TABLE sandboxes (
vcpus INTEGER NOT NULL DEFAULT 1, vcpus INTEGER NOT NULL DEFAULT 1,
memory_mb INTEGER NOT NULL DEFAULT 512, memory_mb INTEGER NOT NULL DEFAULT 512,
timeout_sec INTEGER NOT NULL DEFAULT 300, timeout_sec INTEGER NOT NULL DEFAULT 300,
disk_size_mb INTEGER NOT NULL DEFAULT 20480,
guest_ip TEXT NOT NULL DEFAULT '', guest_ip TEXT NOT NULL DEFAULT '',
host_ip TEXT NOT NULL DEFAULT '', host_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

View File

@ -1,6 +1,6 @@
-- name: InsertSandbox :one -- name: InsertSandbox :one
INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *; RETURNING *;
-- name: GetSandbox :one -- name: GetSandbox :one

View File

@ -7,7 +7,8 @@ RETURNING *;
SELECT * FROM templates WHERE name = $1; SELECT * FROM templates WHERE name = $1;
-- name: GetTemplateByTeam :one -- name: GetTemplateByTeam :one
SELECT * FROM templates WHERE name = $1 AND team_id = $2; -- Platform templates (team_id = 00000000-...) are visible to all teams.
SELECT * FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000');
-- name: ListTemplates :many -- name: ListTemplates :many
SELECT * FROM templates ORDER BY created_at DESC; SELECT * FROM templates ORDER BY created_at DESC;
@ -16,10 +17,12 @@ SELECT * FROM templates ORDER BY created_at DESC;
SELECT * FROM templates WHERE type = $1 ORDER BY created_at DESC; SELECT * FROM templates WHERE type = $1 ORDER BY created_at DESC;
-- name: ListTemplatesByTeam :many -- name: ListTemplatesByTeam :many
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC; -- Platform templates are visible to all teams.
SELECT * FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC;
-- name: ListTemplatesByTeamAndType :many -- name: ListTemplatesByTeamAndType :many
SELECT * FROM templates WHERE team_id = $1 AND type = $2 ORDER BY created_at DESC; -- Platform templates are visible to all teams.
SELECT * FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC;
-- name: DeleteTemplate :exec -- name: DeleteTemplate :exec
DELETE FROM templates WHERE name = $1; DELETE FROM templates WHERE name = $1;

View File

@ -50,3 +50,17 @@ export async function listBuilds(): Promise<ApiResult<Build[]>> {
export async function getBuild(id: string): Promise<ApiResult<Build>> { export async function getBuild(id: string): Promise<ApiResult<Build>> {
return apiFetch('GET', `/api/v1/admin/builds/${id}`); return apiFetch('GET', `/api/v1/admin/builds/${id}`);
} }
export type AdminTemplate = {
name: string;
type: string;
vcpus: number;
memory_mb: number;
size_bytes: number;
team_id: string;
created_at: string;
};
export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>> {
return apiFetch('GET', '/api/v1/admin/templates');
}

View File

@ -3,12 +3,14 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { formatDate, timeAgo } from '$lib/utils/format'; import { formatDate, timeAgo } from '$lib/utils/format';
import { listSnapshots, deleteSnapshot, type Snapshot } from '$lib/api/capsules'; import { deleteSnapshot } from '$lib/api/capsules';
import { import {
listBuilds, listBuilds,
createBuild, createBuild,
listAdminTemplates,
type Build, type Build,
type BuildLogEntry type BuildLogEntry,
type AdminTemplate
} from '$lib/api/builds'; } from '$lib/api/builds';
let collapsed = $state( let collapsed = $state(
@ -20,7 +22,7 @@
let activeTab = $state<'templates' | 'builds'>('templates'); let activeTab = $state<'templates' | 'builds'>('templates');
// Templates state // Templates state
let templates = $state<Snapshot[]>([]); let templates = $state<AdminTemplate[]>([]);
let templatesLoading = $state(true); let templatesLoading = $state(true);
let templatesError = $state<string | null>(null); let templatesError = $state<string | null>(null);
@ -38,7 +40,7 @@
let expandedSteps = $state<Set<number>>(new Set()); let expandedSteps = $state<Set<number>>(new Set());
// Delete template state // Delete template state
let deleteTarget = $state<Snapshot | null>(null); let deleteTarget = $state<AdminTemplate | null>(null);
let deleting = $state(false); let deleting = $state(false);
let deleteError = $state<string | null>(null); let deleteError = $state<string | null>(null);
@ -64,7 +66,7 @@
async function fetchTemplates() { async function fetchTemplates() {
templatesLoading = true; templatesLoading = true;
templatesError = null; templatesError = null;
const result = await listSnapshots(); const result = await listAdminTemplates();
if (result.ok) { if (result.ok) {
templates = result.data; templates = result.data;
} else { } else {

View File

@ -17,10 +17,11 @@ import (
type buildHandler struct { type buildHandler struct {
svc *service.BuildService svc *service.BuildService
db *db.Queries
} }
func newBuildHandler(svc *service.BuildService) *buildHandler { func newBuildHandler(svc *service.BuildService, db *db.Queries) *buildHandler {
return &buildHandler{svc: svc} return &buildHandler{svc: svc, db: db}
} }
type createBuildRequest struct { type createBuildRequest struct {
@ -165,3 +166,39 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, buildToResponse(build)) writeJSON(w, http.StatusOK, buildToResponse(build))
} }
// ListTemplates handles GET /v1/admin/templates — returns all templates across all teams.
func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.db.ListTemplates(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
return
}
type templateResponse struct {
Name string `json:"name"`
Type string `json:"type"`
VCPUs int32 `json:"vcpus"`
MemoryMB int32 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"`
TeamID string `json:"team_id"`
CreatedAt string `json:"created_at"`
}
resp := make([]templateResponse, len(templates))
for i, t := range templates {
resp[i] = templateResponse{
Name: t.Name,
Type: t.Type,
VCPUs: t.Vcpus,
MemoryMB: t.MemoryMb,
SizeBytes: t.SizeBytes,
TeamID: id.FormatTeamID(t.TeamID),
}
if t.CreatedAt.Valid {
resp[i].CreatedAt = t.CreatedAt.Time.Format(time.RFC3339)
}
}
writeJSON(w, http.StatusOK, resp)
}

View File

@ -67,7 +67,7 @@ func New(
auditH := newAuditHandler(auditSvc) auditH := newAuditHandler(auditSvc)
statsH := newStatsHandler(statsSvc) statsH := newStatsHandler(statsSvc)
metricsH := newSandboxMetricsHandler(queries, pool) metricsH := newSandboxMetricsHandler(queries, pool)
buildH := newBuildHandler(buildSvc) buildH := newBuildHandler(buildSvc, queries)
// OpenAPI spec and docs. // OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI) r.Get("/openapi.yaml", serveOpenAPI)
@ -177,6 +177,7 @@ func New(
r.Use(requireJWT(jwtSecret)) r.Use(requireJWT(jwtSecret))
r.Use(requireAdmin(queries)) r.Use(requireAdmin(queries))
r.Put("/teams/{id}/byoc", teamH.SetBYOC) r.Put("/teams/{id}/byoc", teamH.SetBYOC)
r.Get("/templates", buildH.ListTemplates)
r.Post("/builds", buildH.Create) r.Post("/builds", buildH.Create)
r.Get("/builds", buildH.List) r.Get("/builds", buildH.List)
r.Get("/builds/{id}", buildH.Get) r.Get("/builds/{id}", buildH.Get)

View File

@ -91,6 +91,7 @@ type Sandbox struct {
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"` TimeoutSec int32 `json:"timeout_sec"`
DiskSizeMb int32 `json:"disk_size_mb"`
GuestIp string `json:"guest_ip"` GuestIp string `json:"guest_ip"`
HostIp string `json:"host_ip"` HostIp string `json:"host_ip"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`

View File

@ -43,7 +43,7 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu
} }
const getSandbox = `-- name: GetSandbox :one const getSandbox = `-- name: GetSandbox :one
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1 SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1
` `
func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, error) { func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, error) {
@ -58,6 +58,7 @@ func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, erro
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -69,7 +70,7 @@ func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, erro
} }
const getSandboxByTeam = `-- name: GetSandboxByTeam :one const getSandboxByTeam = `-- name: GetSandboxByTeam :one
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1 AND team_id = $2 SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1 AND team_id = $2
` `
type GetSandboxByTeamParams struct { type GetSandboxByTeamParams struct {
@ -89,6 +90,7 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -100,9 +102,9 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
} }
const insertSandbox = `-- name: InsertSandbox :one const insertSandbox = `-- name: InsertSandbox :one
INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated
` `
type InsertSandboxParams struct { type InsertSandboxParams struct {
@ -114,6 +116,7 @@ type InsertSandboxParams struct {
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"` TimeoutSec int32 `json:"timeout_sec"`
DiskSizeMb int32 `json:"disk_size_mb"`
} }
func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) { func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) {
@ -126,6 +129,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
arg.Vcpus, arg.Vcpus,
arg.MemoryMb, arg.MemoryMb,
arg.TimeoutSec, arg.TimeoutSec,
arg.DiskSizeMb,
) )
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
@ -137,6 +141,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -148,7 +153,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
} }
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -171,6 +176,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -189,7 +195,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U
} }
const listSandboxes = `-- name: ListSandboxes :many const listSandboxes = `-- name: ListSandboxes :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes ORDER BY created_at DESC SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes ORDER BY created_at DESC
` `
func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
@ -210,6 +216,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -228,7 +235,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
} }
const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes
WHERE host_id = $1 AND status = ANY($2::text[]) WHERE host_id = $1 AND status = ANY($2::text[])
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -256,6 +263,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -274,7 +282,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
} }
const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes
WHERE team_id = $1 AND status NOT IN ('stopped', 'error') WHERE team_id = $1 AND status NOT IN ('stopped', 'error')
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -297,6 +305,7 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) (
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -355,7 +364,7 @@ SET status = 'running',
last_active_at = $4, last_active_at = $4,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated
` `
type UpdateSandboxRunningParams struct { type UpdateSandboxRunningParams struct {
@ -382,6 +391,7 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,
@ -397,7 +407,7 @@ UPDATE sandboxes
SET status = $2, SET status = $2,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated RETURNING id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated
` `
type UpdateSandboxStatusParams struct { type UpdateSandboxStatusParams struct {
@ -417,6 +427,7 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
&i.Vcpus, &i.Vcpus,
&i.MemoryMb, &i.MemoryMb,
&i.TimeoutSec, &i.TimeoutSec,
&i.DiskSizeMb,
&i.GuestIp, &i.GuestIp,
&i.HostIp, &i.HostIp,
&i.CreatedAt, &i.CreatedAt,

View File

@ -54,7 +54,7 @@ func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error
} }
const getTemplateByTeam = `-- name: GetTemplateByTeam :one const getTemplateByTeam = `-- name: GetTemplateByTeam :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 AND team_id = $2 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
` `
type GetTemplateByTeamParams struct { type GetTemplateByTeamParams struct {
@ -62,6 +62,7 @@ type GetTemplateByTeamParams struct {
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
} }
// Platform templates (team_id = 00000000-...) are visible to all teams.
func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) { func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) {
row := q.db.QueryRow(ctx, getTemplateByTeam, arg.Name, arg.TeamID) row := q.db.QueryRow(ctx, getTemplateByTeam, arg.Name, arg.TeamID)
var i Template var i Template
@ -147,9 +148,10 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
} }
const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC
` `
// Platform templates are visible to all teams.
func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ([]Template, error) { func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplatesByTeam, teamID) rows, err := q.db.Query(ctx, listTemplatesByTeam, teamID)
if err != nil { if err != nil {
@ -179,7 +181,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) (
} }
const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 AND type = $2 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC
` `
type ListTemplatesByTeamAndTypeParams struct { type ListTemplatesByTeamAndTypeParams struct {
@ -187,6 +189,7 @@ type ListTemplatesByTeamAndTypeParams struct {
Type string `json:"type"` Type string `json:"type"`
} }
// Platform templates are visible to all teams.
func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTemplatesByTeamAndTypeParams) ([]Template, error) { func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTemplatesByTeamAndTypeParams) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplatesByTeamAndType, arg.TeamID, arg.Type) rows, err := q.db.Query(ctx, listTemplatesByTeamAndType, arg.TeamID, arg.Type)
if err != nil { if err != nil {

View File

@ -116,9 +116,10 @@ type SnapshotDevice struct {
// writable CoW layer. // writable CoW layer.
// //
// The origin loop device must already exist (from LoopRegistry.Acquire). // The origin loop device must already exist (from LoopRegistry.Acquire).
func CreateSnapshot(name, originLoopDev, cowPath string, originSizeBytes int64) (*SnapshotDevice, error) { func CreateSnapshot(name, originLoopDev, cowPath string, originSizeBytes, cowSizeBytes int64) (*SnapshotDevice, error) {
// Create sparse CoW file sized to match the origin. // Create sparse CoW file. The logical size limits how many blocks can be
if err := createSparseFile(cowPath, originSizeBytes); err != nil { // modified; because the file is sparse, only written blocks use real disk.
if err := createSparseFile(cowPath, cowSizeBytes); err != nil {
return nil, fmt.Errorf("create cow file: %w", err) return nil, fmt.Errorf("create cow file: %w", err)
} }
@ -128,6 +129,9 @@ func CreateSnapshot(name, originLoopDev, cowPath string, originSizeBytes int64)
return nil, fmt.Errorf("losetup cow: %w", err) return nil, fmt.Errorf("losetup cow: %w", err)
} }
// The dm-snapshot virtual device size must match the origin — the snapshot
// target maps 1:1 onto origin sectors. The CoW file just needs enough
// space to store all modified blocks (it's sparse, so 20GB costs nothing).
sectors := originSizeBytes / 512 sectors := originSizeBytes / 512
if err := dmsetupCreate(name, originLoopDev, cowLoopDev, sectors); err != nil { if err := dmsetupCreate(name, originLoopDev, cowLoopDev, sectors); err != nil {
if detachErr := losetupDetach(cowLoopDev); detachErr != nil { if detachErr := losetupDetach(cowLoopDev); detachErr != nil {

View File

@ -39,7 +39,7 @@ func (s *Server) CreateSandbox(
) (*connect.Response[pb.CreateSandboxResponse], error) { ) (*connect.Response[pb.CreateSandboxResponse], error) {
msg := req.Msg msg := req.Msg
sb, err := s.mgr.Create(ctx, msg.SandboxId, msg.Template, int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec)) sb, err := s.mgr.Create(ctx, msg.SandboxId, msg.Template, int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), int(msg.DiskSizeMb))
if err != nil { if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err)) return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err))
} }

View File

@ -0,0 +1,74 @@
package sandbox
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
)
// DefaultDiskSizeMB is the standard disk size for base images. Images smaller
// than this are expanded at startup so that dm-snapshot sandboxes see the full
// size without per-sandbox copies. The expansion is sparse — only metadata
// changes; no physical disk is consumed beyond the original content.
const DefaultDiskSizeMB = 20480 // 20 GB
// EnsureImageSizes walks the images directory and expands any rootfs.ext4 that
// is smaller than the target size. This is idempotent: images already at or
// above the target size are left untouched. Should be called once at host agent
// startup before any sandboxes are created.
func EnsureImageSizes(imagesDir string, targetMB int) error {
if targetMB <= 0 {
targetMB = DefaultDiskSizeMB
}
targetBytes := int64(targetMB) * 1024 * 1024
entries, err := os.ReadDir(imagesDir)
if err != nil {
return fmt.Errorf("read images dir: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
rootfs := filepath.Join(imagesDir, entry.Name(), "rootfs.ext4")
info, err := os.Stat(rootfs)
if err != nil {
continue // not every template dir has a rootfs.ext4
}
if info.Size() >= targetBytes {
continue // already large enough
}
slog.Info("expanding base image",
"template", entry.Name(),
"from_mb", info.Size()/(1024*1024),
"to_mb", targetMB,
)
// Expand the file (sparse — instant, no physical disk used).
if err := os.Truncate(rootfs, targetBytes); err != nil {
return fmt.Errorf("truncate %s: %w", rootfs, err)
}
// Check filesystem before resize.
if out, err := exec.Command("e2fsck", "-fy", rootfs).CombinedOutput(); err != nil {
// e2fsck returns 1 if it fixed errors, which is fine.
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() > 1 {
return fmt.Errorf("e2fsck %s: %s: %w", rootfs, string(out), err)
}
}
// Grow the ext4 filesystem to fill the new file size.
if out, err := exec.Command("resize2fs", rootfs).CombinedOutput(); err != nil {
return fmt.Errorf("resize2fs %s: %s: %w", rootfs, string(out), err)
}
slog.Info("base image expanded", "template", entry.Name(), "size_mb", targetMB)
}
return nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
@ -51,8 +52,8 @@ type sandboxState struct {
slot *network.Slot slot *network.Slot
client *envdclient.Client client *envdclient.Client
uffdSocketPath string // non-empty for sandboxes restored from snapshot uffdSocketPath string // non-empty for sandboxes restored from snapshot
dmDevice *devicemapper.SnapshotDevice dmDevice *devicemapper.SnapshotDevice
baseImagePath string // path to the base template rootfs (for loop registry release) baseImagePath string // path to the base template rootfs (for loop registry release)
// parent holds the snapshot header and diff file paths from which this // parent holds the snapshot header and diff file paths from which this
// sandbox was restored. Non-nil means re-pause should use "Diff" snapshot // sandbox was restored. Non-nil means re-pause should use "Diff" snapshot
@ -94,7 +95,7 @@ func New(cfg Config) *Manager {
// Create boots a new sandbox: clone rootfs, set up network, start VM, wait for envd. // Create boots a new sandbox: clone rootfs, set up network, start VM, wait for envd.
// If sandboxID is empty, a new ID is generated. // If sandboxID is empty, a new ID is generated.
func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, memoryMB, timeoutSec int) (*models.Sandbox, error) { func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, memoryMB, timeoutSec, diskSizeMB int) (*models.Sandbox, error) {
if sandboxID == "" { if sandboxID == "" {
sandboxID = id.FormatSandboxID(id.NewSandboxID()) sandboxID = id.FormatSandboxID(id.NewSandboxID())
} }
@ -105,6 +106,9 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
if memoryMB <= 0 { if memoryMB <= 0 {
memoryMB = 512 memoryMB = 512
} }
if diskSizeMB <= 0 {
diskSizeMB = 20480 // 20 GB default
}
if template == "" { if template == "" {
template = "minimal" template = "minimal"
@ -115,7 +119,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
// Check if template refers to a snapshot (has snapfile + memfile + header + rootfs). // Check if template refers to a snapshot (has snapfile + memfile + header + rootfs).
if snapshot.IsSnapshot(m.cfg.ImagesDir, template) { if snapshot.IsSnapshot(m.cfg.ImagesDir, template) {
return m.createFromSnapshot(ctx, sandboxID, template, vcpus, memoryMB, timeoutSec) return m.createFromSnapshot(ctx, sandboxID, template, vcpus, memoryMB, timeoutSec, diskSizeMB)
} }
// Resolve base rootfs image: /var/lib/wrenn/images/{template}/rootfs.ext4 // Resolve base rootfs image: /var/lib/wrenn/images/{template}/rootfs.ext4
@ -139,7 +143,8 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
// Create dm-snapshot with per-sandbox CoW file. // Create dm-snapshot with per-sandbox CoW file.
dmName := "wrenn-" + sandboxID dmName := "wrenn-" + sandboxID
cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID))
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize) cowSize := int64(diskSizeMB) * 1024 * 1024
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize)
if err != nil { if err != nil {
m.loops.Release(baseRootfs) m.loops.Release(baseRootfs)
return nil, fmt.Errorf("create dm-snapshot: %w", err) return nil, fmt.Errorf("create dm-snapshot: %w", err)
@ -853,6 +858,17 @@ func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID, name string) (in
// Clean up dm device and loop device now that flatten is complete. // Clean up dm device and loop device now that flatten is complete.
m.cleanupDM(sb) m.cleanupDM(sb)
// Shrink the flattened image to its minimum size so stored templates are
// compact. EnsureImageSizes will re-expand them on the next agent startup.
if out, err := exec.Command("e2fsck", "-fy", outputPath).CombinedOutput(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() > 1 {
slog.Warn("e2fsck before shrink failed (non-fatal)", "output", string(out), "error", err)
}
}
if out, err := exec.Command("resize2fs", "-M", outputPath).CombinedOutput(); err != nil {
slog.Warn("resize2fs -M failed (non-fatal)", "output", string(out), "error", err)
}
sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name) sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name)
if err != nil { if err != nil {
slog.Warn("failed to calculate template size", "error", err) slog.Warn("failed to calculate template size", "error", err)
@ -891,7 +907,7 @@ func (m *Manager) DeleteSnapshot(name string) error {
// in ImagesDir/{snapshotName}/. Uses UFFD for lazy memory loading. // in ImagesDir/{snapshotName}/. Uses UFFD for lazy memory loading.
// The template's rootfs.ext4 is a flattened standalone image — we create a // The template's rootfs.ext4 is a flattened standalone image — we create a
// dm-snapshot on top of it just like a normal Create. // dm-snapshot on top of it just like a normal Create.
func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotName string, vcpus, _, timeoutSec int) (*models.Sandbox, error) { func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotName string, vcpus, _, timeoutSec, diskSizeMB int) (*models.Sandbox, error) {
imagesDir := m.cfg.ImagesDir imagesDir := m.cfg.ImagesDir
// Read the header. // Read the header.
@ -936,7 +952,8 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
dmName := "wrenn-" + sandboxID dmName := "wrenn-" + sandboxID
cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID))
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize) cowSize := int64(diskSizeMB) * 1024 * 1024
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize)
if err != nil { if err != nil {
source.Close() source.Close()
m.loops.Release(baseRootfs) m.loops.Release(baseRootfs)

View File

@ -199,7 +199,8 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
Template: build.BaseTemplate, Template: build.BaseTemplate,
Vcpus: build.Vcpus, Vcpus: build.Vcpus,
MemoryMb: build.MemoryMb, MemoryMb: build.MemoryMb,
TimeoutSec: 0, // no auto-pause for builds TimeoutSec: 0, // no auto-pause for builds
DiskSizeMb: 20480, // 20 GB for template builds
})) }))
if err != nil { if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("create sandbox failed: %v", err)) s.failBuild(ctx, buildID, fmt.Sprintf("create sandbox failed: %v", err))

View File

@ -32,6 +32,7 @@ type SandboxCreateParams struct {
VCPUs int32 VCPUs int32
MemoryMB int32 MemoryMB int32
TimeoutSec int32 TimeoutSec int32
DiskSizeMB int32
} }
// agentForSandbox looks up the host for the given sandbox and returns a client. // agentForSandbox looks up the host for the given sandbox and returns a client.
@ -77,6 +78,9 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
if p.MemoryMB <= 0 { if p.MemoryMB <= 0 {
p.MemoryMB = 512 p.MemoryMB = 512
} }
if p.DiskSizeMB <= 0 {
p.DiskSizeMB = 20480 // 20 GB default
}
// If the template is a snapshot, use its baked-in vcpus/memory. // If the template is a snapshot, use its baked-in vcpus/memory.
if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" { if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" {
@ -117,6 +121,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec, TimeoutSec: p.TimeoutSec,
DiskSizeMb: p.DiskSizeMB,
}); err != nil { }); err != nil {
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err) return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
} }
@ -127,6 +132,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec, TimeoutSec: p.TimeoutSec,
DiskSizeMb: p.DiskSizeMB,
})) }))
if err != nil { if err != nil {
if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{

View File

@ -33,7 +33,10 @@ type CreateSandboxRequest struct {
MemoryMb int32 `protobuf:"varint,3,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"` MemoryMb int32 `protobuf:"varint,3,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"`
// TTL in seconds. Sandbox is auto-paused after this duration of // TTL in seconds. Sandbox is auto-paused after this duration of
// inactivity. 0 means no auto-pause. // inactivity. 0 means no auto-pause.
TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
// Disk size in MB for the sparse CoW file. Limits how much data the
// sandbox can write beyond the base image. Default: 20480 (20 GB).
DiskSizeMb int32 `protobuf:"varint,6,opt,name=disk_size_mb,json=diskSizeMb,proto3" json:"disk_size_mb,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -103,6 +106,13 @@ func (x *CreateSandboxRequest) GetTimeoutSec() int32 {
return 0 return 0
} }
func (x *CreateSandboxRequest) GetDiskSizeMb() int32 {
if x != nil {
return x.DiskSizeMb
}
return 0
}
type CreateSandboxResponse struct { type CreateSandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
@ -2271,7 +2281,7 @@ var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" + const file_hostagent_proto_rawDesc = "" +
"\n" + "\n" +
"\x0fhostagent.proto\x12\fhostagent.v1\"\xa5\x01\n" + "\x0fhostagent.proto\x12\fhostagent.v1\"\xc7\x01\n" +
"\x14CreateSandboxRequest\x12\x1d\n" + "\x14CreateSandboxRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x05 \x01(\tR\tsandboxId\x12\x1a\n" + "sandbox_id\x18\x05 \x01(\tR\tsandboxId\x12\x1a\n" +
@ -2279,7 +2289,9 @@ const file_hostagent_proto_rawDesc = "" +
"\x05vcpus\x18\x02 \x01(\x05R\x05vcpus\x12\x1b\n" + "\x05vcpus\x18\x02 \x01(\x05R\x05vcpus\x12\x1b\n" +
"\tmemory_mb\x18\x03 \x01(\x05R\bmemoryMb\x12\x1f\n" + "\tmemory_mb\x18\x03 \x01(\x05R\bmemoryMb\x12\x1f\n" +
"\vtimeout_sec\x18\x04 \x01(\x05R\n" + "\vtimeout_sec\x18\x04 \x01(\x05R\n" +
"timeoutSec\"g\n" + "timeoutSec\x12 \n" +
"\fdisk_size_mb\x18\x06 \x01(\x05R\n" +
"diskSizeMb\"g\n" +
"\x15CreateSandboxResponse\x12\x1d\n" + "\x15CreateSandboxResponse\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +

View File

@ -85,6 +85,10 @@ message CreateSandboxRequest {
// TTL in seconds. Sandbox is auto-paused after this duration of // TTL in seconds. Sandbox is auto-paused after this duration of
// inactivity. 0 means no auto-pause. // inactivity. 0 means no auto-pause.
int32 timeout_sec = 4; int32 timeout_sec = 4;
// Disk size in MB for the sparse CoW file. Limits how much data the
// sandbox can write beyond the base image. Default: 20480 (20 GB).
int32 disk_size_mb = 6;
} }
message CreateSandboxResponse { message CreateSandboxResponse {