From 75b28ed8992b0d1fda4a4fad1854a9605966a174 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sun, 29 Mar 2026 00:30:10 +0600 Subject: [PATCH] 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. --- .../20260328162803_template_uuid_pk.sql | 64 +++++ db/queries/sandboxes.sql | 4 +- db/queries/template_builds.sql | 4 +- db/queries/templates.sql | 24 +- internal/api/handlers_builds.go | 24 +- internal/api/handlers_snapshots.go | 35 ++- internal/api/middleware.go | 8 + internal/db/models.go | 35 +-- internal/db/sandboxes.sql.go | 62 +++-- internal/db/template_builds.sql.go | 24 +- internal/db/templates.sql.go | 130 ++++++++-- internal/hostagent/server.go | 67 ++++- internal/id/id.go | 20 +- internal/id/id_test.go | 6 +- internal/layout/layout.go | 58 +++++ internal/layout/layout_test.go | 120 +++++++++ internal/models/sandbox.go | 23 +- internal/sandbox/images.go | 108 +++++--- internal/sandbox/manager.go | 244 +++++++++--------- internal/service/build.go | 33 ++- internal/service/sandbox.go | 41 ++- internal/service/team.go | 49 ++++ proto/hostagent/gen/hostagent.pb.go | 168 +++++++++--- proto/hostagent/hostagent.proto | 28 +- 24 files changed, 1057 insertions(+), 322 deletions(-) create mode 100644 db/migrations/20260328162803_template_uuid_pk.sql create mode 100644 internal/layout/layout.go create mode 100644 internal/layout/layout_test.go diff --git a/db/migrations/20260328162803_template_uuid_pk.sql b/db/migrations/20260328162803_template_uuid_pk.sql new file mode 100644 index 0000000..8665241 --- /dev/null +++ b/db/migrations/20260328162803_template_uuid_pk.sql @@ -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; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index b8ae8de..8cbd10b 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -1,6 +1,6 @@ -- name: InsertSandbox :one -INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +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, $10, $11) RETURNING *; -- name: GetSandbox :one diff --git a/db/queries/template_builds.sql b/db/queries/template_builds.sql index ead4d92..be1c09e 100644 --- a/db/queries/template_builds.sql +++ b/db/queries/template_builds.sql @@ -1,6 +1,6 @@ -- name: InsertTemplateBuild :one -INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps) -VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8) +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, $9, $10) RETURNING *; -- name: GetTemplateBuild :one diff --git a/db/queries/templates.sql b/db/queries/templates.sql index c7b7085..de4d6f2 100644 --- a/db/queries/templates.sql +++ b/db/queries/templates.sql @@ -1,15 +1,23 @@ -- name: InsertTemplate :one -INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; -- name: GetTemplate :one -SELECT * FROM templates WHERE name = $1; +SELECT * FROM templates WHERE id = $1; -- name: GetTemplateByTeam :one -- Platform templates (team_id = 00000000-...) are visible to all teams. SELECT * FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000'); +-- name: 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 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; -- name: DeleteTemplate :exec -DELETE FROM templates WHERE name = $1; +DELETE FROM templates WHERE id = $1; -- name: DeleteTemplateByTeam :exec 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; diff --git a/internal/api/handlers_builds.go b/internal/api/handlers_builds.go index 8b8fd5c..58a7ed4 100644 --- a/internal/api/handlers_builds.go +++ b/internal/api/handlers_builds.go @@ -180,13 +180,13 @@ func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) { } type templateResponse struct { - Name string `json:"name"` - Type string `json:"type"` - VCPUs int32 `json:"vcpus"` - MemoryMB int32 `json:"memory_mb"` - SizeBytes int64 `json:"size_bytes"` - TeamID string `json:"team_id"` - CreatedAt string `json:"created_at"` + Name string `json:"name"` + Type string `json:"type"` + VCPUs int32 `json:"vcpus"` + MemoryMB int32 `json:"memory_mb"` + SizeBytes int64 `json:"size_bytes"` + TeamID string `json:"team_id"` + CreatedAt string `json:"created_at"` } resp := make([]templateResponse, len(templates)) @@ -216,7 +216,8 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) { } 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") return } @@ -231,14 +232,17 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) { if err != nil { 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 { 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") return } diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go index 1b5f1f3..07bd030 100644 --- a/internal/api/handlers_snapshots.go +++ b/internal/api/handlers_snapshots.go @@ -11,6 +11,8 @@ import ( "connectrpc.com/connect" "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/auth" "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. // 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. -func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, name string) error { +// and ignore NotFound errors. +func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, teamID, templateID pgtype.UUID) error { hosts, err := h.db.ListActiveHosts(ctx) if err != nil { return fmt.Errorf("list hosts: %w", err) @@ -48,9 +50,12 @@ func (h *snapshotHandler) deleteSnapshotBroadcast(ctx context.Context, name stri if err != nil { 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 { - 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) 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. - 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 { writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace") return } // 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") return } @@ -174,9 +185,14 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { snapCtx, snapCancel := context.WithTimeout(context.Background(), 5*time.Minute) 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{ - SandboxId: req.SandboxID, - Name: req.Name, + SandboxId: req.SandboxID, + Name: req.Name, + TeamId: formatUUIDForRPC(ac.TeamID), + TemplateId: formatUUIDForRPC(newTemplateID), })) if err != nil { // 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{ + ID: newTemplateID, Name: req.Name, Type: "snapshot", Vcpus: sb.Vcpus, @@ -255,7 +272,7 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) { 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") return } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 6a56293..5c9d8cd 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -12,6 +12,9 @@ import ( "time" "connectrpc.com/connect" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/id" ) 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. func agentErrToHTTP(err error) (int, string, string) { switch connect.CodeOf(err) { diff --git a/internal/db/models.go b/internal/db/models.go index f35bfe7..d5bfc0f 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -83,21 +83,23 @@ type OauthProvider struct { } type Sandbox struct { - ID pgtype.UUID `json:"id"` - TeamID pgtype.UUID `json:"team_id"` - HostID pgtype.UUID `json:"host_id"` - Template string `json:"template"` - Status string `json:"status"` - Vcpus int32 `json:"vcpus"` - MemoryMb int32 `json:"memory_mb"` - TimeoutSec int32 `json:"timeout_sec"` - DiskSizeMb int32 `json:"disk_size_mb"` - GuestIp string `json:"guest_ip"` - HostIp string `json:"host_ip"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - StartedAt pgtype.Timestamptz `json:"started_at"` - LastActiveAt pgtype.Timestamptz `json:"last_active_at"` - LastUpdated pgtype.Timestamptz `json:"last_updated"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` + HostID pgtype.UUID `json:"host_id"` + Template string `json:"template"` + Status string `json:"status"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` + DiskSizeMb int32 `json:"disk_size_mb"` + GuestIp string `json:"guest_ip"` + HostIp string `json:"host_ip"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + LastActiveAt pgtype.Timestamptz `json:"last_active_at"` + LastUpdated pgtype.Timestamptz `json:"last_updated"` + TemplateID pgtype.UUID `json:"template_id"` + TemplateTeamID pgtype.UUID `json:"template_team_id"` } type SandboxMetricPoint struct { @@ -146,6 +148,7 @@ type Template struct { SizeBytes int64 `json:"size_bytes"` CreatedAt pgtype.Timestamptz `json:"created_at"` TeamID pgtype.UUID `json:"team_id"` + ID pgtype.UUID `json:"id"` } type TemplateBuild struct { @@ -166,6 +169,8 @@ type TemplateBuild struct { CreatedAt pgtype.Timestamptz `json:"created_at"` StartedAt pgtype.Timestamptz `json:"started_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"` + TemplateID pgtype.UUID `json:"template_id"` + TeamID pgtype.UUID `json:"team_id"` } type User struct { diff --git a/internal/db/sandboxes.sql.go b/internal/db/sandboxes.sql.go index ace4370..4107f1a 100644 --- a/internal/db/sandboxes.sql.go +++ b/internal/db/sandboxes.sql.go @@ -43,7 +43,7 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu } 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) { @@ -65,12 +65,14 @@ func (q *Queries) GetSandbox(ctx context.Context, id pgtype.UUID) (Sandbox, erro &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ) return i, err } 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 { @@ -97,26 +99,30 @@ func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamPara &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ) return i, err } const insertSandbox = `-- name: InsertSandbox :one -INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) -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 +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, $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, template_id, template_team_id ` type InsertSandboxParams struct { - ID pgtype.UUID `json:"id"` - TeamID pgtype.UUID `json:"team_id"` - HostID pgtype.UUID `json:"host_id"` - Template string `json:"template"` - Status string `json:"status"` - Vcpus int32 `json:"vcpus"` - MemoryMb int32 `json:"memory_mb"` - TimeoutSec int32 `json:"timeout_sec"` - DiskSizeMb int32 `json:"disk_size_mb"` + ID pgtype.UUID `json:"id"` + TeamID pgtype.UUID `json:"team_id"` + HostID pgtype.UUID `json:"host_id"` + Template string `json:"template"` + Status string `json:"status"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` + 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) { @@ -130,6 +136,8 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S arg.MemoryMb, arg.TimeoutSec, arg.DiskSizeMb, + arg.TemplateID, + arg.TemplateTeamID, ) var i Sandbox err := row.Scan( @@ -148,12 +156,14 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ) return i, err } 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') ORDER BY created_at DESC ` @@ -183,6 +193,8 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ); err != nil { return nil, err } @@ -195,7 +207,7 @@ func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID pgtype.U } 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) { @@ -223,6 +235,8 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ); err != nil { return nil, err } @@ -235,7 +249,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { } 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[]) ORDER BY created_at DESC ` @@ -270,6 +284,8 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ); err != nil { return nil, err } @@ -282,7 +298,7 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand } 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') ORDER BY created_at DESC ` @@ -312,6 +328,8 @@ func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID pgtype.UUID) ( &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ); err != nil { return nil, err } @@ -364,7 +382,7 @@ SET status = 'running', last_active_at = $4, last_updated = NOW() 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 { @@ -398,6 +416,8 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ) return i, err } @@ -407,7 +427,7 @@ UPDATE sandboxes SET status = $2, last_updated = NOW() 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 { @@ -434,6 +454,8 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat &i.StartedAt, &i.LastActiveAt, &i.LastUpdated, + &i.TemplateID, + &i.TemplateTeamID, ) return i, err } diff --git a/internal/db/template_builds.sql.go b/internal/db/template_builds.sql.go index 9e770ee..7aa1b67 100644 --- a/internal/db/template_builds.sql.go +++ b/internal/db/template_builds.sql.go @@ -12,7 +12,7 @@ import ( ) 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) { @@ -36,14 +36,16 @@ func (q *Queries) GetTemplateBuild(ctx context.Context, id pgtype.UUID) (Templat &i.CreatedAt, &i.StartedAt, &i.CompletedAt, + &i.TemplateID, + &i.TeamID, ) return i, err } const insertTemplateBuild = `-- name: InsertTemplateBuild :one -INSERT INTO template_builds (id, name, base_template, recipe, healthcheck, vcpus, memory_mb, status, total_steps) -VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8) -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 +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, $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, template_id, team_id ` type InsertTemplateBuildParams struct { @@ -55,6 +57,8 @@ type InsertTemplateBuildParams struct { Vcpus int32 `json:"vcpus"` MemoryMb int32 `json:"memory_mb"` 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) { @@ -67,6 +71,8 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui arg.Vcpus, arg.MemoryMb, arg.TotalSteps, + arg.TemplateID, + arg.TeamID, ) var i TemplateBuild err := row.Scan( @@ -87,12 +93,14 @@ func (q *Queries) InsertTemplateBuild(ctx context.Context, arg InsertTemplateBui &i.CreatedAt, &i.StartedAt, &i.CompletedAt, + &i.TemplateID, + &i.TeamID, ) return i, err } 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) { @@ -122,6 +130,8 @@ func (q *Queries) ListTemplateBuilds(ctx context.Context) ([]TemplateBuild, erro &i.CreatedAt, &i.StartedAt, &i.CompletedAt, + &i.TemplateID, + &i.TeamID, ); err != nil { 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, completed_at = CASE WHEN $2 IN ('success', 'failed') THEN NOW() ELSE completed_at END 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 { @@ -218,6 +228,8 @@ func (q *Queries) UpdateBuildStatus(ctx context.Context, arg UpdateBuildStatusPa &i.CreatedAt, &i.StartedAt, &i.CompletedAt, + &i.TemplateID, + &i.TeamID, ) return i, err } diff --git a/internal/db/templates.sql.go b/internal/db/templates.sql.go index 45a673c..7d37808 100644 --- a/internal/db/templates.sql.go +++ b/internal/db/templates.sql.go @@ -12,11 +12,11 @@ import ( ) 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 { - _, err := q.db.Exec(ctx, deleteTemplate, name) +func (q *Queries) DeleteTemplate(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTemplate, id) return err } @@ -34,12 +34,23 @@ func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateBy return err } -const getTemplate = `-- name: GetTemplate :one -SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 +const deleteTemplatesByTeam = `-- name: DeleteTemplatesByTeam :exec +DELETE FROM templates WHERE team_id = $1 ` -func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error) { - row := q.db.QueryRow(ctx, getTemplate, name) +// Bulk delete all templates owned by a team (for team soft-delete cleanup). +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 err := row.Scan( &i.Name, @@ -49,12 +60,59 @@ func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error &i.SizeBytes, &i.CreatedAt, &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 } 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 { @@ -74,17 +132,19 @@ func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamPa &i.SizeBytes, &i.CreatedAt, &i.TeamID, + &i.ID, ) return i, err } const insertTemplate = `-- name: InsertTemplate :one -INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id +INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id ` type InsertTemplateParams struct { + ID pgtype.UUID `json:"id"` Name string `json:"name"` Type string `json:"type"` Vcpus int32 `json:"vcpus"` @@ -95,6 +155,7 @@ type InsertTemplateParams struct { func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { row := q.db.QueryRow(ctx, insertTemplate, + arg.ID, arg.Name, arg.Type, arg.Vcpus, @@ -111,12 +172,13 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) &i.SizeBytes, &i.CreatedAt, &i.TeamID, + &i.ID, ) return i, err } 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) { @@ -136,6 +198,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { &i.SizeBytes, &i.CreatedAt, &i.TeamID, + &i.ID, ); err != nil { return nil, err } @@ -148,7 +211,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { } 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. @@ -169,6 +232,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ( &i.SizeBytes, &i.CreatedAt, &i.TeamID, + &i.ID, ); err != nil { return nil, err } @@ -181,7 +245,7 @@ func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID pgtype.UUID) ( } 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 { @@ -207,6 +271,41 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla &i.SizeBytes, &i.CreatedAt, &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 { return nil, err } @@ -219,7 +318,7 @@ func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTempla } 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) { @@ -239,6 +338,7 @@ func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Temp &i.SizeBytes, &i.CreatedAt, &i.TeamID, + &i.ID, ); err != nil { return nil, err } diff --git a/internal/hostagent/server.go b/internal/hostagent/server.go index 549158f..ab016f8 100644 --- a/internal/hostagent/server.go +++ b/internal/hostagent/server.go @@ -12,6 +12,8 @@ import ( "time" "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" "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} } +// 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( ctx context.Context, req *connect.Request[pb.CreateSandboxRequest], ) (*connect.Response[pb.CreateSandboxResponse], error) { 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 { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err)) } @@ -90,12 +114,22 @@ func (s *Server) CreateSnapshot( ctx context.Context, req *connect.Request[pb.CreateSnapshotRequest], ) (*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 { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create snapshot: %w", err)) } return connect.NewResponse(&pb.CreateSnapshotResponse{ - Name: req.Msg.Name, + Name: msg.Name, SizeBytes: sizeBytes, }), nil } @@ -104,7 +138,17 @@ func (s *Server) DeleteSnapshot( ctx context.Context, req *connect.Request[pb.DeleteSnapshotRequest], ) (*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 connect.NewResponse(&pb.DeleteSnapshotResponse{}), nil @@ -114,7 +158,17 @@ func (s *Server) FlattenRootfs( ctx context.Context, req *connect.Request[pb.FlattenRootfsRequest], ) (*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 { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("flatten rootfs: %w", err)) } @@ -413,7 +467,8 @@ func (s *Server) ListSandboxes( infos[i] = &pb.SandboxInfo{ SandboxId: sb.ID, Status: string(sb.Status), - Template: sb.Template, + TeamId: uuid.UUID(sb.TemplateTeamID).String(), + TemplateId: uuid.UUID(sb.TemplateID).String(), Vcpus: int32(sb.VCPUs), MemoryMb: int32(sb.MemoryMB), HostIp: sb.HostIP.String(), diff --git a/internal/id/id.go b/internal/id/id.go index 35c44ae..45cba6c 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -36,8 +36,9 @@ func NewAuditLogID() pgtype.UUID { return newUUID() } func NewBuildID() 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. -// Templates use TEXT primary keys (not UUID), so this stays as a string. func NewSnapshotName() string { return "template-" + hex8() } @@ -76,8 +77,8 @@ const ( PrefixAdminPermission = "perm-" ) -// uuidToBase36 encodes 16 UUID bytes as a 25-char base36 string (0-9a-z). -func uuidToBase36(b [16]byte) string { +// UUIDToBase36 encodes 16 UUID bytes as a 25-char base36 string (0-9a-z). +func UUIDToBase36(b [16]byte) string { n := new(big.Int).SetBytes(b[:]) buf := make([]byte, base36IDLen) mod := new(big.Int) @@ -110,7 +111,7 @@ func base36ToUUID(s string) ([16]byte, error) { } 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) } @@ -151,6 +152,17 @@ func ParseBuildID(s string) (pgtype.UUID, error) { return parseUUID(PrefixBu // (e.g. base templates, shared infrastructure). 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 --- func hex8() string { diff --git a/internal/id/id_test.go b/internal/id/id_test.go index f8ae285..c16ec7a 100644 --- a/internal/id/id_test.go +++ b/internal/id/id_test.go @@ -10,7 +10,7 @@ import ( func TestBase36RoundTrip(t *testing.T) { for i := 0; i < 1000; i++ { orig := uuid.New() - encoded := uuidToBase36(orig) + encoded := UUIDToBase36(orig) if len(encoded) != base36IDLen { 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) { var zero [16]byte - encoded := uuidToBase36(zero) + encoded := UUIDToBase36(zero) if encoded != "0000000000000000000000000" { 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) { max := [16]byte{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 { t.Fatalf("max UUID encoding wrong length: %d", len(encoded)) } diff --git a/internal/layout/layout.go b/internal/layout/layout.go new file mode 100644 index 0000000..d4084f5 --- /dev/null +++ b/internal/layout/layout.go @@ -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") +} diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go new file mode 100644 index 0000000..bffdae2 --- /dev/null +++ b/internal/layout/layout_test.go @@ -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) + } +} diff --git a/internal/models/sandbox.go b/internal/models/sandbox.go index b99bd6b..ab72cd3 100644 --- a/internal/models/sandbox.go +++ b/internal/models/sandbox.go @@ -18,15 +18,16 @@ const ( // Sandbox holds all state for a running sandbox on this host. type Sandbox struct { - ID string - Status SandboxStatus - Template string - VCPUs int - MemoryMB int - TimeoutSec int - SlotIndex int - HostIP net.IP - RootfsPath string - CreatedAt time.Time - LastActiveAt time.Time + ID string + Status SandboxStatus + TemplateTeamID [16]byte + TemplateID [16]byte + VCPUs int + MemoryMB int + TimeoutSec int + SlotIndex int + HostIP net.IP + RootfsPath string + CreatedAt time.Time + LastActiveAt time.Time } diff --git a/internal/sandbox/images.go b/internal/sandbox/images.go index feb3398..1716d80 100644 --- a/internal/sandbox/images.go +++ b/internal/sandbox/images.go @@ -6,6 +6,9 @@ import ( "os" "os/exec" "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 @@ -14,61 +17,90 @@ import ( // changes; no physical disk is consumed beyond the original content. 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 // above the target size are left untouched. Should be called once at host agent // startup before any sandboxes are created. -func EnsureImageSizes(imagesDir string, targetMB int) error { +func EnsureImageSizes(wrennDir string, targetMB int) error { if targetMB <= 0 { targetMB = DefaultDiskSizeMB } targetBytes := int64(targetMB) * 1024 * 1024 - entries, err := os.ReadDir(imagesDir) - if err != nil { - return fmt.Errorf("read images dir: %w", err) + // Expand the built-in minimal image. + minimalRootfs := layout.TemplateRootfs(wrennDir, id.PlatformTeamID, id.MinimalTemplateID) + if err := expandImage(minimalRootfs, targetBytes, targetMB); err != nil { + return err } - for _, entry := range entries { - if !entry.IsDir() { + // Walk teams/{teamDir}/{templateDir}/rootfs.ext4 two levels deep. + 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 } - rootfs := filepath.Join(imagesDir, entry.Name(), "rootfs.ext4") - info, err := os.Stat(rootfs) + teamPath := filepath.Join(teamsDir, teamEntry.Name()) + templateEntries, err := os.ReadDir(teamPath) if err != nil { - continue // not every template dir has a rootfs.ext4 + continue } - - if info.Size() >= targetBytes { - continue // already large enough - } - - slog.Info("expanding base image", - "template", entry.Name(), - "from_mb", info.Size()/(1024*1024), - "to_mb", targetMB, - ) - - // Expand the file (sparse — instant, no physical disk used). - if err := os.Truncate(rootfs, targetBytes); err != nil { - return fmt.Errorf("truncate %s: %w", rootfs, err) - } - - // Check filesystem before resize. - if out, err := exec.Command("e2fsck", "-fy", rootfs).CombinedOutput(); err != nil { - // e2fsck returns 1 if it fixed errors, which is fine. - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() > 1 { - return fmt.Errorf("e2fsck %s: %s: %w", rootfs, string(out), err) + for _, tmplEntry := range templateEntries { + if !tmplEntry.IsDir() { + continue + } + rootfs := filepath.Join(teamPath, tmplEntry.Name(), "rootfs.ext4") + if err := expandImage(rootfs, targetBytes, targetMB); err != nil { + return 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 } + +// 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 +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 9fcecb5..4e97c49 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -11,25 +11,23 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "git.omukk.dev/wrenn/sandbox/internal/devicemapper" "git.omukk.dev/wrenn/sandbox/internal/envdclient" "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/network" "git.omukk.dev/wrenn/sandbox/internal/snapshot" "git.omukk.dev/wrenn/sandbox/internal/uffd" - "git.omukk.dev/wrenn/sandbox/internal/validate" "git.omukk.dev/wrenn/sandbox/internal/vm" ) // Config holds the paths and defaults for the sandbox manager. type Config struct { - KernelPath string - ImagesDir string // directory containing template images (e.g., /var/lib/wrenn/images/{name}/rootfs.ext4) - 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 + WrennDir string // root directory (e.g. /var/lib/wrenn); all sub-paths derived via layout package + EnvdTimeout time.Duration } // Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd. @@ -52,8 +50,8 @@ type sandboxState struct { slot *network.Slot client *envdclient.Client uffdSocketPath string // non-empty for sandboxes restored from snapshot - dmDevice *devicemapper.SnapshotDevice - baseImagePath string // path to the base template rootfs (for loop registry release) + dmDevice *devicemapper.SnapshotDevice + baseImagePath string // path to the base template rootfs (for loop registry release) // parent holds the snapshot header and diff file paths from which this // 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. // 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 == "" { 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 } - 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). - if snapshot.IsSnapshot(m.cfg.ImagesDir, template) { - return m.createFromSnapshot(ctx, sandboxID, template, vcpus, memoryMB, timeoutSec, diskSizeMB) + tmplDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID) + 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 - baseRootfs := filepath.Join(m.cfg.ImagesDir, template, "rootfs.ext4") + // Resolve base rootfs image. + baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID) if _, err := os.Stat(baseRootfs); err != nil { 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. 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 dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize) 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. vmCfg := vm.VMConfig{ SandboxID: sandboxID, - KernelPath: m.cfg.KernelPath, + KernelPath: layout.KernelPath(m.cfg.WrennDir), RootfsPath: dmDev.DevicePath, VCPUs: vcpus, MemoryMB: memoryMB, @@ -211,17 +203,18 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, now := time.Now() sb := &sandboxState{ Sandbox: models.Sandbox{ - ID: sandboxID, - Status: models.StatusRunning, - Template: template, - VCPUs: vcpus, - MemoryMB: memoryMB, - TimeoutSec: timeoutSec, - SlotIndex: slotIdx, - HostIP: slot.HostIP, - RootfsPath: dmDev.DevicePath, - CreatedAt: now, - LastActiveAt: now, + ID: sandboxID, + Status: models.StatusRunning, + TemplateTeamID: teamID.Bytes, + TemplateID: templateID.Bytes, + VCPUs: vcpus, + MemoryMB: memoryMB, + TimeoutSec: timeoutSec, + SlotIndex: slotIdx, + HostIP: slot.HostIP, + RootfsPath: dmDev.DevicePath, + CreatedAt: now, + LastActiveAt: now, }, slot: slot, client: client, @@ -237,7 +230,8 @@ func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, slog.Info("sandbox created", "id", sandboxID, - "template", template, + "team_id", teamID, + "template_id", templateID, "host_ip", slot.HostIP.String(), "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). - 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) return nil @@ -331,18 +327,18 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error { } // 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() return fmt.Errorf("create snapshot dir: %w", err) } - snapDir := snapshot.DirPath(m.cfg.SnapshotsDir, sandboxID) - rawMemPath := filepath.Join(snapDir, "memfile.raw") - snapPath := snapshot.SnapPath(m.cfg.SnapshotsDir, sandboxID) + rawMemPath := filepath.Join(pauseDir, "memfile.raw") + snapPath := filepath.Join(pauseDir, snapshot.SnapFileName) snapshotStart := time.Now() 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() 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. buildID := uuid.New() - headerPath := snapshot.MemHeaderPath(m.cfg.SnapshotsDir, sandboxID) + headerPath := filepath.Join(pauseDir, snapshot.MemHeaderName) processStart := time.Now() if sb.parent != nil && snapshotType == "Diff" { // 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 { - warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir)) resumeOnError() return fmt.Errorf("process memfile with parent: %w", err) } // Copy previous generation diff files into the snapshot directory. 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 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() return fmt.Errorf("copy parent diff file: %w", err) } @@ -375,9 +371,9 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error { } } else { // 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 { - warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + warnErr("snapshot dir cleanup error", sandboxID, os.RemoveAll(pauseDir)) resumeOnError() return fmt.Errorf("process memfile: %w", err) } @@ -407,7 +403,7 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error { if 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() delete(m.boxes, sandboxID) 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. - snapshotCow := snapshot.CowPath(m.cfg.SnapshotsDir, sandboxID) + snapshotCow := snapshot.CowPath(pauseDir, "") 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. warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) 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. - if err := snapshot.WriteMeta(m.cfg.SnapshotsDir, sandboxID, &snapshot.RootfsMeta{ + if err := snapshot.WriteMeta(pauseDir, "", &snapshot.RootfsMeta{ BaseTemplate: sb.baseImagePath, }); 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. warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) 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 // lazy memory loading. The sandbox gets a new network slot. func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) (*models.Sandbox, error) { - snapDir := m.cfg.SnapshotsDir - if !snapshot.Exists(snapDir, sandboxID) { + pauseDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID) + if _, err := os.Stat(pauseDir); err != nil { return nil, fmt.Errorf("no snapshot found for sandbox %s", sandboxID) } // 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 { 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. - diffPaths, err := snapshot.ListDiffFiles(snapDir, sandboxID, header) + diffPaths, err := snapshot.ListDiffFiles(pauseDir, "", header) if err != nil { 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. - meta, err := snapshot.ReadMeta(snapDir, sandboxID) + meta, err := snapshot.ReadMeta(pauseDir, "") if err != nil { source.Close() 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. - savedCow := snapshot.CowPath(snapDir, sandboxID) - cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) + savedCow := snapshot.CowPath(pauseDir, "") + cowPath := filepath.Join(layout.SandboxesDir(m.cfg.WrennDir), fmt.Sprintf("%s.cow", sandboxID)) if err := os.Rename(savedCow, cowPath); err != nil { source.Close() m.loops.Release(baseImagePath) @@ -574,7 +570,7 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) } // 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. uffdServer := uffd.NewServer(uffdSocketPath, source) 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. vmCfg := vm.VMConfig{ SandboxID: sandboxID, - KernelPath: m.cfg.KernelPath, + KernelPath: layout.KernelPath(m.cfg.WrennDir), RootfsPath: dmDev.DevicePath, VCPUs: 1, // 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, } - snapPath := snapshot.SnapPath(snapDir, sandboxID) - if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, snapPath, uffdSocketPath); err != nil { + resumeSnapPath := filepath.Join(pauseDir, snapshot.SnapFileName) + if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, resumeSnapPath, uffdSocketPath); err != nil { warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) source.Close() 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{ ID: sandboxID, Status: models.StatusRunning, - Template: "", VCPUs: vmCfg.VCPUs, MemoryMB: vmCfg.MemoryMB, 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 // so the template has no dependency on the original base image. Memory state // and VM snapshot files are copied as-is. -func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (int64, error) { - if err := validate.SafeName(name); err != nil { - return 0, fmt.Errorf("invalid snapshot name: %w", err) - } - +func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID) (int64, error) { // If the sandbox is running, pause it first. if _, err := m.get(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}/. - if !snapshot.Exists(m.cfg.SnapshotsDir, sandboxID) { + // At this point, pause snapshot files must exist. + 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) } // 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) } // Copy VM snapshot file and memory header. - srcDir := snapshot.DirPath(m.cfg.SnapshotsDir, sandboxID) - dstDir := snapshot.DirPath(m.cfg.ImagesDir, name) + srcDir := pauseDir for _, fname := range []string{snapshot.SnapFileName, snapshot.MemHeaderName} { src := filepath.Join(srcDir, fname) dst := filepath.Join(dstDir, fname) 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) } } @@ -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). headerData, err := os.ReadFile(filepath.Join(srcDir, snapshot.MemHeaderName)) 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) } srcHeader, err := snapshot.Deserialize(headerData) 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) } - srcDiffPaths, err := snapshot.ListDiffFiles(m.cfg.SnapshotsDir, sandboxID, srcHeader) + srcDiffPaths, err := snapshot.ListDiffFiles(pauseDir, "", srcHeader) 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) } for _, srcPath := range srcDiffPaths { dstPath := filepath.Join(dstDir, filepath.Base(srcPath)) 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) } } // 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 { - 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) } originLoop, err := m.loops.Acquire(meta.BaseTemplate) 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) } originSize, err := devicemapper.OriginSizeBytes(originLoop) if err != nil { 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) } // 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 tmpDev, err := devicemapper.RestoreSnapshot(ctx, tmpDmName, originLoop, cowPath, originSize) if err != nil { 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) } // Flatten to new standalone rootfs. - flattenedPath := snapshot.RootfsPath(m.cfg.ImagesDir, name) + flattenedPath := filepath.Join(dstDir, snapshot.RootfsFileName) flattenErr := devicemapper.FlattenSnapshot(tmpDev.DevicePath, flattenedPath) // 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) 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) } - sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name) + sizeBytes, err := snapshot.DirSize(dstDir, "") if err != nil { slog.Warn("failed to calculate snapshot size", "error", err) } slog.Info("template snapshot created (rootfs flattened)", "sandbox", sandboxID, - "name", name, + "team_id", teamID, + "template_id", templateID, "size_bytes", sizeBytes, ) 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. // The result is an image-only template (no VM memory/CPU state) stored in // ImagesDir/{name}/rootfs.ext4. -func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID, name string) (int64, error) { - if err := validate.SafeName(name); err != nil { - return 0, fmt.Errorf("invalid template name: %w", err) - } - +func (m *Manager) FlattenRootfs(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID) (int64, error) { m.mu.Lock() sb, ok := m.boxes[sandboxID] 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. - 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) 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 { 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) } if err := devicemapper.FlattenSnapshot(sb.dmDevice.DevicePath, outputPath); err != nil { 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) } @@ -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) } - sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name) + sizeBytes, err := snapshot.DirSize(flattenDstDir, "") if err != nil { slog.Warn("failed to calculate template size", "error", err) } slog.Info("rootfs flattened to image-only template", "sandbox", sandboxID, - "name", name, + "team_id", teamID, + "template_id", templateID, "size_bytes", sizeBytes, ) return sizeBytes, nil @@ -896,22 +887,19 @@ func (m *Manager) cleanupDM(sb *sandboxState) { } // DeleteSnapshot removes a snapshot template from disk. -func (m *Manager) DeleteSnapshot(name string) error { - if err := validate.SafeName(name); err != nil { - return fmt.Errorf("invalid snapshot name: %w", err) - } - return snapshot.Remove(m.cfg.ImagesDir, name) +func (m *Manager) DeleteSnapshot(teamID, templateID pgtype.UUID) error { + return os.RemoveAll(layout.TemplateDir(m.cfg.WrennDir, teamID, templateID)) } // createFromSnapshot creates a new sandbox by restoring from a snapshot template // in ImagesDir/{snapshotName}/. Uses UFFD for lazy memory loading. // The template's rootfs.ext4 is a flattened standalone image — we create a // 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) { - imagesDir := m.cfg.ImagesDir +func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, teamID, templateID pgtype.UUID, vcpus, _, timeoutSec, diskSizeMB int) (*models.Sandbox, error) { + tmplDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID) // Read the header. - headerData, err := os.ReadFile(snapshot.MemHeaderPath(imagesDir, snapshotName)) + headerData, err := os.ReadFile(filepath.Join(tmplDir, snapshot.MemHeaderName)) if err != nil { 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)) // Build diff file map — supports multi-generation templates. - diffPaths, err := snapshot.ListDiffFiles(imagesDir, snapshotName, header) + diffPaths, err := snapshot.ListDiffFiles(tmplDir, "", header) if err != nil { 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. - baseRootfs := snapshot.RootfsPath(imagesDir, snapshotName) + baseRootfs := filepath.Join(tmplDir, snapshot.RootfsFileName) originLoop, err := m.loops.Acquire(baseRootfs) if err != nil { source.Close() @@ -951,7 +939,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam } 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 dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize) if err != nil { @@ -981,7 +969,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam } // 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) uffdServer := uffd.NewServer(uffdSocketPath, source) if err := uffdServer.Start(ctx); err != nil { @@ -997,7 +985,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam // Restore VM. vmCfg := vm.VMConfig{ SandboxID: sandboxID, - KernelPath: m.cfg.KernelPath, + KernelPath: layout.KernelPath(m.cfg.WrennDir), RootfsPath: dmDev.DevicePath, VCPUs: vcpus, MemoryMB: memoryMB, @@ -1009,7 +997,7 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam 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 { warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) source.Close() @@ -1041,17 +1029,18 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam now := time.Now() sb := &sandboxState{ Sandbox: models.Sandbox{ - ID: sandboxID, - Status: models.StatusRunning, - Template: snapshotName, - VCPUs: vcpus, - MemoryMB: memoryMB, - TimeoutSec: timeoutSec, - SlotIndex: slotIdx, - HostIP: slot.HostIP, - RootfsPath: dmDev.DevicePath, - CreatedAt: now, - LastActiveAt: now, + ID: sandboxID, + Status: models.StatusRunning, + TemplateTeamID: teamID.Bytes, + TemplateID: templateID.Bytes, + VCPUs: vcpus, + MemoryMB: memoryMB, + TimeoutSec: timeoutSec, + SlotIndex: slotIdx, + HostIP: slot.HostIP, + RootfsPath: dmDev.DevicePath, + CreatedAt: now, + LastActiveAt: now, }, slot: slot, client: client, @@ -1073,7 +1062,8 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotNam slog.Info("sandbox created from snapshot", "id", sandboxID, - "snapshot", snapshotName, + "team_id", teamID, + "template_id", templateID, "host_ip", slot.HostIP.String(), "dm_device", dmDev.DevicePath, ) diff --git a/internal/service/build.go b/internal/service/build.go index 5142a13..2592a6d 100644 --- a/internal/service/build.go +++ b/internal/service/build.go @@ -95,6 +95,7 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp buildID := id.NewBuildID() buildIDStr := id.FormatBuildID(buildID) + newTemplateID := id.NewTemplateID() build, err := s.DB.InsertTemplateBuild(ctx, db.InsertTemplateBuildParams{ ID: buildID, @@ -105,6 +106,8 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp Vcpus: p.VCPUs, MemoryMb: p.MemoryMB, TotalSteps: int32(len(p.Recipe) + len(preBuildCmds) + len(postBuildCmds)), + TemplateID: newTemplateID, + TeamID: id.PlatformTeamID, }) if err != nil { 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) 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{ SandboxId: sandboxIDStr, Template: build.BaseTemplate, + TeamId: id.UUIDString(baseTeamID), + TemplateId: id.UUIDString(baseTemplateID), Vcpus: build.Vcpus, MemoryMb: build.MemoryMb, - TimeoutSec: 0, // no auto-pause for builds + TimeoutSec: 0, // no auto-pause for builds DiskSizeMb: 5120, // 5 GB for template builds })) if err != nil { @@ -316,8 +334,10 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) { // Healthcheck passed → full snapshot (with memory/CPU state). log.Info("healthcheck passed, creating snapshot") snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{ - SandboxId: sandboxIDStr, - Name: build.Name, + SandboxId: sandboxIDStr, + Name: build.Name, + TeamId: id.UUIDString(build.TeamID), + TemplateId: id.UUIDString(build.TemplateID), })) if err != nil { 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). log.Info("no healthcheck, flattening rootfs") flatResp, err := agent.FlattenRootfs(ctx, connect.NewRequest(&pb.FlattenRootfsRequest{ - SandboxId: sandboxIDStr, - Name: build.Name, + SandboxId: sandboxIDStr, + Name: build.Name, + TeamId: id.UUIDString(build.TeamID), + TemplateId: id.UUIDString(build.TemplateID), })) if err != nil { 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{ + ID: build.TemplateID, Name: build.Name, Type: templateType, Vcpus: build.Vcpus, diff --git a/internal/service/sandbox.go b/internal/service/sandbox.go index 43f1bd3..2d1f68c 100644 --- a/internal/service/sandbox.go +++ b/internal/service/sandbox.go @@ -82,10 +82,21 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db. p.DiskSizeMB = 5120 // 5 GB default } - // If the template is a snapshot, use its baked-in vcpus/memory. - if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" { - p.VCPUs = tmpl.Vcpus - p.MemoryMB = tmpl.MemoryMb + // Resolve template name → (teamID, templateID). + templateTeamID := id.PlatformTeamID + templateID := id.MinimalTemplateID + 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 { @@ -113,15 +124,17 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db. sandboxIDStr := id.FormatSandboxID(sandboxID) if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{ - ID: sandboxID, - TeamID: p.TeamID, - HostID: host.ID, - Template: p.Template, - Status: "pending", - Vcpus: p.VCPUs, - MemoryMb: p.MemoryMB, - TimeoutSec: p.TimeoutSec, - DiskSizeMb: p.DiskSizeMB, + ID: sandboxID, + TeamID: p.TeamID, + HostID: host.ID, + Template: p.Template, + Status: "pending", + Vcpus: p.VCPUs, + MemoryMb: p.MemoryMB, + TimeoutSec: p.TimeoutSec, + DiskSizeMb: p.DiskSizeMB, + TemplateID: templateID, + TemplateTeamID: templateTeamID, }); err != nil { 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{ SandboxId: sandboxIDStr, Template: p.Template, + TeamId: id.UUIDString(templateTeamID), + TemplateId: id.UUIDString(templateID), Vcpus: p.VCPUs, MemoryMb: p.MemoryMB, TimeoutSec: p.TimeoutSec, diff --git a/internal/service/team.go b/internal/service/team.go index 667cd04..a7acbac 100644 --- a/internal/service/team.go +++ b/internal/service/team.go @@ -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 { return fmt.Errorf("soft delete team: %w", err) } 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. func (s *TeamService) GetMembers(ctx context.Context, teamID pgtype.UUID) ([]MemberInfo, error) { rows, err := s.DB.GetTeamMembers(ctx, teamID) diff --git a/proto/hostagent/gen/hostagent.pb.go b/proto/hostagent/gen/hostagent.pb.go index 9f984f9..516e4d2 100644 --- a/proto/hostagent/gen/hostagent.pb.go +++ b/proto/hostagent/gen/hostagent.pb.go @@ -25,7 +25,7 @@ type CreateSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // 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"` - // 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"` // Number of virtual CPUs (default: 1). 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"` // Disk size in MB for the rootfs. Base images are expanded to this size // 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 sizeCache protoimpl.SizeCache } @@ -113,6 +117,20 @@ func (x *CreateSandboxRequest) GetDiskSizeMb() int32 { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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 { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,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 sizeCache protoimpl.SizeCache } @@ -499,6 +522,20 @@ func (x *CreateSnapshotRequest) GetName() string { 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 { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -552,8 +589,13 @@ func (x *CreateSnapshotResponse) GetSizeBytes() int64 { } type DeleteSnapshotRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // 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 sizeCache protoimpl.SizeCache } @@ -595,6 +637,20 @@ func (x *DeleteSnapshotRequest) GetName() string { 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 { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -851,16 +907,19 @@ func (x *ListSandboxesResponse) GetAutoPausedSandboxIds() []string { } type SandboxInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"` - Vcpus int32 `protobuf:"varint,4,opt,name=vcpus,proto3" json:"vcpus,omitempty"` - MemoryMb int32 `protobuf:"varint,5,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"` - HostIp string `protobuf:"bytes,6,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` - CreatedAtUnix int64 `protobuf:"varint,7,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,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"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + // Deprecated: use team_id + template_id instead. + Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"` + Vcpus int32 `protobuf:"varint,4,opt,name=vcpus,proto3" json:"vcpus,omitempty"` + MemoryMb int32 `protobuf:"varint,5,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"` + HostIp string `protobuf:"bytes,6,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + CreatedAtUnix int64 `protobuf:"varint,7,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,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 sizeCache protoimpl.SizeCache } @@ -958,6 +1017,20 @@ func (x *SandboxInfo) GetTimeoutSec() int32 { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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 { - state protoimpl.MessageState `protogen:"open.v1"` - 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 + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,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 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 sizeCache protoimpl.SizeCache } @@ -2233,6 +2311,20 @@ func (x *FlattenRootfsRequest) GetName() string { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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 = "" + "\n" + - "\x0fhostagent.proto\x12\fhostagent.v1\"\xc7\x01\n" + + "\x0fhostagent.proto\x12\fhostagent.v1\"\x81\x02\n" + "\x14CreateSandboxRequest\x12\x1d\n" + "\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" + "timeoutSec\x12 \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" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + @@ -2314,17 +2409,23 @@ const file_hostagent_proto_rawDesc = "" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\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" + "\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" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "size_bytes\x18\x02 \x01(\x03R\tsizeBytes\"+\n" + + "size_bytes\x18\x02 \x01(\x03R\tsizeBytes\"e\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" + "\vExecRequest\x12\x1d\n" + "\n" + @@ -2340,7 +2441,7 @@ const file_hostagent_proto_rawDesc = "" + "\x14ListSandboxesRequest\"\x87\x01\n" + "\x15ListSandboxesResponse\x127\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" + "\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" + "\x13last_active_at_unix\x18\b \x01(\x03R\x10lastActiveAtUnix\x12\x1f\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" + "\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" + "\tpoints_2h\x18\x02 \x03(\v2\x19.hostagent.v1.MetricPointR\bpoints2h\x128\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" + "\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" + "\n" + "size_bytes\x18\x01 \x01(\x03R\tsizeBytes2\xc8\f\n" + diff --git a/proto/hostagent/hostagent.proto b/proto/hostagent/hostagent.proto index 5a3205b..817d535 100644 --- a/proto/hostagent/hostagent.proto +++ b/proto/hostagent/hostagent.proto @@ -73,7 +73,7 @@ message CreateSandboxRequest { // Sandbox ID assigned by the control plane. If empty, the host agent generates one. string sandbox_id = 5; - // Template name (e.g., "minimal", "python311"). Determines base rootfs. + // Deprecated: use team_id + template_id instead. string template = 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 // at host agent startup. Default: 5120 (5 GB). 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 { @@ -125,7 +131,12 @@ message ResumeSandboxResponse { message CreateSnapshotRequest { string sandbox_id = 1; + // Deprecated: use team_id + template_id instead. 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 { @@ -134,7 +145,12 @@ message CreateSnapshotResponse { } message DeleteSnapshotRequest { + // Deprecated: use team_id + template_id instead. string name = 1; + // Team UUID that owns the template. + string team_id = 2; + // Template UUID to delete. + string template_id = 3; } message DeleteSnapshotResponse {} @@ -166,6 +182,7 @@ message ListSandboxesResponse { message SandboxInfo { string sandbox_id = 1; string status = 2; + // Deprecated: use team_id + template_id instead. string template = 3; int32 vcpus = 4; int32 memory_mb = 5; @@ -173,6 +190,8 @@ message SandboxInfo { int64 created_at_unix = 7; int64 last_active_at_unix = 8; int32 timeout_sec = 9; + string team_id = 10; + string template_id = 11; } message WriteFileRequest { @@ -299,7 +318,12 @@ message FlushSandboxMetricsResponse { message FlattenRootfsRequest { 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 {