1
0
forked from wrenn/wrenn

Add UUID-based template IDs and team-scoped template directory layout

Introduces internal/layout package for centralized path construction,
migrates templates from name-based TEXT primary keys to UUID PKs with
team-scoped directories (WRENN_DIR/images/teams/{team_id}/{template_id}).
The built-in minimal template uses sentinel zero UUIDs. Proto messages
carry team_id + template_id alongside deprecated template name field.
Team deletion now cleans up template files across all hosts.
This commit is contained in:
2026-03-29 00:30:10 +06:00
parent 03e96629c7
commit 75b28ed899
24 changed files with 1057 additions and 322 deletions

View File

@ -0,0 +1,64 @@
-- +goose Up
-- 1. Add UUID id column to templates and make it the primary key.
ALTER TABLE templates ADD COLUMN id UUID DEFAULT gen_random_uuid();
UPDATE templates SET id = gen_random_uuid() WHERE id IS NULL;
ALTER TABLE templates ALTER COLUMN id SET NOT NULL;
ALTER TABLE templates DROP CONSTRAINT templates_pkey;
ALTER TABLE templates ADD PRIMARY KEY (id);
-- 2. Name becomes a display field with team-scoped uniqueness.
ALTER TABLE templates ADD CONSTRAINT uq_templates_team_name UNIQUE (team_id, name);
-- 3. Prevent team templates from using names that belong to global (platform) templates.
-- A team template insert/update with a name matching any platform template is rejected.
CREATE OR REPLACE FUNCTION check_global_template_name_collision()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.team_id != '00000000-0000-0000-0000-000000000000' THEN
IF EXISTS (
SELECT 1 FROM templates
WHERE name = NEW.name
AND team_id = '00000000-0000-0000-0000-000000000000'
) THEN
RAISE EXCEPTION 'template name "%" is reserved by a global template', NEW.name
USING ERRCODE = 'unique_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_check_global_template_name
BEFORE INSERT OR UPDATE ON templates
FOR EACH ROW
EXECUTE FUNCTION check_global_template_name_collision();
-- 4. Add template UUID references to template_builds.
ALTER TABLE template_builds
ADD COLUMN template_id UUID,
ADD COLUMN team_id UUID;
-- 5. Add template UUID references to sandboxes.
ALTER TABLE sandboxes
ADD COLUMN template_id UUID,
ADD COLUMN template_team_id UUID;
-- +goose Down
ALTER TABLE sandboxes
DROP COLUMN IF EXISTS template_team_id,
DROP COLUMN IF EXISTS template_id;
ALTER TABLE template_builds
DROP COLUMN IF EXISTS team_id,
DROP COLUMN IF EXISTS template_id;
DROP TRIGGER IF EXISTS trg_check_global_template_name ON templates;
DROP FUNCTION IF EXISTS check_global_template_name_collision();
ALTER TABLE templates DROP CONSTRAINT IF EXISTS uq_templates_team_name;
ALTER TABLE templates DROP CONSTRAINT IF EXISTS templates_pkey;
ALTER TABLE templates ADD PRIMARY KEY (name);
ALTER TABLE templates DROP COLUMN IF EXISTS id;

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, disk_size_mb) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, template_id, template_team_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *; RETURNING *;
-- name: GetSandbox :one -- name: GetSandbox :one

View File

@ -1,6 +1,6 @@
-- name: InsertTemplateBuild :one -- name: InsertTemplateBuild :one
INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps) INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps, template_id, team_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10)
RETURNING *; RETURNING *;
-- name: GetTemplateBuild :one -- name: GetTemplateBuild :one

View File

@ -1,15 +1,23 @@
-- name: InsertTemplate :one -- name: InsertTemplate :one
INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *; RETURNING *;
-- name: GetTemplate :one -- name: GetTemplate :one
SELECT * FROM templates WHERE name = $1; SELECT * FROM templates WHERE id = $1;
-- name: GetTemplateByTeam :one -- name: GetTemplateByTeam :one
-- Platform templates (team_id = 00000000-...) are visible to all teams. -- 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'); SELECT * FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000');
-- name: GetTemplateByName :one
-- Look up a template by team_id and name (exact team match, no global fallback).
SELECT * FROM templates WHERE team_id = $1 AND name = $2;
-- name: GetPlatformTemplateByName :one
-- Check if a global (platform) template exists with the given name.
SELECT * FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1;
-- name: ListTemplates :many -- name: ListTemplates :many
SELECT * FROM templates ORDER BY created_at DESC; SELECT * FROM templates ORDER BY created_at DESC;
@ -25,7 +33,15 @@ SELECT * FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-000
SELECT * FROM templates WHERE (team_id = $1 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC; 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 id = $1;
-- name: DeleteTemplateByTeam :exec -- name: DeleteTemplateByTeam :exec
DELETE FROM templates WHERE name = $1 AND team_id = $2; DELETE FROM templates WHERE name = $1 AND team_id = $2;
-- name: DeleteTemplatesByTeam :exec
-- Bulk delete all templates owned by a team (for team soft-delete cleanup).
DELETE FROM templates WHERE team_id = $1;
-- name: ListTemplatesByTeamOnly :many
-- List templates owned by a specific team (NOT including platform templates).
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC;

View File

@ -180,13 +180,13 @@ func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
} }
type templateResponse struct { type templateResponse struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
VCPUs int32 `json:"vcpus"` VCPUs int32 `json:"vcpus"`
MemoryMB int32 `json:"memory_mb"` MemoryMB int32 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
TeamID string `json:"team_id"` TeamID string `json:"team_id"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
resp := make([]templateResponse, len(templates)) resp := make([]templateResponse, len(templates))
@ -216,7 +216,8 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
} }
ctx := r.Context() ctx := r.Context()
if _, err := h.db.GetTemplate(ctx, name); err != nil { tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "template not found") writeError(w, http.StatusNotFound, "not_found", "template not found")
return return
} }
@ -231,14 +232,17 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
continue continue
} }
if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{Name: name})); err != nil { if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{
TeamId: formatUUIDForRPC(tmpl.TeamID),
TemplateId: formatUUIDForRPC(tmpl.ID),
})); err != nil {
if connect.CodeOf(err) != connect.CodeNotFound { if connect.CodeOf(err) != connect.CodeNotFound {
slog.Warn("admin: failed to delete template on host", "host_id", id.FormatHostID(host.ID), "name", name, "error", err) slog.Warn("admin: failed to delete template on host", "host_id", id.FormatHostID(host.ID), "name", name, "error", err)
} }
} }
} }
if err := h.db.DeleteTemplate(ctx, name); err != nil { if err := h.db.DeleteTemplate(ctx, tmpl.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record") writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
return return
} }

View File

@ -11,6 +11,8 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
@ -34,8 +36,8 @@ func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, pool *life
// deleteSnapshotBroadcast attempts to delete snapshot files on all online hosts. // deleteSnapshotBroadcast attempts to delete snapshot files on all online hosts.
// Snapshots aren't currently host-tracked in the DB, so we broadcast to all hosts // Snapshots aren't currently host-tracked in the DB, so we broadcast to all hosts
// and ignore NotFound errors. TODO: add host_id to templates table. // and ignore NotFound errors.
func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, name string) error { func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, teamID, templateID pgtype.UUID) error {
hosts, err := h.db.ListActiveHosts(ctx) hosts, err := h.db.ListActiveHosts(ctx)
if err != nil { if err != nil {
return fmt.Errorf("list hosts: %w", err) return fmt.Errorf("list hosts: %w", err)
@ -48,9 +50,12 @@ func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, name stri
if err != nil { if err != nil {
continue continue
} }
if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{Name: name})); err != nil { if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{
TeamId: formatUUIDForRPC(teamID),
TemplateId: formatUUIDForRPC(templateID),
})); err != nil {
if connect.CodeOf(err) != connect.CodeNotFound { if connect.CodeOf(err) != connect.CodeNotFound {
slog.Warn("snapshot: failed to delete on host", "host_id", id.FormatHostID(host.ID), "name", name, "error", err) slog.Warn("snapshot: failed to delete on host", "host_id", id.FormatHostID(host.ID), "error", err)
} }
} }
} }
@ -122,14 +127,20 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(ctx) ac := auth.MustFromContext(ctx)
overwrite := r.URL.Query().Get("overwrite") == "true" overwrite := r.URL.Query().Get("overwrite") == "true"
// Check for global name collision.
if _, err := h.db.GetPlatformTemplateByName(ctx, req.Name); err == nil {
writeError(w, http.StatusConflict, "name_reserved", "template name is reserved by a global template")
return
}
// Check if name already exists for this team. // Check if name already exists for this team.
if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err == nil { if existing, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err == nil {
if !overwrite { if !overwrite {
writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace") writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace")
return return
} }
// Delete old snapshot files from all hosts before removing the DB record. // Delete old snapshot files from all hosts before removing the DB record.
if err := h.deleteSnapshotBroadcast(ctx, req.Name); err != nil { if err := h.deleteSnapshotBroadcast(ctx, existing.TeamID, existing.ID); err != nil {
writeError(w, http.StatusInternalServerError, "agent_error", "failed to delete existing snapshot files") writeError(w, http.StatusInternalServerError, "agent_error", "failed to delete existing snapshot files")
return return
} }
@ -174,9 +185,14 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
snapCtx, snapCancel := context.WithTimeout(context.Background(), 5*time.Minute) snapCtx, snapCancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer snapCancel() defer snapCancel()
// Generate the new template ID upfront so the host agent knows where to store files.
newTemplateID := id.NewTemplateID()
resp, err := agent.CreateSnapshot(snapCtx, connect.NewRequest(&pb.CreateSnapshotRequest{ resp, err := agent.CreateSnapshot(snapCtx, connect.NewRequest(&pb.CreateSnapshotRequest{
SandboxId: req.SandboxID, SandboxId: req.SandboxID,
Name: req.Name, Name: req.Name,
TeamId: formatUUIDForRPC(ac.TeamID),
TemplateId: formatUUIDForRPC(newTemplateID),
})) }))
if err != nil { if err != nil {
// Snapshot failed — revert status back to what it was. // Snapshot failed — revert status back to what it was.
@ -193,6 +209,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
tmpl, err := h.db.InsertTemplate(snapCtx, db.InsertTemplateParams{ tmpl, err := h.db.InsertTemplate(snapCtx, db.InsertTemplateParams{
ID: newTemplateID,
Name: req.Name, Name: req.Name,
Type: "snapshot", Type: "snapshot",
Vcpus: sb.Vcpus, Vcpus: sb.Vcpus,
@ -255,7 +272,7 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.deleteSnapshotBroadcast(ctx, name); err != nil { if err := h.deleteSnapshotBroadcast(ctx, tmpl.TeamID, tmpl.ID); err != nil {
writeError(w, http.StatusInternalServerError, "agent_error", "failed to delete snapshot files") writeError(w, http.StatusInternalServerError, "agent_error", "failed to delete snapshot files")
return return
} }

View File

@ -12,6 +12,9 @@ import (
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/id"
) )
type errorResponse struct { type errorResponse struct {
@ -35,6 +38,11 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
}) })
} }
// formatUUIDForRPC converts a pgtype.UUID to a hex string for RPC messages.
func formatUUIDForRPC(u pgtype.UUID) string {
return id.UUIDString(u)
}
// agentErrToHTTP maps a Connect RPC error to an HTTP status, error code, and message. // agentErrToHTTP maps a Connect RPC error to an HTTP status, error code, and message.
func agentErrToHTTP(err error) (int, string, string) { func agentErrToHTTP(err error) (int, string, string) {
switch connect.CodeOf(err) { switch connect.CodeOf(err) {

View File

@ -83,21 +83,23 @@ type OauthProvider struct {
} }
type Sandbox struct { type Sandbox struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
HostID pgtype.UUID `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Template string `json:"template"` Template string `json:"template"`
Status string `json:"status"` Status string `json:"status"`
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"` 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"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
LastActiveAt pgtype.Timestamptz `json:"last_active_at"` LastActiveAt pgtype.Timestamptz `json:"last_active_at"`
LastUpdated pgtype.Timestamptz `json:"last_updated"` LastUpdated pgtype.Timestamptz `json:"last_updated"`
TemplateID pgtype.UUID `json:"template_id"`
TemplateTeamID pgtype.UUID `json:"template_team_id"`
} }
type SandboxMetricPoint struct { type SandboxMetricPoint struct {
@ -146,6 +148,7 @@ type Template struct {
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
ID pgtype.UUID `json:"id"`
} }
type TemplateBuild struct { type TemplateBuild struct {
@ -166,6 +169,8 @@ type TemplateBuild struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"`
TemplateID pgtype.UUID `json:"template_id"`
TeamID pgtype.UUID `json:"team_id"`
} }
type User struct { type User struct {

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, disk_size_mb, 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, template_id, template_team_id 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) {
@ -65,12 +65,14 @@ func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, erro
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
) )
return i, err return i, err
} }
const getSandboxByTeam = `-- name: GetSandboxByTeam :one const getSandboxByTeam = `-- name: GetSandboxByTeam :one
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 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, template_id, template_team_id FROM sandboxes WHERE id = $1 AND team_id = $2
` `
type GetSandboxByTeamParams struct { type GetSandboxByTeamParams struct {
@ -97,26 +99,30 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
) )
return i, err return i, err
} }
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, disk_size_mb) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, template_id, template_team_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
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 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, template_id, template_team_id
` `
type InsertSandboxParams struct { type InsertSandboxParams struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
TeamID pgtype.UUID `json:"team_id"` TeamID pgtype.UUID `json:"team_id"`
HostID pgtype.UUID `json:"host_id"` HostID pgtype.UUID `json:"host_id"`
Template string `json:"template"` Template string `json:"template"`
Status string `json:"status"` Status string `json:"status"`
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"` DiskSizeMb int32 `json:"disk_size_mb"`
TemplateID pgtype.UUID `json:"template_id"`
TemplateTeamID pgtype.UUID `json:"template_team_id"`
} }
func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) { func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) {
@ -130,6 +136,8 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
arg.MemoryMb, arg.MemoryMb,
arg.TimeoutSec, arg.TimeoutSec,
arg.DiskSizeMb, arg.DiskSizeMb,
arg.TemplateID,
arg.TemplateTeamID,
) )
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
@ -148,12 +156,14 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
) )
return i, err return i, err
} }
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
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 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, template_id, template_team_id 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
` `
@ -183,6 +193,8 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -195,7 +207,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, disk_size_mb, 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, template_id, template_team_id 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) {
@ -223,6 +235,8 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -235,7 +249,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, disk_size_mb, 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, template_id, template_team_id 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
` `
@ -270,6 +284,8 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -282,7 +298,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, disk_size_mb, 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, template_id, template_team_id 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
` `
@ -312,6 +328,8 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) (
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -364,7 +382,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, disk_size_mb, 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, template_id, template_team_id
` `
type UpdateSandboxRunningParams struct { type UpdateSandboxRunningParams struct {
@ -398,6 +416,8 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
) )
return i, err return i, err
} }
@ -407,7 +427,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, disk_size_mb, 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, template_id, template_team_id
` `
type UpdateSandboxStatusParams struct { type UpdateSandboxStatusParams struct {
@ -434,6 +454,8 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TemplateID,
&i.TemplateTeamID,
) )
return i, err return i, err
} }

