1
0
forked from wrenn/wrenn

Add template build system with admin panel, async workers, and FlattenRootfs RPC

Introduces an end-to-end template building pipeline: admins submit a recipe
(list of shell commands) via the dashboard, a Redis-backed worker pool spins
up a sandbox, executes each command, and produces either a full snapshot
(with healthcheck) or an image-only template (rootfs flattened via a new
FlattenRootfs host-agent RPC). Build progress and per-step logs are persisted
to a new template_builds table and polled by the frontend.

Backend:
- New FlattenRootfs RPC (proto + host agent + sandbox manager)
- BuildService with Redis queue (BLPOP) and configurable worker pool (default 2)
- Admin-only REST endpoints: POST/GET /v1/admin/builds, GET /v1/admin/builds/{id}
- Migration for template_builds table with JSONB logs and recipe columns
- sqlc queries for build CRUD and progress updates

Frontend:
- /admin/templates page with Templates + Builds tabs
- Create Template dialog with recipe textarea, healthcheck, specs
- Build history with expandable per-step logs, status badges, progress bars
- Auto-polling every 3s for active builds
- AdminSidebar updated with Templates nav item
This commit is contained in:
2026-03-26 15:27:21 +06:00
parent 6898528096
commit 1ce62934b3
17 changed files with 2031 additions and 26 deletions

View File

@ -795,6 +795,88 @@ func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (i
return sizeBytes, nil
}
// FlattenRootfs stops a running sandbox, flattens its device-mapper CoW
// 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)
}
m.mu.Lock()
sb, ok := m.boxes[sandboxID]
if ok {
delete(m.boxes, sandboxID)
}
m.mu.Unlock()
if !ok {
return 0, fmt.Errorf("sandbox %s not found", sandboxID)
}
// Stop the VM but keep the dm device alive for flattening.
m.stopSampler(sb)
if err := m.vm.Destroy(ctx, sb.ID); err != nil {
slog.Warn("vm destroy error during flatten", "id", sb.ID, "error", err)
}
// Release network resources — not needed after VM is stopped.
if err := network.RemoveNetwork(sb.slot); err != nil {
slog.Warn("network cleanup error during flatten", "id", sb.ID, "error", err)
}
m.slots.Release(sb.SlotIndex)
if sb.uffdSocketPath != "" {
os.Remove(sb.uffdSocketPath)
}
// Create template directory and flatten the dm-snapshot.
if err := snapshot.EnsureDir(m.cfg.ImagesDir, name); err != nil {
m.cleanupDM(sb)
return 0, fmt.Errorf("create template dir: %w", err)
}
outputPath := snapshot.RootfsPath(m.cfg.ImagesDir, name)
if sb.dmDevice == nil {
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))
return 0, fmt.Errorf("flatten rootfs: %w", err)
}
// Clean up dm device and loop device now that flatten is complete.
m.cleanupDM(sb)
sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name)
if err != nil {
slog.Warn("failed to calculate template size", "error", err)
}
slog.Info("rootfs flattened to image-only template",
"sandbox", sandboxID,
"name", name,
"size_bytes", sizeBytes,
)
return sizeBytes, nil
}
// cleanupDM tears down the dm-snapshot device and releases the base image loop device.
func (m *Manager) cleanupDM(sb *sandboxState) {
if sb.dmDevice != nil {
if err := devicemapper.RemoveSnapshot(context.Background(), sb.dmDevice); err != nil {
slog.Warn("dm-snapshot remove error", "id", sb.ID, "error", err)
}
os.Remove(sb.dmDevice.CowPath)
}
if sb.baseImagePath != "" {
m.loops.Release(sb.baseImagePath)
}
}
// DeleteSnapshot removes a snapshot template from disk.
func (m *Manager) DeleteSnapshot(name string) error {
if err := validate.SafeName(name); err != nil {