View File

@ -12,7 +12,7 @@ import (
) )
const getTemplateBuild = `-- name: GetTemplateBuild :one const getTemplateBuild = `-- name: GetTemplateBuild :one
SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at FROM template_builds WHERE id = $1 SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id FROM template_builds WHERE id = $1
` `
func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) { func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (TemplateBuild, error) {
@ -36,14 +36,16 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat
&i.CreatedAt, &i.CreatedAt,
&i.StartedAt, &i.StartedAt,
&i.CompletedAt, &i.CompletedAt,
&i.TemplateID,
&i.TeamID,
) )
return i, err return i, err
} }
const insertTemplateBuild = `-- name: InsertTemplateBuild :one const insertTemplateBuild = `-- name: InsertTemplateBuild :one
INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps) INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps, template_id, team_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10)
RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id
` `
type InsertTemplateBuildParams struct { type InsertTemplateBuildParams struct {
@ -55,6 +57,8 @@ type InsertTemplateBuildParams struct {
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
MemoryMb int32 `json:"memory_mb"` MemoryMb int32 `json:"memory_mb"`
TotalSteps int32 `json:"total_steps"` TotalSteps int32 `json:"total_steps"`
TemplateID pgtype.UUID `json:"template_id"`
TeamID pgtype.UUID `json:"team_id"`
} }
func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBuildParams) (TemplateBuild, error) { func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBuildParams) (TemplateBuild, error) {
@ -67,6 +71,8 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui
arg.Vcpus, arg.Vcpus,
arg.MemoryMb, arg.MemoryMb,
arg.TotalSteps, arg.TotalSteps,
arg.TemplateID,
arg.TeamID,
) )
var i TemplateBuild var i TemplateBuild
err := row.Scan( err := row.Scan(
@ -87,12 +93,14 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui
&i.CreatedAt, &i.CreatedAt,
&i.StartedAt, &i.StartedAt,
&i.CompletedAt, &i.CompletedAt,
&i.TemplateID,
&i.TeamID,
) )
return i, err return i, err
} }
const listTemplateBuilds = `-- name: ListTemplateBuilds :many const listTemplateBuilds = `-- name: ListTemplateBuilds :many
SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at FROM template_builds ORDER BY created_at DESC SELECT id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id FROM template_builds ORDER BY created_at DESC
` `
func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, error) { func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, error) {
@ -122,6 +130,8 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro
&i.CreatedAt, &i.CreatedAt,
&i.StartedAt, &i.StartedAt,
&i.CompletedAt, &i.CompletedAt,
&i.TemplateID,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -189,7 +199,7 @@ SET status = $2,
started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END, started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END,
completed_at = CASE WHEN $2 IN ('success', 'failed') THEN NOW() ELSE completed_at END completed_at = CASE WHEN $2 IN ('success', 'failed') THEN NOW() ELSE completed_at END
WHERE id = $1 WHERE id = $1
RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at RETURNING id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, current_step, total_steps, logs, error, sandbox_id, host_id, created_at, started_at, completed_at, template_id, team_id
` `
type UpdateBuildStatusParams struct { type UpdateBuildStatusParams struct {
@ -218,6 +228,8 @@ func (q *Queries) UpdateBuildStatus(ctx context.Context, arg UpdateBuildStatusPa
&i.CreatedAt, &i.CreatedAt,
&i.StartedAt, &i.StartedAt,
&i.CompletedAt, &i.CompletedAt,
&i.TemplateID,
&i.TeamID,
) )
return i, err return i, err
} }

View File

@ -12,11 +12,11 @@ import (
) )
const deleteTemplate = `-- name: DeleteTemplate :exec const deleteTemplate = `-- name: DeleteTemplate :exec
DELETE FROM templates WHERE name = $1 DELETE FROM templates WHERE id = $1
` `
func (q *Queries) DeleteTemplate(ctx context.Context, name string) error { func (q *Queries) DeleteTemplate(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTemplate, name) _, err := q.db.Exec(ctx, deleteTemplate, id)
return err return err
} }
@ -34,12 +34,23 @@ func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateBy
return err return err
} }
const getTemplate = `-- name: GetTemplate :one const deleteTemplatesByTeam = `-- name: DeleteTemplatesByTeam :exec
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 DELETE FROM templates WHERE team_id = $1
` `
func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error) { // Bulk delete all templates owned by a team (for team soft-delete cleanup).
row := q.db.QueryRow(ctx, getTemplate, name) func (q *Queries) DeleteTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTemplatesByTeam, teamID)
return err
}
const getPlatformTemplateByName = `-- name: GetPlatformTemplateByName :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1
`
// Check if a global (platform) template exists with the given name.
func (q *Queries) GetPlatformTemplateByName(ctx context.Context, name string) (Template, error) {
row := q.db.QueryRow(ctx, getPlatformTemplateByName, name)
var i Template var i Template
err := row.Scan( err := row.Scan(
&i.Name, &i.Name,
@ -49,12 +60,59 @@ func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
)
return i, err
}
const getTemplate = `-- name: GetTemplate :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE id = $1
`
func (q *Queries) GetTemplate(ctx context.Context, id pgtype.UUID) (Template, error) {
row := q.db.QueryRow(ctx, getTemplate, id)
var i Template
err := row.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
&i.ID,
)
return i, err
}
const getTemplateByName = `-- name: GetTemplateByName :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE team_id = $1 AND name = $2
`
type GetTemplateByNameParams struct {
TeamID pgtype.UUID `json:"team_id"`
Name string `json:"name"`
}
// Look up a template by team_id and name (exact team match, no global fallback).
func (q *Queries) GetTemplateByName(ctx context.Context, arg GetTemplateByNameParams) (Template, error) {
row := q.db.QueryRow(ctx, getTemplateByName, arg.TeamID, arg.Name)
var i Template
err := row.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
&i.ID,
) )
return i, err return i, err
} }
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 OR team_id = '00000000-0000-0000-0000-000000000000') SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
` `
type GetTemplateByTeamParams struct { type GetTemplateByTeamParams struct {
@ -74,17 +132,19 @@ func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamPa
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
) )
return i, err return i, err
} }
const insertTemplate = `-- name: InsertTemplate :one const insertTemplate = `-- name: InsertTemplate :one
INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id
` `
type InsertTemplateParams struct { type InsertTemplateParams struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Vcpus int32 `json:"vcpus"` Vcpus int32 `json:"vcpus"`
@ -95,6 +155,7 @@ type InsertTemplateParams struct {
func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
row := q.db.QueryRow(ctx, insertTemplate, row := q.db.QueryRow(ctx, insertTemplate,
arg.ID,
arg.Name, arg.Name,
arg.Type, arg.Type,
arg.Vcpus, arg.Vcpus,
@ -111,12 +172,13 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
) )
return i, err return i, err
} }
const listTemplates = `-- name: ListTemplates :many const listTemplates = `-- name: ListTemplates :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates ORDER BY created_at DESC
` `
func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
@ -136,6 +198,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -148,7 +211,7 @@ 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 OR team_id = '00000000-0000-0000-0000-000000000000') ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, 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. // Platform templates are visible to all teams.
@ -169,6 +232,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) (
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -181,7 +245,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 OR team_id = '00000000-0000-0000-0000-000000000000') AND type = $2 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, 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 {
@ -207,6 +271,41 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTemplatesByTeamOnly = `-- name: ListTemplatesByTeamOnly :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE team_id = $1 ORDER BY created_at DESC
`
// List templates owned by a specific team (NOT including platform templates).
func (q *Queries) ListTemplatesByTeamOnly(ctx context.Context, teamID pgtype.UUID) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplatesByTeamOnly, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Template
for rows.Next() {
var i Template
if err := rows.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
&i.ID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -219,7 +318,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla
} }
const listTemplatesByType = `-- name: ListTemplatesByType :many const listTemplatesByType = `-- name: ListTemplatesByType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE type = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id FROM templates WHERE type = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) { func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) {
@ -239,6 +338,7 @@ func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Temp
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID, &i.TeamID,
&i.ID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@ -12,6 +12,8 @@ import (
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -33,13 +35,35 @@ func NewServer(mgr *sandbox.Manager, terminate func()) *Server {
return &Server{mgr: mgr, terminate: terminate} return &Server{mgr: mgr, terminate: terminate}
} }
// parseUUIDString parses a UUID hex string into a pgtype.UUID.
// An empty string yields an all-zeros UUID (valid).
func parseUUIDString(s string) (pgtype.UUID, error) {
if s == "" {
return pgtype.UUID{Bytes: [16]byte{}, Valid: true}, nil
}
parsed, err := uuid.Parse(s)
if err != nil {
return pgtype.UUID{}, fmt.Errorf("invalid UUID %q: %w", s, err)
}
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
}
func (s *Server) CreateSandbox( func (s *Server) CreateSandbox(
ctx context.Context, ctx context.Context,
req *connect.Request[pb.CreateSandboxRequest], req *connect.Request[pb.CreateSandboxRequest],
) (*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), int(msg.DiskSizeMb)) teamID, err := parseUUIDString(msg.TeamId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
templateID, err := parseUUIDString(msg.TemplateId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
sb, err := s.mgr.Create(ctx, msg.SandboxId, teamID, templateID, 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))
} }
@ -90,12 +114,22 @@ func (s *Server) CreateSnapshot(
ctx context.Context, ctx context.Context,
req *connect.Request[pb.CreateSnapshotRequest], req *connect.Request[pb.CreateSnapshotRequest],
) (*connect.Response[pb.CreateSnapshotResponse], error) { ) (*connect.Response[pb.CreateSnapshotResponse], error) {
sizeBytes, err := s.mgr.CreateSnapshot(ctx, req.Msg.SandboxId, req.Msg.Name) msg := req.Msg
teamID, err := parseUUIDString(msg.TeamId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
templateID, err := parseUUIDString(msg.TemplateId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
sizeBytes, err := s.mgr.CreateSnapshot(ctx, msg.SandboxId, teamID, templateID)
if err != nil { if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create snapshot: %w", err)) return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create snapshot: %w", err))
} }
return connect.NewResponse(&pb.CreateSnapshotResponse{ return connect.NewResponse(&pb.CreateSnapshotResponse{
Name: req.Msg.Name, Name: msg.Name,
SizeBytes: sizeBytes, SizeBytes: sizeBytes,
}), nil }), nil
} }
@ -104,7 +138,17 @@ func (s *Server) DeleteSnapshot(
ctx context.Context, ctx context.Context,
req *connect.Request[pb.DeleteSnapshotRequest], req *connect.Request[pb.DeleteSnapshotRequest],
) (*connect.Response[pb.DeleteSnapshotResponse], error) { ) (*connect.Response[pb.DeleteSnapshotResponse], error) {
if err := s.mgr.DeleteSnapshot(req.Msg.Name); err != nil { msg := req.Msg
teamID, err := parseUUIDString(msg.TeamId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
templateID, err := parseUUIDString(msg.TemplateId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
if err := s.mgr.DeleteSnapshot(teamID, templateID); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("delete snapshot: %w", err)) return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("delete snapshot: %w", err))
} }
return connect.NewResponse(&pb.DeleteSnapshotResponse{}), nil return connect.NewResponse(&pb.DeleteSnapshotResponse{}), nil
@ -114,7 +158,17 @@ func (s *Server) FlattenRootfs(
ctx context.Context, ctx context.Context,
req *connect.Request[pb.FlattenRootfsRequest], req *connect.Request[pb.FlattenRootfsRequest],
) (*connect.Response[pb.FlattenRootfsResponse], error) { ) (*connect.Response[pb.FlattenRootfsResponse], error) {
sizeBytes, err := s.mgr.FlattenRootfs(ctx, req.Msg.SandboxId, req.Msg.Name) msg := req.Msg
teamID, err := parseUUIDString(msg.TeamId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
templateID, err := parseUUIDString(msg.TemplateId)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
sizeBytes, err := s.mgr.FlattenRootfs(ctx, msg.SandboxId, teamID, templateID)
if err != nil { if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("flatten rootfs: %w", err)) return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("flatten rootfs: %w", err))
} }
@ -413,7 +467,8 @@ func (s *Server) ListSandboxes(
infos[i] = &pb.SandboxInfo{ infos[i] = &pb.SandboxInfo{
SandboxId: sb.ID, SandboxId: sb.ID,
Status: string(sb.Status), Status: string(sb.Status),
Template: sb.Template, TeamId: uuid.UUID(sb.TemplateTeamID).String(),
TemplateId: uuid.UUID(sb.TemplateID).String(),
Vcpus: int32(sb.VCPUs), Vcpus: int32(sb.VCPUs),
MemoryMb: int32(sb.MemoryMB), MemoryMb: int32(sb.MemoryMB),
HostIp: sb.HostIP.String(), HostIp: sb.HostIP.String(),

View File

@ -36,8 +36,9 @@ func NewAuditLogID() pgtype.UUID { return newUUID() }
func NewBuildID() pgtype.UUID { return newUUID() } func NewBuildID() pgtype.UUID { return newUUID() }
func NewAdminPermissionID() pgtype.UUID { return newUUID() } func NewAdminPermissionID() pgtype.UUID { return newUUID() }
func NewTemplateID() pgtype.UUID { return newUUID() }
// NewSnapshotName generates a snapshot name: "template-" + 8 hex chars. // NewSnapshotName generates a snapshot name: "template-" + 8 hex chars.
// Templates use TEXT primary keys (not UUID), so this stays as a string.
func NewSnapshotName() string { func NewSnapshotName() string {
return "template-" + hex8() return "template-" + hex8()
} }
@ -76,8 +77,8 @@ const (
PrefixAdminPermission = "perm-" PrefixAdminPermission = "perm-"
) )
// uuidToBase36 encodes 16 UUID bytes as a 25-char base36 string (0-9a-z). // UUIDToBase36 encodes 16 UUID bytes as a 25-char base36 string (0-9a-z).
func uuidToBase36(b [16]byte) string { func UUIDToBase36(b [16]byte) string {
n := new(big.Int).SetBytes(b[:]) n := new(big.Int).SetBytes(b[:])
buf := make([]byte, base36IDLen) buf := make([]byte, base36IDLen)
mod := new(big.Int) mod := new(big.Int)
@ -110,7 +111,7 @@ func base36ToUUID(s string) ([16]byte, error) {
} }
func formatUUID(prefix string, id pgtype.UUID) string { func formatUUID(prefix string, id pgtype.UUID) string {
return prefix + uuidToBase36(id.Bytes) return prefix + UUIDToBase36(id.Bytes)
} }
func FormatSandboxID(id pgtype.UUID) string { return formatUUID(PrefixSandbox, id) } func FormatSandboxID(id pgtype.UUID) string { return formatUUID(PrefixSandbox, id) }
@ -151,6 +152,17 @@ func ParseBuildID(s string) (pgtype.UUID, error) { return parseUUID(PrefixBu
// (e.g. base templates, shared infrastructure). // (e.g. base templates, shared infrastructure).
var PlatformTeamID = pgtype.UUID{Bytes: [16]byte{}, Valid: true} var PlatformTeamID = pgtype.UUID{Bytes: [16]byte{}, Valid: true}
// MinimalTemplateID is the all-zeros UUID sentinel for the built-in "minimal"
// template. When both team_id and template_id are zero, the host agent uses
// the minimal rootfs at WRENN_DIR/images/minimal/.
var MinimalTemplateID = pgtype.UUID{Bytes: [16]byte{}, Valid: true}
// UUIDString converts a pgtype.UUID to a standard hyphenated UUID string
// (e.g., "6ba7b810-9dad-11d1-80b4-00c04fd430c8"). Used for RPC wire format.
func UUIDString(id pgtype.UUID) string {
return uuid.UUID(id.Bytes).String()
}
// --- Helpers --- // --- Helpers ---
func hex8() string { func hex8() string {

View File

@ -10,7 +10,7 @@ import (
func TestBase36RoundTrip(t *testing.T) { func TestBase36RoundTrip(t *testing.T) {
for i := 0; i < 1000; i++ { for i := 0; i < 1000; i++ {
orig := uuid.New() orig := uuid.New()
encoded := uuidToBase36(orig) encoded := UUIDToBase36(orig)
if len(encoded) != base36IDLen { if len(encoded) != base36IDLen {
t.Fatalf("expected %d chars, got %d: %s", base36IDLen, len(encoded), encoded) t.Fatalf("expected %d chars, got %d: %s", base36IDLen, len(encoded), encoded)
@ -29,7 +29,7 @@ func TestBase36RoundTrip(t *testing.T) {
func TestBase36ZeroUUID(t *testing.T) { func TestBase36ZeroUUID(t *testing.T) {
var zero [16]byte var zero [16]byte
encoded := uuidToBase36(zero) encoded := UUIDToBase36(zero)
if encoded != "0000000000000000000000000" { if encoded != "0000000000000000000000000" {
t.Fatalf("zero UUID should encode to all zeros, got %s", encoded) t.Fatalf("zero UUID should encode to all zeros, got %s", encoded)
} }
@ -87,7 +87,7 @@ func TestPlatformTeamIDFormats(t *testing.T) {
func TestMaxUUID(t *testing.T) { func TestMaxUUID(t *testing.T) {
max := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, max := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
encoded := uuidToBase36(max) encoded := UUIDToBase36(max)
if len(encoded) != base36IDLen { if len(encoded) != base36IDLen {
t.Fatalf("max UUID encoding wrong length: %d", len(encoded)) t.Fatalf("max UUID encoding wrong length: %d", len(encoded))
} }

58
internal/layout/layout.go Normal file
View File

@ -0,0 +1,58 @@
package layout
import (
"path/filepath"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
// IsMinimal reports whether the given team and template IDs represent the
// built-in "minimal" template (both all-zeros).
func IsMinimal(teamID, templateID pgtype.UUID) bool {
return teamID.Bytes == id.PlatformTeamID.Bytes && templateID.Bytes == id.MinimalTemplateID.Bytes
}
// TemplateDir returns the on-disk directory for a template.
//
// minimal (zeros, zeros): {wrennDir}/images/minimal
// all others: {wrennDir}/images/teams/{base36(teamID)}/{base36(templateID)}
func TemplateDir(wrennDir string, teamID, templateID pgtype.UUID) string {
if IsMinimal(teamID, templateID) {
return filepath.Join(wrennDir, "images", "minimal")
}
return filepath.Join(wrennDir, "images", "teams",
id.UUIDToBase36(teamID.Bytes),
id.UUIDToBase36(templateID.Bytes))
}
// TemplateRootfs returns the path to a template's rootfs.ext4.
func TemplateRootfs(wrennDir string, teamID, templateID pgtype.UUID) string {
return filepath.Join(TemplateDir(wrennDir, teamID, templateID), "rootfs.ext4")
}
// PauseSnapshotDir returns the directory for a paused sandbox's snapshot files.
func PauseSnapshotDir(wrennDir, sandboxID string) string {
return filepath.Join(wrennDir, "snapshots", sandboxID)
}
// SandboxesDir returns the directory for running sandbox CoW files.
func SandboxesDir(wrennDir string) string {
return filepath.Join(wrennDir, "sandboxes")
}
// KernelPath returns the path to the Firecracker kernel.
func KernelPath(wrennDir string) string {
return filepath.Join(wrennDir, "kernels", "vmlinux")
}
// ImagesRoot returns the root images directory.
func ImagesRoot(wrennDir string) string {
return filepath.Join(wrennDir, "images")
}
// TeamsDir returns the directory containing all team template subdirectories.
func TeamsDir(wrennDir string) string {
return filepath.Join(wrennDir, "images", "teams")
}

View File

@ -0,0 +1,120 @@
package layout
import (
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
func TestIsMinimal(t *testing.T) {
tests := []struct {
name string
teamID pgtype.UUID
templateID pgtype.UUID
want bool
}{
{
name: "both zeros",
teamID: id.PlatformTeamID,
templateID: id.MinimalTemplateID,
want: true,
},
{
name: "non-zero team",
teamID: pgtype.UUID{Bytes: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, Valid: true},
templateID: id.MinimalTemplateID,
want: false,
},
{
name: "non-zero template",
teamID: id.PlatformTeamID,
templateID: pgtype.UUID{Bytes: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, Valid: true},
want: false,
},
{
name: "both non-zero",
teamID: pgtype.UUID{Bytes: [16]byte{1}, Valid: true},
templateID: pgtype.UUID{Bytes: [16]byte{2}, Valid: true},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsMinimal(tt.teamID, tt.templateID); got != tt.want {
t.Errorf("IsMinimal() = %v, want %v", got, tt.want)
}
})
}
}
func TestTemplateDir(t *testing.T) {
wrennDir := "/var/lib/wrenn"
t.Run("minimal", func(t *testing.T) {
got := TemplateDir(wrennDir, id.PlatformTeamID, id.MinimalTemplateID)
want := filepath.Join(wrennDir, "images", "minimal")
if got != want {
t.Errorf("TemplateDir() = %q, want %q", got, want)
}
})
t.Run("team template", func(t *testing.T) {
teamID := pgtype.UUID{Bytes: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, Valid: true}
tmplID := pgtype.UUID{Bytes: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, Valid: true}
got := TemplateDir(wrennDir, teamID, tmplID)
want := filepath.Join(wrennDir, "images", "teams",
id.UUIDToBase36(teamID.Bytes),
id.UUIDToBase36(tmplID.Bytes))
if got != want {
t.Errorf("TemplateDir() = %q, want %q", got, want)
}
})
t.Run("global template (platform team, non-zero template)", func(t *testing.T) {
tmplID := pgtype.UUID{Bytes: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5}, Valid: true}
got := TemplateDir(wrennDir, id.PlatformTeamID, tmplID)
want := filepath.Join(wrennDir, "images", "teams",
id.UUIDToBase36(id.PlatformTeamID.Bytes),
id.UUIDToBase36(tmplID.Bytes))
if got != want {
t.Errorf("TemplateDir() = %q, want %q", got, want)
}
})
}
func TestTemplateRootfs(t *testing.T) {
wrennDir := "/var/lib/wrenn"
got := TemplateRootfs(wrennDir, id.PlatformTeamID, id.MinimalTemplateID)
want := filepath.Join(wrennDir, "images", "minimal", "rootfs.ext4")
if got != want {
t.Errorf("TemplateRootfs() = %q, want %q", got, want)
}
}
func TestPauseSnapshotDir(t *testing.T) {
got := PauseSnapshotDir("/var/lib/wrenn", "sb-abc123")
want := "/var/lib/wrenn/snapshots/sb-abc123"
if got != want {
t.Errorf("PauseSnapshotDir() = %q, want %q", got, want)
}
}
func TestSandboxesDir(t *testing.T) {
got := SandboxesDir("/var/lib/wrenn")
want := "/var/lib/wrenn/sandboxes"
if got != want {
t.Errorf("SandboxesDir() = %q, want %q", got, want)
}
}
func TestKernelPath(t *testing.T) {
got := KernelPath("/var/lib/wrenn")
want := "/var/lib/wrenn/kernels/vmlinux"
if got != want {
t.Errorf("KernelPath() = %q, want %q", got, want)
}
}

View File

@ -18,15 +18,16 @@ const (
// Sandbox holds all state for a running sandbox on this host. // Sandbox holds all state for a running sandbox on this host.
type Sandbox struct { type Sandbox struct {
ID string ID string
Status SandboxStatus Status SandboxStatus
Template string TemplateTeamID [16]byte
VCPUs int TemplateID [16]byte
MemoryMB int VCPUs int
TimeoutSec int MemoryMB int
SlotIndex int TimeoutSec int
HostIP net.IP SlotIndex int
RootfsPath string HostIP net.IP
CreatedAt time.Time RootfsPath string
LastActiveAt time.Time CreatedAt time.Time
LastActiveAt time.Time
} }

View File

@ -6,6 +6,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/layout"
) )
// DefaultDiskSizeMB is the standard disk size for base images. Images smaller // DefaultDiskSizeMB is the standard disk size for base images. Images smaller
@ -14,61 +17,90 @@ import (
// changes; no physical disk is consumed beyond the original content. // changes; no physical disk is consumed beyond the original content.
const DefaultDiskSizeMB = 5120 // 5 GB const DefaultDiskSizeMB = 5120 // 5 GB
// EnsureImageSizes walks the images directory and expands any rootfs.ext4 that // EnsureImageSizes walks template directories and expands any rootfs.ext4 that
// is smaller than the target size. This is idempotent: images already at or // 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 // above the target size are left untouched. Should be called once at host agent
// startup before any sandboxes are created. // startup before any sandboxes are created.
func EnsureImageSizes(imagesDir string, targetMB int) error { func EnsureImageSizes(wrennDir string, targetMB int) error {
if targetMB <= 0 { if targetMB <= 0 {
targetMB = DefaultDiskSizeMB targetMB = DefaultDiskSizeMB
} }
targetBytes := int64(targetMB) * 1024 * 1024 targetBytes := int64(targetMB) * 1024 * 1024
entries, err := os.ReadDir(imagesDir) // Expand the built-in minimal image.
if err != nil { minimalRootfs := layout.TemplateRootfs(wrennDir, id.PlatformTeamID, id.MinimalTemplateID)
return fmt.Errorf("read images dir: %w", err) if err := expandImage(minimalRootfs, targetBytes, targetMB); err != nil {
return err
} }
for _, entry := range entries { // Walk teams/{teamDir}/{templateDir}/rootfs.ext4 two levels deep.
if !entry.IsDir() { teamsDir := layout.TeamsDir(wrennDir)
teamEntries, err := os.ReadDir(teamsDir)
if err != nil {
if os.IsNotExist(err) {
return nil // teams dir doesn't exist yet — nothing to expand
}
return fmt.Errorf("read teams dir: %w", err)
}
for _, teamEntry := range teamEntries {
if !teamEntry.IsDir() {
continue continue
} }
rootfs := filepath.Join(imagesDir, entry.Name(), "rootfs.ext4") teamPath := filepath.Join(teamsDir, teamEntry.Name())
info, err := os.Stat(rootfs) templateEntries, err := os.ReadDir(teamPath)
if err != nil { if err != nil {
continue // not every template dir has a rootfs.ext4 continue
} }
for _, tmplEntry := range templateEntries {
if info.Size() >= targetBytes { if !tmplEntry.IsDir() {
continue // already large enough continue
} }
rootfs := filepath.Join(teamPath, tmplEntry.Name(), "rootfs.ext4")
slog.Info("expanding base image", if err := expandImage(rootfs, targetBytes, targetMB); err != nil {
"template", entry.Name(), return err
"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 return nil
} }
// expandImage expands a single rootfs image if it is smaller than targetBytes.
func expandImage(rootfs string, targetBytes int64, targetMB int) error {
info, err := os.Stat(rootfs)
if err != nil {
return nil // not every template dir has a rootfs.ext4
}
if info.Size() >= targetBytes {
return nil // already large enough
}
slog.Info("expanding base image",
"path", rootfs,
"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", "path", rootfs, "size_mb", targetMB)
return nil
}

View File

@ -11,25 +11,23 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/devicemapper" "git.omukk.dev/wrenn/sandbox/internal/devicemapper"
"git.omukk.dev/wrenn/sandbox/internal/envdclient" "git.omukk.dev/wrenn/sandbox/internal/envdclient"
"git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/layout"
"git.omukk.dev/wrenn/sandbox/internal/models" "git.omukk.dev/wrenn/sandbox/internal/models"
"git.omukk.dev/wrenn/sandbox/internal/network" "git.omukk.dev/wrenn/sandbox/internal/network"
"git.omukk.dev/wrenn/sandbox/internal/snapshot" "git.omukk.dev/wrenn/sandbox/internal/snapshot"
"git.omukk.dev/wrenn/sandbox/internal/uffd" "git.omukk.dev/wrenn/sandbox/internal/uffd"
"git.omukk.dev/wrenn/sandbox/internal/validate"
"git.omukk.dev/wrenn/sandbox/internal/vm" "git.omukk.dev/wrenn/sandbox/internal/vm"
) )
// Config holds the paths and defaults for the sandbox manager. // Config holds the paths and defaults for the sandbox manager.
type Config struct { type Config struct {
KernelPath string WrennDir string // root directory (e.g. /var/lib/wrenn); all sub-paths derived via layout package
ImagesDir string // directory containing template images (e.g., /var/lib/wrenn/images/{name}/rootfs.ext4) EnvdTimeout time.Duration
SandboxesDir string // directory for per-sandbox rootfs clones (e.g., /var/lib/wrenn/sandboxes)
SnapshotsDir string // directory for pause snapshots (e.g., /var/lib/wrenn/snapshots/{sandbox-id}/)
EnvdTimeout time.Duration
} }
// Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd. // Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd.
@ -52,8 +50,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
@ -95,7 +93,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, diskSizeMB int) (*models.Sandbox, error) { func (m *Manager) Create(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID, vcpus, memoryMB, timeoutSec, diskSizeMB int) (*models.Sandbox, error) {
if sandboxID == "" { if sandboxID == "" {
sandboxID = id.FormatSandboxID(id.NewSandboxID()) sandboxID = id.FormatSandboxID(id.NewSandboxID())
} }
@ -110,20 +108,14 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
diskSizeMB = 5120 // 5 GB default diskSizeMB = 5120 // 5 GB default
} }
if template == "" {
template = "minimal"
}
if err := validate.SafeName(template); err != nil {
return nil, fmt.Errorf("invalid template name: %w", err)
}
// 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) { tmplDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID)
return m.createFromSnapshot(ctx, sandboxID, template, vcpus, memoryMB, timeoutSec, diskSizeMB) if _, err := os.Stat(filepath.Join(tmplDir, snapshot.SnapFileName)); err == nil {
return m.createFromSnapshot(ctx, sandboxID, teamID, templateID, vcpus, memoryMB, timeoutSec, diskSizeMB)
} }
// Resolve base rootfs image: /var/lib/wrenn/images/{template}/rootfs.ext4 // Resolve base rootfs image.
baseRootfs := filepath.Join(m.cfg.ImagesDir, template, "rootfs.ext4") baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID)
if _, err := os.Stat(baseRootfs); err != nil { if _, err := os.Stat(baseRootfs); err != nil {
return nil, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err) return nil, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
} }
@ -142,7 +134,7 @@ 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(layout.SandboxesDir(m.cfg.WrennDir), fmt.Sprintf("%s.cow", sandboxID))
cowSize := int64(diskSizeMB) * 1024 * 1024 cowSize := int64(diskSizeMB) * 1024 * 1024
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize) dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize)
if err != nil { if err != nil {
@ -172,7 +164,7 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
// Boot VM — Firecracker gets the dm device path. // Boot VM — Firecracker gets the dm device path.
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
SandboxID: sandboxID, SandboxID: sandboxID,
KernelPath: m.cfg.KernelPath, KernelPath: layout.KernelPath(m.cfg.WrennDir),
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
VCPUs: vcpus, VCPUs: vcpus,
MemoryMB: memoryMB, MemoryMB: memoryMB,
@ -211,17 +203,18 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
now := time.Now() now := time.Now()
sb := &sandboxState{ sb := &sandboxState{
Sandbox: models.Sandbox{ Sandbox: models.Sandbox{
ID: sandboxID, ID: sandboxID,
Status: models.StatusRunning, Status: models.StatusRunning,
Template: template, TemplateTeamID: teamID.Bytes,
VCPUs: vcpus, TemplateID: templateID.Bytes,
MemoryMB: memoryMB, VCPUs: vcpus,
TimeoutSec: timeoutSec, MemoryMB: memoryMB,
SlotIndex: slotIdx, TimeoutSec: timeoutSec,
HostIP: slot.HostIP, SlotIndex: slotIdx,
RootfsPath: dmDev.DevicePath, HostIP: slot.HostIP,
CreatedAt: now, RootfsPath: dmDev.DevicePath,
LastActiveAt: now, CreatedAt: now,
LastActiveAt: now,
}, },
slot: slot, slot: slot,
client: client, client: client,
@ -237,7 +230,8 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus,
slog.Info("sandbox created", slog.Info("sandbox created",
"id", sandboxID, "id", sandboxID,
"template", template, "team_id", teamID,
"template_id", templateID,
"host_ip", slot.HostIP.String(), "host_ip", slot.HostIP.String(),
"dm_device", dmDev.DevicePath, "dm_device", dmDev.DevicePath,
) )
@ -260,7 +254,9 @@ func (m *Manager) Destroy(ctx context.Context, sandboxID string) error {
} }
// Always clean up pause snapshot files (may exist if sandbox was paused). // Always clean up pause snapshot files (may exist if sandbox was paused).
warnErr("snapshot cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) if err := os.RemoveAll(layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)); err != nil {
slog.Warn("snapshot cleanup error", "id", sandboxID, "error", err)
}
slog.Info("sandbox destroyed", "id", sandboxID) slog.Info("sandbox destroyed", "id", sandboxID)
return nil return nil
@ -331,18 +327,18 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
} }
// Step 2: Take VM state snapshot (snapfile + memfile). // Step 2: Take VM state snapshot (snapfile + memfile).
if err := snapshot.EnsureDir(m.cfg.SnapshotsDir, sandboxID); err != nil { pauseDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)
if err := os.MkdirAll(pauseDir, 0755); err != nil {
resumeOnError() resumeOnError()
return fmt.Errorf("create snapshot dir: %w", err) return fmt.Errorf("create snapshot dir: %w", err)
} }
snapDir := snapshot.DirPath(m.cfg.SnapshotsDir, sandboxID) rawMemPath := filepath.Join(pauseDir, "memfile.raw")
rawMemPath := filepath.Join(snapDir, "memfile.raw") snapPath := filepath.Join(pauseDir, snapshot.SnapFileName)
snapPath := snapshot.SnapPath(m.cfg.SnapshotsDir, sandboxID)
snapshotStart := time.Now() snapshotStart := time.Now()
if err := m.vm.Snapshot(ctx, sandboxID, snapPath, rawMemPath, snapshotType); err != nil { if err := m.vm.Snapshot(ctx, sandboxID, snapPath, rawMemPath, snapshotType); err != nil {
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
resumeOnError() resumeOnError()
return fmt.Errorf("create VM snapshot: %w", err) return fmt.Errorf("create VM snapshot: %w", err)
} }
@ -350,24 +346,24 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
// Step 3: Process the raw memfile into a compact diff + header. // Step 3: Process the raw memfile into a compact diff + header.
buildID := uuid.New() buildID := uuid.New()
headerPath := snapshot.MemHeaderPath(m.cfg.SnapshotsDir, sandboxID) headerPath := filepath.Join(pauseDir, snapshot.MemHeaderName)
processStart := time.Now() processStart := time.Now()
if sb.parent != nil && snapshotType == "Diff" { if sb.parent != nil && snapshotType == "Diff" {
// Diff: process against parent header, producing only changed blocks. // Diff: process against parent header, producing only changed blocks.
diffPath := snapshot.MemDiffPathForBuild(m.cfg.SnapshotsDir, sandboxID, buildID) diffPath := snapshot.MemDiffPathForBuild(pauseDir, "", buildID)
if _, err := snapshot.ProcessMemfileWithParent(rawMemPath, diffPath, headerPath, sb.parent.header, buildID); err != nil { if _, err := snapshot.ProcessMemfileWithParent(rawMemPath, diffPath, headerPath, sb.parent.header, buildID); err != nil {
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
resumeOnError() resumeOnError()
return fmt.Errorf("process memfile with parent: %w", err) return fmt.Errorf("process memfile with parent: %w", err)
} }
// Copy previous generation diff files into the snapshot directory. // Copy previous generation diff files into the snapshot directory.
for prevBuildID, prevPath := range sb.parent.diffPaths { for prevBuildID, prevPath := range sb.parent.diffPaths {
dstPath := snapshot.MemDiffPathForBuild(m.cfg.SnapshotsDir, sandboxID, uuid.MustParse(prevBuildID)) dstPath := snapshot.MemDiffPathForBuild(pauseDir, "", uuid.MustParse(prevBuildID))
if prevPath != dstPath { if prevPath != dstPath {
if err := copyFile(prevPath, dstPath); err != nil { if err := copyFile(prevPath, dstPath); err != nil {
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
resumeOnError() resumeOnError()
return fmt.Errorf("copy parent diff file: %w", err) return fmt.Errorf("copy parent diff file: %w", err)
} }
@ -375,9 +371,9 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
} }
} else { } else {
// Full: first generation or generation cap reached — single diff file. // Full: first generation or generation cap reached — single diff file.
diffPath := snapshot.MemDiffPath(m.cfg.SnapshotsDir, sandboxID) diffPath := snapshot.MemDiffPath(pauseDir, "")
if _, err := snapshot.ProcessMemfile(rawMemPath, diffPath, headerPath, buildID); err != nil { if _, err := snapshot.ProcessMemfile(rawMemPath, diffPath, headerPath, buildID); err != nil {
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
resumeOnError() resumeOnError()
return fmt.Errorf("process memfile: %w", err) return fmt.Errorf("process memfile: %w", err)
} }
@ -407,7 +403,7 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
if sb.uffdSocketPath != "" { if sb.uffdSocketPath != "" {
os.Remove(sb.uffdSocketPath) os.Remove(sb.uffdSocketPath)
} }
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
m.mu.Lock() m.mu.Lock()
delete(m.boxes, sandboxID) delete(m.boxes, sandboxID)
m.mu.Unlock() m.mu.Unlock()
@ -415,9 +411,9 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
} }
// Move (not copy) the CoW file into the snapshot directory. // Move (not copy) the CoW file into the snapshot directory.
snapshotCow := snapshot.CowPath(m.cfg.SnapshotsDir, sandboxID) snapshotCow := snapshot.CowPath(pauseDir, "")
if err := os.Rename(sb.dmDevice.CowPath, snapshotCow); err != nil { if err := os.Rename(sb.dmDevice.CowPath, snapshotCow); err != nil {
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
// VM and dm-snapshot are already gone — clean up remaining resources. // VM and dm-snapshot are already gone — clean up remaining resources.
warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot))
m.slots.Release(sb.SlotIndex) m.slots.Release(sb.SlotIndex)
@ -434,10 +430,10 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
} }
// Record which base template this CoW was built against. // Record which base template this CoW was built against.
if err := snapshot.WriteMeta(m.cfg.SnapshotsDir, sandboxID, &snapshot.RootfsMeta{ if err := snapshot.WriteMeta(pauseDir, "", &snapshot.RootfsMeta{
BaseTemplate: sb.baseImagePath, BaseTemplate: sb.baseImagePath,
}); err != nil { }); err != nil {
warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir))
// VM and dm-snapshot are already gone — clean up remaining resources. // VM and dm-snapshot are already gone — clean up remaining resources.
warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot))
m.slots.Release(sb.SlotIndex) m.slots.Release(sb.SlotIndex)
@ -477,13 +473,13 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
// Resume restores a paused sandbox from its snapshot using UFFD for // Resume restores a paused sandbox from its snapshot using UFFD for
// lazy memory loading. The sandbox gets a new network slot. // lazy memory loading. The sandbox gets a new network slot.
func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) (*models.Sandbox, error) { func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) (*models.Sandbox, error) {
snapDir := m.cfg.SnapshotsDir pauseDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)
if !snapshot.Exists(snapDir, sandboxID) { if _, err := os.Stat(pauseDir); err != nil {
return nil, fmt.Errorf("no snapshot found for sandbox %s", sandboxID) return nil, fmt.Errorf("no snapshot found for sandbox %s", sandboxID)
} }
// Read the header to set up the UFFD memory source. // Read the header to set up the UFFD memory source.
headerData, err := os.ReadFile(snapshot.MemHeaderPath(snapDir, sandboxID)) headerData, err := os.ReadFile(filepath.Join(pauseDir, snapshot.MemHeaderName))
if err != nil { if err != nil {
return nil, fmt.Errorf("read header: %w", err) return nil, fmt.Errorf("read header: %w", err)
} }
@ -494,7 +490,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
} }
// Build diff file map — supports both single-generation and multi-generation. // Build diff file map — supports both single-generation and multi-generation.
diffPaths, err := snapshot.ListDiffFiles(snapDir, sandboxID, header) diffPaths, err := snapshot.ListDiffFiles(pauseDir, "", header)
if err != nil { if err != nil {
return nil, fmt.Errorf("list diff files: %w", err) return nil, fmt.Errorf("list diff files: %w", err)
} }
@ -505,7 +501,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
} }
// Read rootfs metadata to find the base template image. // Read rootfs metadata to find the base template image.
meta, err := snapshot.ReadMeta(snapDir, sandboxID) meta, err := snapshot.ReadMeta(pauseDir, "")
if err != nil { if err != nil {
source.Close() source.Close()
return nil, fmt.Errorf("read rootfs meta: %w", err) return nil, fmt.Errorf("read rootfs meta: %w", err)
@ -527,8 +523,8 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
} }
// Move CoW file from snapshot dir to sandboxes dir for the running sandbox. // Move CoW file from snapshot dir to sandboxes dir for the running sandbox.
savedCow := snapshot.CowPath(snapDir, sandboxID) savedCow := snapshot.CowPath(pauseDir, "")
cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) cowPath := filepath.Join(layout.SandboxesDir(m.cfg.WrennDir), fmt.Sprintf("%s.cow", sandboxID))
if err := os.Rename(savedCow, cowPath); err != nil { if err := os.Rename(savedCow, cowPath); err != nil {
source.Close() source.Close()
m.loops.Release(baseImagePath) m.loops.Release(baseImagePath)
@ -574,7 +570,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
} }
// Start UFFD server. // Start UFFD server.
uffdSocketPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s-uffd.sock", sandboxID)) uffdSocketPath := filepath.Join(layout.SandboxesDir(m.cfg.WrennDir), fmt.Sprintf("%s-uffd.sock", sandboxID))
os.Remove(uffdSocketPath) // Clean stale socket. os.Remove(uffdSocketPath) // Clean stale socket.
uffdServer := uffd.NewServer(uffdSocketPath, source) uffdServer := uffd.NewServer(uffdSocketPath, source)
if err := uffdServer.Start(ctx); err != nil { if err := uffdServer.Start(ctx); err != nil {
@ -590,7 +586,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
// Restore VM from snapshot. // Restore VM from snapshot.
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
SandboxID: sandboxID, SandboxID: sandboxID,
KernelPath: m.cfg.KernelPath, KernelPath: layout.KernelPath(m.cfg.WrennDir),
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
VCPUs: 1, // Placeholder; overridden by snapshot. VCPUs: 1, // Placeholder; overridden by snapshot.
MemoryMB: int(header.Metadata.Size / (1024 * 1024)), // Placeholder; overridden by snapshot. MemoryMB: int(header.Metadata.Size / (1024 * 1024)), // Placeholder; overridden by snapshot.
@ -602,8 +598,8 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
NetMask: slot.GuestNetMask, NetMask: slot.GuestNetMask,
} }
snapPath := snapshot.SnapPath(snapDir, sandboxID) resumeSnapPath := filepath.Join(pauseDir, snapshot.SnapFileName)
if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, snapPath, uffdSocketPath); err != nil { if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, resumeSnapPath, uffdSocketPath); err != nil {
warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) warnErr("uffd server stop error", sandboxID, uffdServer.Stop())
source.Close() source.Close()
warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot))
@ -636,7 +632,6 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
Sandbox: models.Sandbox{ Sandbox: models.Sandbox{
ID: sandboxID, ID: sandboxID,
Status: models.StatusRunning, Status: models.StatusRunning,
Template: "",
VCPUs: vmCfg.VCPUs, VCPUs: vmCfg.VCPUs,
MemoryMB: vmCfg.MemoryMB, MemoryMB: vmCfg.MemoryMB,
TimeoutSec: timeoutSec, TimeoutSec: timeoutSec,
@ -685,11 +680,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
// The rootfs is flattened (base + CoW merged) into a new standalone rootfs.ext4 // The rootfs is flattened (base + CoW merged) into a new standalone rootfs.ext4
// so the template has no dependency on the original base image. Memory state // so the template has no dependency on the original base image. Memory state
// and VM snapshot files are copied as-is. // and VM snapshot files are copied as-is.
func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (int64, error) { func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID) (int64, error) {
if err := validate.SafeName(name); err != nil {
return 0, fmt.Errorf("invalid snapshot name: %w", err)
}
// If the sandbox is running, pause it first. // If the sandbox is running, pause it first.
if _, err := m.get(sandboxID); err == nil { if _, err := m.get(sandboxID); err == nil {
if err := m.Pause(ctx, sandboxID); err != nil { if err := m.Pause(ctx, sandboxID); err != nil {
@ -697,25 +688,26 @@ func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (i
} }
} }
// At this point, pause snapshot files must exist in SnapshotsDir/{sandboxID}/. // At this point, pause snapshot files must exist.
if !snapshot.Exists(m.cfg.SnapshotsDir, sandboxID) { pauseDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)
if _, err := os.Stat(pauseDir); err != nil {
return 0, fmt.Errorf("no snapshot found for sandbox %s", sandboxID) return 0, fmt.Errorf("no snapshot found for sandbox %s", sandboxID)
} }
// Create template directory. // Create template directory.
if err := snapshot.EnsureDir(m.cfg.ImagesDir, name); err != nil { dstDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID)
if err := os.MkdirAll(dstDir, 0755); err != nil {
return 0, fmt.Errorf("create template dir: %w", err) return 0, fmt.Errorf("create template dir: %w", err)
} }
// Copy VM snapshot file and memory header. // Copy VM snapshot file and memory header.
srcDir := snapshot.DirPath(m.cfg.SnapshotsDir, sandboxID) srcDir := pauseDir
dstDir := snapshot.DirPath(m.cfg.ImagesDir, name)
for _, fname := range []string{snapshot.SnapFileName, snapshot.MemHeaderName} { for _, fname := range []string{snapshot.SnapFileName, snapshot.MemHeaderName} {
src := filepath.Join(srcDir, fname) src := filepath.Join(srcDir, fname)
dst := filepath.Join(dstDir, fname) dst := filepath.Join(dstDir, fname)
if err := copyFile(src, dst); err != nil { if err := copyFile(src, dst); err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("copy %s: %w", fname, err) return 0, fmt.Errorf("copy %s: %w", fname, err)
} }
} }
@ -723,59 +715,59 @@ func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (i
// Copy all memory diff files referenced by the header (supports multi-generation). // Copy all memory diff files referenced by the header (supports multi-generation).
headerData, err := os.ReadFile(filepath.Join(srcDir, snapshot.MemHeaderName)) headerData, err := os.ReadFile(filepath.Join(srcDir, snapshot.MemHeaderName))
if err != nil { if err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("read header for template: %w", err) return 0, fmt.Errorf("read header for template: %w", err)
} }
srcHeader, err := snapshot.Deserialize(headerData) srcHeader, err := snapshot.Deserialize(headerData)
if err != nil { if err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("deserialize header for template: %w", err) return 0, fmt.Errorf("deserialize header for template: %w", err)
} }
srcDiffPaths, err := snapshot.ListDiffFiles(m.cfg.SnapshotsDir, sandboxID, srcHeader) srcDiffPaths, err := snapshot.ListDiffFiles(pauseDir, "", srcHeader)
if err != nil { if err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("list diff files for template: %w", err) return 0, fmt.Errorf("list diff files for template: %w", err)
} }
for _, srcPath := range srcDiffPaths { for _, srcPath := range srcDiffPaths {
dstPath := filepath.Join(dstDir, filepath.Base(srcPath)) dstPath := filepath.Join(dstDir, filepath.Base(srcPath))
if err := copyFile(srcPath, dstPath); err != nil { if err := copyFile(srcPath, dstPath); err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("copy diff file %s: %w", filepath.Base(srcPath), err) return 0, fmt.Errorf("copy diff file %s: %w", filepath.Base(srcPath), err)
} }
} }
// Flatten rootfs: temporarily set up dm device from base + CoW, dd to new image. // Flatten rootfs: temporarily set up dm device from base + CoW, dd to new image.
meta, err := snapshot.ReadMeta(m.cfg.SnapshotsDir, sandboxID) meta, err := snapshot.ReadMeta(pauseDir, "")
if err != nil { if err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("read rootfs meta: %w", err) return 0, fmt.Errorf("read rootfs meta: %w", err)
} }
originLoop, err := m.loops.Acquire(meta.BaseTemplate) originLoop, err := m.loops.Acquire(meta.BaseTemplate)
if err != nil { if err != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("acquire loop device for flatten: %w", err) return 0, fmt.Errorf("acquire loop device for flatten: %w", err)
} }
originSize, err := devicemapper.OriginSizeBytes(originLoop) originSize, err := devicemapper.OriginSizeBytes(originLoop)
if err != nil { if err != nil {
m.loops.Release(meta.BaseTemplate) m.loops.Release(meta.BaseTemplate)
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("get origin size: %w", err) return 0, fmt.Errorf("get origin size: %w", err)
} }
// Temporarily restore the dm-snapshot to read the merged view. // Temporarily restore the dm-snapshot to read the merged view.
cowPath := snapshot.CowPath(m.cfg.SnapshotsDir, sandboxID) cowPath := snapshot.CowPath(pauseDir, "")
tmpDmName := "wrenn-flatten-" + sandboxID tmpDmName := "wrenn-flatten-" + sandboxID
tmpDev, err := devicemapper.RestoreSnapshot(ctx, tmpDmName, originLoop, cowPath, originSize) tmpDev, err := devicemapper.RestoreSnapshot(ctx, tmpDmName, originLoop, cowPath, originSize)
if err != nil { if err != nil {
m.loops.Release(meta.BaseTemplate) m.loops.Release(meta.BaseTemplate)
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("restore dm-snapshot for flatten: %w", err) return 0, fmt.Errorf("restore dm-snapshot for flatten: %w", err)
} }
// Flatten to new standalone rootfs. // Flatten to new standalone rootfs.
flattenedPath := snapshot.RootfsPath(m.cfg.ImagesDir, name) flattenedPath := filepath.Join(dstDir, snapshot.RootfsFileName)
flattenErr := devicemapper.FlattenSnapshot(tmpDev.DevicePath, flattenedPath) flattenErr := devicemapper.FlattenSnapshot(tmpDev.DevicePath, flattenedPath)
// Always clean up the temporary dm device. // Always clean up the temporary dm device.
@ -783,18 +775,19 @@ func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (i
m.loops.Release(meta.BaseTemplate) m.loops.Release(meta.BaseTemplate)
if flattenErr != nil { if flattenErr != nil {
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", dstDir, os.RemoveAll(dstDir))
return 0, fmt.Errorf("flatten rootfs: %w", flattenErr) return 0, fmt.Errorf("flatten rootfs: %w", flattenErr)
} }
sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name) sizeBytes, err := snapshot.DirSize(dstDir, "")
if err != nil { if err != nil {
slog.Warn("failed to calculate snapshot size", "error", err) slog.Warn("failed to calculate snapshot size", "error", err)
} }
slog.Info("template snapshot created (rootfs flattened)", slog.Info("template snapshot created (rootfs flattened)",
"sandbox", sandboxID, "sandbox", sandboxID,
"name", name, "team_id", teamID,
"template_id", templateID,
"size_bytes", sizeBytes, "size_bytes", sizeBytes,
) )
return sizeBytes, nil return sizeBytes, nil
@ -804,11 +797,7 @@ func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (i
// rootfs into a standalone rootfs.ext4, and cleans up all resources. // rootfs into a standalone rootfs.ext4, and cleans up all resources.
// The result is an image-only template (no VM memory/CPU state) stored in // The result is an image-only template (no VM memory/CPU state) stored in
// ImagesDir/{name}/rootfs.ext4. // ImagesDir/{name}/rootfs.ext4.
func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID, name string) (int64, error) { func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID) (int64, error) {
if err := validate.SafeName(name); err != nil {
return 0, fmt.Errorf("invalid template name: %w", err)
}
m.mu.Lock() m.mu.Lock()
sb, ok := m.boxes[sandboxID] sb, ok := m.boxes[sandboxID]
if ok { if ok {
@ -837,21 +826,22 @@ func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID, name string) (in
} }
// Create template directory and flatten the dm-snapshot. // Create template directory and flatten the dm-snapshot.
if err := snapshot.EnsureDir(m.cfg.ImagesDir, name); err != nil { flattenDstDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID)
if err := os.MkdirAll(flattenDstDir, 0755); err != nil {
m.cleanupDM(sb) m.cleanupDM(sb)
return 0, fmt.Errorf("create template dir: %w", err) return 0, fmt.Errorf("create template dir: %w", err)
} }
outputPath := snapshot.RootfsPath(m.cfg.ImagesDir, name) outputPath := filepath.Join(flattenDstDir, snapshot.RootfsFileName)
if sb.dmDevice == nil { if sb.dmDevice == nil {
m.cleanupDM(sb) m.cleanupDM(sb)
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", flattenDstDir, os.RemoveAll(flattenDstDir))
return 0, fmt.Errorf("sandbox %s has no dm device", sandboxID) return 0, fmt.Errorf("sandbox %s has no dm device", sandboxID)
} }
if err := devicemapper.FlattenSnapshot(sb.dmDevice.DevicePath, outputPath); err != nil { if err := devicemapper.FlattenSnapshot(sb.dmDevice.DevicePath, outputPath); err != nil {
m.cleanupDM(sb) m.cleanupDM(sb)
warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) warnErr("template dir cleanup error", flattenDstDir, os.RemoveAll(flattenDstDir))
return 0, fmt.Errorf("flatten rootfs: %w", err) return 0, fmt.Errorf("flatten rootfs: %w", err)
} }
@ -869,14 +859,15 @@ func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID, name string) (in
slog.Warn("resize2fs -M failed (non-fatal)", "output", string(out), "error", err) slog.Warn("resize2fs -M failed (non-fatal)", "output", string(out), "error", err)
} }
sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name) sizeBytes, err := snapshot.DirSize(flattenDstDir, "")
if err != nil { if err != nil {
slog.Warn("failed to calculate template size", "error", err) slog.Warn("failed to calculate template size", "error", err)
} }
slog.Info("rootfs flattened to image-only template", slog.Info("rootfs flattened to image-only template",
"sandbox", sandboxID, "sandbox", sandboxID,
"name", name, "team_id", teamID,
"template_id", templateID,
"size_bytes", sizeBytes, "size_bytes", sizeBytes,
) )
return sizeBytes, nil return sizeBytes, nil
@ -896,22 +887,19 @@ func (m *Manager) cleanupDM(sb *sandboxState) {
} }
// DeleteSnapshot removes a snapshot template from disk. // DeleteSnapshot removes a snapshot template from disk.
func (m *Manager) DeleteSnapshot(name string) error { func (m *Manager) DeleteSnapshot(teamID, templateID pgtype.UUID) error {
if err := validate.SafeName(name); err != nil { return os.RemoveAll(layout.TemplateDir(m.cfg.WrennDir, teamID, templateID))
return fmt.Errorf("invalid snapshot name: %w", err)
}
return snapshot.Remove(m.cfg.ImagesDir, name)
} }
// createFromSnapshot creates a new sandbox by restoring from a snapshot template // createFromSnapshot creates a new sandbox by restoring from a snapshot template
// 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, diskSizeMB int) (*models.Sandbox, error) { func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID, vcpus, _, timeoutSec, diskSizeMB int) (*models.Sandbox, error) {
imagesDir := m.cfg.ImagesDir tmplDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID)
// Read the header. // Read the header.
headerData, err := os.ReadFile(snapshot.MemHeaderPath(imagesDir, snapshotName)) headerData, err := os.ReadFile(filepath.Join(tmplDir, snapshot.MemHeaderName))
if err != nil { if err != nil {
return nil, fmt.Errorf("read snapshot header: %w", err) return nil, fmt.Errorf("read snapshot header: %w", err)
} }
@ -925,7 +913,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
memoryMB := int(header.Metadata.Size / (1024 * 1024)) memoryMB := int(header.Metadata.Size / (1024 * 1024))
// Build diff file map — supports multi-generation templates. // Build diff file map — supports multi-generation templates.
diffPaths, err := snapshot.ListDiffFiles(imagesDir, snapshotName, header) diffPaths, err := snapshot.ListDiffFiles(tmplDir, "", header)
if err != nil { if err != nil {
return nil, fmt.Errorf("list diff files: %w", err) return nil, fmt.Errorf("list diff files: %w", err)
} }
@ -936,7 +924,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
} }
// Set up dm-snapshot on the template's flattened rootfs. // Set up dm-snapshot on the template's flattened rootfs.
baseRootfs := snapshot.RootfsPath(imagesDir, snapshotName) baseRootfs := filepath.Join(tmplDir, snapshot.RootfsFileName)
originLoop, err := m.loops.Acquire(baseRootfs) originLoop, err := m.loops.Acquire(baseRootfs)
if err != nil { if err != nil {
source.Close() source.Close()
@ -951,7 +939,7 @@ 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(layout.SandboxesDir(m.cfg.WrennDir), fmt.Sprintf("%s.cow", sandboxID))
cowSize := int64(diskSizeMB) * 1024 * 1024 cowSize := int64(diskSizeMB) * 1024 * 1024
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize) dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize)
if err != nil { if err != nil {
@ -981,7 +969,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
} }
// Start UFFD server. // Start UFFD server.
uffdSocketPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s-uffd.sock", sandboxID)) uffdSocketPath := filepath.Join(layout.SandboxesDir(m.cfg.WrennDir), fmt.Sprintf("%s-uffd.sock", sandboxID))
os.Remove(uffdSocketPath) os.Remove(uffdSocketPath)
uffdServer := uffd.NewServer(uffdSocketPath, source) uffdServer := uffd.NewServer(uffdSocketPath, source)
if err := uffdServer.Start(ctx); err != nil { if err := uffdServer.Start(ctx); err != nil {
@ -997,7 +985,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
// Restore VM. // Restore VM.
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
SandboxID: sandboxID, SandboxID: sandboxID,
KernelPath: m.cfg.KernelPath, KernelPath: layout.KernelPath(m.cfg.WrennDir),
RootfsPath: dmDev.DevicePath, RootfsPath: dmDev.DevicePath,
VCPUs: vcpus, VCPUs: vcpus,
MemoryMB: memoryMB, MemoryMB: memoryMB,
@ -1009,7 +997,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
NetMask: slot.GuestNetMask, NetMask: slot.GuestNetMask,
} }
snapPath := snapshot.SnapPath(imagesDir, snapshotName) snapPath := filepath.Join(tmplDir, snapshot.SnapFileName)
if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, snapPath, uffdSocketPath); err != nil { if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, snapPath, uffdSocketPath); err != nil {
warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) warnErr("uffd server stop error", sandboxID, uffdServer.Stop())
source.Close() source.Close()
@ -1041,17 +1029,18 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
now := time.Now() now := time.Now()
sb := &sandboxState{ sb := &sandboxState{
Sandbox: models.Sandbox{ Sandbox: models.Sandbox{
ID: sandboxID, ID: sandboxID,
Status: models.StatusRunning, Status: models.StatusRunning,
Template: snapshotName, TemplateTeamID: teamID.Bytes,
VCPUs: vcpus, TemplateID: templateID.Bytes,
MemoryMB: memoryMB, VCPUs: vcpus,
TimeoutSec: timeoutSec, MemoryMB: memoryMB,
SlotIndex: slotIdx, TimeoutSec: timeoutSec,
HostIP: slot.HostIP, SlotIndex: slotIdx,
RootfsPath: dmDev.DevicePath, HostIP: slot.HostIP,
CreatedAt: now, RootfsPath: dmDev.DevicePath,
LastActiveAt: now, CreatedAt: now,
LastActiveAt: now,
}, },
slot: slot, slot: slot,
client: client, client: client,
@ -1073,7 +1062,8 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam
slog.Info("sandbox created from snapshot", slog.Info("sandbox created from snapshot",
"id", sandboxID, "id", sandboxID,
"snapshot", snapshotName, "team_id", teamID,
"template_id", templateID,
"host_ip", slot.HostIP.String(), "host_ip", slot.HostIP.String(),
"dm_device", dmDev.DevicePath, "dm_device", dmDev.DevicePath,
) )

View File

@ -95,6 +95,7 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
buildID := id.NewBuildID() buildID := id.NewBuildID()
buildIDStr := id.FormatBuildID(buildID) buildIDStr := id.FormatBuildID(buildID)
newTemplateID := id.NewTemplateID()
build, err := s.DB.InsertTemplateBuild(ctx, db.InsertTemplateBuildParams{ build, err := s.DB.InsertTemplateBuild(ctx, db.InsertTemplateBuildParams{
ID: buildID, ID: buildID,
@ -105,6 +106,8 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TotalSteps: int32(len(p.Recipe) + len(preBuildCmds) + len(postBuildCmds)), TotalSteps: int32(len(p.Recipe) + len(preBuildCmds) + len(postBuildCmds)),
TemplateID: newTemplateID,
TeamID: id.PlatformTeamID,
}) })
if err != nil { if err != nil {
return db.TemplateBuild{}, fmt.Errorf("insert build: %w", err) return db.TemplateBuild{}, fmt.Errorf("insert build: %w", err)
@ -207,12 +210,27 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
sandboxIDStr := id.FormatSandboxID(sandboxID) sandboxIDStr := id.FormatSandboxID(sandboxID)
log = log.With("sandbox_id", sandboxIDStr, "host_id", id.FormatHostID(host.ID)) log = log.With("sandbox_id", sandboxIDStr, "host_id", id.FormatHostID(host.ID))
// Resolve the base template to UUIDs. "minimal" is the zero sentinel.
baseTeamID := id.PlatformTeamID
baseTemplateID := id.MinimalTemplateID
if build.BaseTemplate != "minimal" {
baseTmpl, err := s.DB.GetPlatformTemplateByName(ctx, build.BaseTemplate)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found: %v", build.BaseTemplate, err))
return
}
baseTeamID = baseTmpl.TeamID
baseTemplateID = baseTmpl.ID
}
resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{ resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxIDStr, SandboxId: sandboxIDStr,
Template: build.BaseTemplate, Template: build.BaseTemplate,
TeamId: id.UUIDString(baseTeamID),
TemplateId: id.UUIDString(baseTemplateID),
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: 5120, // 5 GB for template builds DiskSizeMb: 5120, // 5 GB for template builds
})) }))
if err != nil { if err != nil {
@ -316,8 +334,10 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
// Healthcheck passed → full snapshot (with memory/CPU state). // Healthcheck passed → full snapshot (with memory/CPU state).
log.Info("healthcheck passed, creating snapshot") log.Info("healthcheck passed, creating snapshot")
snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{ snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{
SandboxId: sandboxIDStr, SandboxId: sandboxIDStr,
Name: build.Name, Name: build.Name,
TeamId: id.UUIDString(build.TeamID),
TemplateId: id.UUIDString(build.TemplateID),
})) }))
if err != nil { if err != nil {
s.destroySandbox(ctx, agent, sandboxIDStr) s.destroySandbox(ctx, agent, sandboxIDStr)
@ -329,8 +349,10 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
// No healthcheck → image-only template (rootfs only). // No healthcheck → image-only template (rootfs only).
log.Info("no healthcheck, flattening rootfs") log.Info("no healthcheck, flattening rootfs")
flatResp, err := agent.FlattenRootfs(ctx, connect.NewRequest(&pb.FlattenRootfsRequest{ flatResp, err := agent.FlattenRootfs(ctx, connect.NewRequest(&pb.FlattenRootfsRequest{
SandboxId: sandboxIDStr, SandboxId: sandboxIDStr,
Name: build.Name, Name: build.Name,
TeamId: id.UUIDString(build.TeamID),
TemplateId: id.UUIDString(build.TemplateID),
})) }))
if err != nil { if err != nil {
s.destroySandbox(ctx, agent, sandboxIDStr) s.destroySandbox(ctx, agent, sandboxIDStr)
@ -347,6 +369,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
} }
if _, err := s.DB.InsertTemplate(ctx, db.InsertTemplateParams{ if _, err := s.DB.InsertTemplate(ctx, db.InsertTemplateParams{
ID: build.TemplateID,
Name: build.Name, Name: build.Name,
Type: templateType, Type: templateType,
Vcpus: build.Vcpus, Vcpus: build.Vcpus,

View File

@ -82,10 +82,21 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
p.DiskSizeMB = 5120 // 5 GB default p.DiskSizeMB = 5120 // 5 GB default
} }
// If the template is a snapshot, use its baked-in vcpus/memory. // Resolve template name → (teamID, templateID).
if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" { templateTeamID := id.PlatformTeamID
p.VCPUs = tmpl.Vcpus templateID := id.MinimalTemplateID
p.MemoryMB = tmpl.MemoryMb if p.Template != "minimal" {
tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("template %q not found: %w", p.Template, err)
}
templateTeamID = tmpl.TeamID
templateID = tmpl.ID
// If the template is a snapshot, use its baked-in vcpus/memory.
if tmpl.Type == "snapshot" {
p.VCPUs = tmpl.Vcpus
p.MemoryMB = tmpl.MemoryMb
}
} }
if !p.TeamID.Valid { if !p.TeamID.Valid {
@ -113,15 +124,17 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
sandboxIDStr := id.FormatSandboxID(sandboxID) sandboxIDStr := id.FormatSandboxID(sandboxID)
if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{ if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
ID: sandboxID, ID: sandboxID,
TeamID: p.TeamID, TeamID: p.TeamID,
HostID: host.ID, HostID: host.ID,
Template: p.Template, Template: p.Template,
Status: "pending", Status: "pending",
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec, TimeoutSec: p.TimeoutSec,
DiskSizeMb: p.DiskSizeMB, DiskSizeMb: p.DiskSizeMB,
TemplateID: templateID,
TemplateTeamID: templateTeamID,
}); err != nil { }); err != nil {
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err) return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
} }
@ -129,6 +142,8 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{ resp, err := agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxIDStr, SandboxId: sandboxIDStr,
Template: p.Template, Template: p.Template,
TeamId: id.UUIDString(templateTeamID),
TemplateId: id.UUIDString(templateID),
Vcpus: p.VCPUs, Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB, MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec, TimeoutSec: p.TimeoutSec,

View File

@ -202,12 +202,61 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
} }
} }
// Clean up team-owned templates from all hosts in the background.
go s.cleanupTeamTemplates(context.Background(), teamID)
if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil { if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil {
return fmt.Errorf("soft delete team: %w", err) return fmt.Errorf("soft delete team: %w", err)
} }
return nil return nil
} }
// cleanupTeamTemplates deletes all template files for a team from all online hosts,
// then removes the DB records. Called asynchronously during team deletion.
func (s *TeamService) cleanupTeamTemplates(ctx context.Context, teamID pgtype.UUID) {
templates, err := s.DB.ListTemplatesByTeamOnly(ctx, teamID)
if err != nil {
slog.Warn("team delete: failed to list templates for cleanup", "team_id", id.FormatTeamID(teamID), "error", err)
return
}
if len(templates) == 0 {
return
}
hosts, err := s.DB.ListActiveHosts(ctx)
if err != nil {
slog.Warn("team delete: failed to list hosts for template cleanup", "error", err)
return
}
for _, tmpl := range templates {
for _, host := range hosts {
if host.Status != "online" {
continue
}
agent, err := s.HostPool.GetForHost(host)
if err != nil {
continue
}
if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{
TeamId: id.UUIDString(tmpl.TeamID),
TemplateId: id.UUIDString(tmpl.ID),
})); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
slog.Warn("team delete: failed to delete template on host",
"host_id", id.FormatHostID(host.ID),
"template", tmpl.Name,
"error", err,
)
}
}
}
// Remove DB records.
if err := s.DB.DeleteTemplatesByTeam(ctx, teamID); err != nil {
slog.Warn("team delete: failed to delete template records", "team_id", id.FormatTeamID(teamID), "error", err)
}
}
// GetMembers returns all members of the team with their emails and roles. // GetMembers returns all members of the team with their emails and roles.
func (s *TeamService) GetMembers(ctx context.Context, teamID pgtype.UUID) ([]MemberInfo, error) { func (s *TeamService) GetMembers(ctx context.Context, teamID pgtype.UUID) ([]MemberInfo, error) {
rows, err := s.DB.GetTeamMembers(ctx, teamID) rows, err := s.DB.GetTeamMembers(ctx, teamID)

View File

@ -25,7 +25,7 @@ type CreateSandboxRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Sandbox ID assigned by the control plane. If empty, the host agent generates one. // Sandbox ID assigned by the control plane. If empty, the host agent generates one.
SandboxId string `protobuf:"bytes,5,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` SandboxId string `protobuf:"bytes,5,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
// Template name (e.g., "minimal", "python311"). Determines base rootfs. // Deprecated: use team_id + template_id instead.
Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
// Number of virtual CPUs (default: 1). // Number of virtual CPUs (default: 1).
Vcpus int32 `protobuf:"varint,2,opt,name=vcpus,proto3" json:"vcpus,omitempty"` Vcpus int32 `protobuf:"varint,2,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
@ -36,7 +36,11 @@ type CreateSandboxRequest struct {
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 rootfs. Base images are expanded to this size // Disk size in MB for the rootfs. Base images are expanded to this size
// at host agent startup. Default: 5120 (5 GB). // at host agent startup. Default: 5120 (5 GB).
DiskSizeMb int32 `protobuf:"varint,6,opt,name=disk_size_mb,json=diskSizeMb,proto3" json:"disk_size_mb,omitempty"` DiskSizeMb int32 `protobuf:"varint,6,opt,name=disk_size_mb,json=diskSizeMb,proto3" json:"disk_size_mb,omitempty"`
// Team UUID that owns the template (hex string). All-zeros = platform.
TeamId string `protobuf:"bytes,7,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
// Template UUID (hex string). Both zeros + team zeros = "minimal" sentinel.
TemplateId string `protobuf:"bytes,8,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -113,6 +117,20 @@ func (x *CreateSandboxRequest) GetDiskSizeMb() int32 {
return 0 return 0
} }
func (x *CreateSandboxRequest) GetTeamId() string {
if x != nil {
return x.TeamId
}
return ""
}
func (x *CreateSandboxRequest) GetTemplateId() string {
if x != nil {
return x.TemplateId
}
return ""
}
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"`
@ -448,9 +466,14 @@ func (x *ResumeSandboxResponse) GetHostIp() string {
} }
type CreateSnapshotRequest struct { type CreateSnapshotRequest 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"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // Deprecated: use team_id + template_id instead.
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
// Team UUID that will own the new template.
TeamId string `protobuf:"bytes,3,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
// Template UUID for the new snapshot template.
TemplateId string `protobuf:"bytes,4,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -499,6 +522,20 @@ func (x *CreateSnapshotRequest) GetName() string {
return "" return ""
} }
func (x *CreateSnapshotRequest) GetTeamId() string {
if x != nil {
return x.TeamId
}
return ""
}
func (x *CreateSnapshotRequest) GetTemplateId() string {
if x != nil {
return x.TemplateId
}
return ""
}
type CreateSnapshotResponse struct { type CreateSnapshotResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@ -552,8 +589,13 @@ func (x *CreateSnapshotResponse) GetSizeBytes() int64 {
} }
type DeleteSnapshotRequest struct { type DeleteSnapshotRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Deprecated: use team_id + template_id instead.
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Team UUID that owns the template.
TeamId string `protobuf:"bytes,2,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
// Template UUID to delete.
TemplateId string `protobuf:"bytes,3,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -595,6 +637,20 @@ func (x *DeleteSnapshotRequest) GetName() string {
return "" return ""
} }
func (x *DeleteSnapshotRequest) GetTeamId() string {
if x != nil {
return x.TeamId
}
return ""
}
func (x *DeleteSnapshotRequest) GetTemplateId() string {
if x != nil {
return x.TemplateId
}
return ""
}
type DeleteSnapshotResponse struct { type DeleteSnapshotResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@ -851,16 +907,19 @@ func (x *ListSandboxesResponse) GetAutoPausedSandboxIds() []string {
} }
type SandboxInfo struct { type SandboxInfo 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"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"` // Deprecated: use team_id + template_id instead.
Vcpus int32 `protobuf:"varint,4,opt,name=vcpus,proto3" json:"vcpus,omitempty"` Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"`
MemoryMb int32 `protobuf:"varint,5,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"` Vcpus int32 `protobuf:"varint,4,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
HostIp string `protobuf:"bytes,6,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` MemoryMb int32 `protobuf:"varint,5,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"`
CreatedAtUnix int64 `protobuf:"varint,7,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,omitempty"` HostIp string `protobuf:"bytes,6,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"`
LastActiveAtUnix int64 `protobuf:"varint,8,opt,name=last_active_at_unix,json=lastActiveAtUnix,proto3" json:"last_active_at_unix,omitempty"` CreatedAtUnix int64 `protobuf:"varint,7,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,omitempty"`
TimeoutSec int32 `protobuf:"varint,9,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` LastActiveAtUnix int64 `protobuf:"varint,8,opt,name=last_active_at_unix,json=lastActiveAtUnix,proto3" json:"last_active_at_unix,omitempty"`
TimeoutSec int32 `protobuf:"varint,9,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
TeamId string `protobuf:"bytes,10,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
TemplateId string `protobuf:"bytes,11,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -958,6 +1017,20 @@ func (x *SandboxInfo) GetTimeoutSec() int32 {
return 0 return 0
} }
func (x *SandboxInfo) GetTeamId() string {
if x != nil {
return x.TeamId
}
return ""
}
func (x *SandboxInfo) GetTemplateId() string {
if x != nil {
return x.TemplateId
}
return ""
}
type WriteFileRequest struct { type WriteFileRequest 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"`
@ -2182,9 +2255,14 @@ func (x *FlushSandboxMetricsResponse) GetPoints_24H() []*MetricPoint {
} }
type FlattenRootfsRequest struct { type FlattenRootfsRequest 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"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // template name — output written to images/{name}/rootfs.ext4 // Deprecated: use team_id + template_id instead.
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
// Team UUID that will own the resulting template.
TeamId string `protobuf:"bytes,3,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
// Template UUID for the output.
TemplateId string `protobuf:"bytes,4,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2233,6 +2311,20 @@ func (x *FlattenRootfsRequest) GetName() string {
return "" return ""
} }
func (x *FlattenRootfsRequest) GetTeamId() string {
if x != nil {
return x.TeamId
}
return ""
}
func (x *FlattenRootfsRequest) GetTemplateId() string {
if x != nil {
return x.TemplateId
}
return ""
}
type FlattenRootfsResponse struct { type FlattenRootfsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SizeBytes int64 `protobuf:"varint,1,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` SizeBytes int64 `protobuf:"varint,1,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"`
@ -2281,7 +2373,7 @@ var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" + const file_hostagent_proto_rawDesc = "" +
"\n" + "\n" +
"\x0fhostagent.proto\x12\fhostagent.v1\"\xc7\x01\n" + "\x0fhostagent.proto\x12\fhostagent.v1\"\x81\x02\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" +
@ -2291,7 +2383,10 @@ const file_hostagent_proto_rawDesc = "" +
"\vtimeout_sec\x18\x04 \x01(\x05R\n" + "\vtimeout_sec\x18\x04 \x01(\x05R\n" +
"timeoutSec\x12 \n" + "timeoutSec\x12 \n" +
"\fdisk_size_mb\x18\x06 \x01(\x05R\n" + "\fdisk_size_mb\x18\x06 \x01(\x05R\n" +
"diskSizeMb\"g\n" + "diskSizeMb\x12\x17\n" +
"\ateam_id\x18\a \x01(\tR\x06teamId\x12\x1f\n" +
"\vtemplate_id\x18\b \x01(\tR\n" +
"templateId\"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" +
@ -2314,17 +2409,23 @@ const file_hostagent_proto_rawDesc = "" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" +
"\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"J\n" + "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"\x84\x01\n" +
"\x15CreateSnapshotRequest\x12\x1d\n" + "\x15CreateSnapshotRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\"K\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x17\n" +
"\ateam_id\x18\x03 \x01(\tR\x06teamId\x12\x1f\n" +
"\vtemplate_id\x18\x04 \x01(\tR\n" +
"templateId\"K\n" +
"\x16CreateSnapshotResponse\x12\x12\n" + "\x16CreateSnapshotResponse\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" +
"\n" + "\n" +
"size_bytes\x18\x02 \x01(\x03R\tsizeBytes\"+\n" + "size_bytes\x18\x02 \x01(\x03R\tsizeBytes\"e\n" +
"\x15DeleteSnapshotRequest\x12\x12\n" + "\x15DeleteSnapshotRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"\x18\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x17\n" +
"\ateam_id\x18\x02 \x01(\tR\x06teamId\x12\x1f\n" +
"\vtemplate_id\x18\x03 \x01(\tR\n" +
"templateId\"\x18\n" +
"\x16DeleteSnapshotResponse\"s\n" + "\x16DeleteSnapshotResponse\"s\n" +
"\vExecRequest\x12\x1d\n" + "\vExecRequest\x12\x1d\n" +
"\n" + "\n" +
@ -2340,7 +2441,7 @@ const file_hostagent_proto_rawDesc = "" +
"\x14ListSandboxesRequest\"\x87\x01\n" + "\x14ListSandboxesRequest\"\x87\x01\n" +
"\x15ListSandboxesResponse\x127\n" + "\x15ListSandboxesResponse\x127\n" +
"\tsandboxes\x18\x01 \x03(\v2\x19.hostagent.v1.SandboxInfoR\tsandboxes\x125\n" + "\tsandboxes\x18\x01 \x03(\v2\x19.hostagent.v1.SandboxInfoR\tsandboxes\x125\n" +
"\x17auto_paused_sandbox_ids\x18\x02 \x03(\tR\x14autoPausedSandboxIds\"\xa4\x02\n" + "\x17auto_paused_sandbox_ids\x18\x02 \x03(\tR\x14autoPausedSandboxIds\"\xde\x02\n" +
"\vSandboxInfo\x12\x1d\n" + "\vSandboxInfo\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" +
@ -2352,7 +2453,11 @@ const file_hostagent_proto_rawDesc = "" +
"\x0fcreated_at_unix\x18\a \x01(\x03R\rcreatedAtUnix\x12-\n" + "\x0fcreated_at_unix\x18\a \x01(\x03R\rcreatedAtUnix\x12-\n" +
"\x13last_active_at_unix\x18\b \x01(\x03R\x10lastActiveAtUnix\x12\x1f\n" + "\x13last_active_at_unix\x18\b \x01(\x03R\x10lastActiveAtUnix\x12\x1f\n" +
"\vtimeout_sec\x18\t \x01(\x05R\n" + "\vtimeout_sec\x18\t \x01(\x05R\n" +
"timeoutSec\"_\n" + "timeoutSec\x12\x17\n" +
"\ateam_id\x18\n" +
" \x01(\tR\x06teamId\x12\x1f\n" +
"\vtemplate_id\x18\v \x01(\tR\n" +
"templateId\"_\n" +
"\x10WriteFileRequest\x12\x1d\n" + "\x10WriteFileRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" +
@ -2427,11 +2532,14 @@ const file_hostagent_proto_rawDesc = "" +
"points_10m\x18\x01 \x03(\v2\x19.hostagent.v1.MetricPointR\tpoints10m\x126\n" + "points_10m\x18\x01 \x03(\v2\x19.hostagent.v1.MetricPointR\tpoints10m\x126\n" +
"\tpoints_2h\x18\x02 \x03(\v2\x19.hostagent.v1.MetricPointR\bpoints2h\x128\n" + "\tpoints_2h\x18\x02 \x03(\v2\x19.hostagent.v1.MetricPointR\bpoints2h\x128\n" +
"\n" + "\n" +
"points_24h\x18\x03 \x03(\v2\x19.hostagent.v1.MetricPointR\tpoints24h\"I\n" + "points_24h\x18\x03 \x03(\v2\x19.hostagent.v1.MetricPointR\tpoints24h\"\x83\x01\n" +
"\x14FlattenRootfsRequest\x12\x1d\n" + "\x14FlattenRootfsRequest\x12\x1d\n" +
"\n" + "\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\"6\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x17\n" +
"\ateam_id\x18\x03 \x01(\tR\x06teamId\x12\x1f\n" +
"\vtemplate_id\x18\x04 \x01(\tR\n" +
"templateId\"6\n" +
"\x15FlattenRootfsResponse\x12\x1d\n" + "\x15FlattenRootfsResponse\x12\x1d\n" +
"\n" + "\n" +
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes2\xc8\f\n" + "size_bytes\x18\x01 \x01(\x03R\tsizeBytes2\xc8\f\n" +

View File

@ -73,7 +73,7 @@ message CreateSandboxRequest {
// Sandbox ID assigned by the control plane. If empty, the host agent generates one. // Sandbox ID assigned by the control plane. If empty, the host agent generates one.
string sandbox_id = 5; string sandbox_id = 5;
// Template name (e.g., "minimal", "python311"). Determines base rootfs. // Deprecated: use team_id + template_id instead.
string template = 1; string template = 1;
// Number of virtual CPUs (default: 1). // Number of virtual CPUs (default: 1).
@ -89,6 +89,12 @@ message CreateSandboxRequest {
// Disk size in MB for the rootfs. Base images are expanded to this size // Disk size in MB for the rootfs. Base images are expanded to this size
// at host agent startup. Default: 5120 (5 GB). // at host agent startup. Default: 5120 (5 GB).
int32 disk_size_mb = 6; int32 disk_size_mb = 6;
// Team UUID that owns the template (hex string). All-zeros = platform.
string team_id = 7;
// Template UUID (hex string). Both zeros + team zeros = "minimal" sentinel.
string template_id = 8;
} }
message CreateSandboxResponse { message CreateSandboxResponse {
@ -125,7 +131,12 @@ message ResumeSandboxResponse {
message CreateSnapshotRequest { message CreateSnapshotRequest {
string sandbox_id = 1; string sandbox_id = 1;
// Deprecated: use team_id + template_id instead.
string name = 2; string name = 2;
// Team UUID that will own the new template.
string team_id = 3;
// Template UUID for the new snapshot template.
string template_id = 4;
} }
message CreateSnapshotResponse { message CreateSnapshotResponse {
@ -134,7 +145,12 @@ message CreateSnapshotResponse {
} }
message DeleteSnapshotRequest { message DeleteSnapshotRequest {
// Deprecated: use team_id + template_id instead.
string name = 1; string name = 1;
// Team UUID that owns the template.
string team_id = 2;
// Template UUID to delete.
string template_id = 3;
} }
message DeleteSnapshotResponse {} message DeleteSnapshotResponse {}
@ -166,6 +182,7 @@ message ListSandboxesResponse {
message SandboxInfo { message SandboxInfo {
string sandbox_id = 1; string sandbox_id = 1;
string status = 2; string status = 2;
// Deprecated: use team_id + template_id instead.
string template = 3; string template = 3;
int32 vcpus = 4; int32 vcpus = 4;
int32 memory_mb = 5; int32 memory_mb = 5;
@ -173,6 +190,8 @@ message SandboxInfo {
int64 created_at_unix = 7; int64 created_at_unix = 7;
int64 last_active_at_unix = 8; int64 last_active_at_unix = 8;
int32 timeout_sec = 9; int32 timeout_sec = 9;
string team_id = 10;
string template_id = 11;
} }
message WriteFileRequest { message WriteFileRequest {
@ -299,7 +318,12 @@ message FlushSandboxMetricsResponse {
message FlattenRootfsRequest { message FlattenRootfsRequest {
string sandbox_id = 1; string sandbox_id = 1;
string name = 2; // template name — output written to images/{name}/rootfs.ext4 // Deprecated: use team_id + template_id instead.
string name = 2;
// Team UUID that will own the resulting template.
string team_id = 3;
// Template UUID for the output.
string template_id = 4;
} }
message FlattenRootfsResponse { message FlattenRootfsResponse {