diff --git a/AGENTS.md b/AGENTS.md index 331062f..223cfb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,6 @@ Always run `make build` before commit. - `./build/bin/banger vm run` is the primary user-facing entry point — auto-pulls the default image + kernel from the catalogs if missing. - `./build/bin/banger image pull ` uses the bundle catalog (fast) when `` is a catalog entry, or falls through to the OCI path for arbitrary registry refs. See `docs/image-catalog.md` and `docs/oci-import.md`. - `./build/bin/banger image register ...` registers an unmanaged host-side image stack. -- `./build/bin/banger image build --from-image ` builds a managed image from an existing one. - `./build/bin/banger image promote ` copies an unmanaged image into daemon-owned managed artifacts. - `scripts/make-generic-kernel.sh` builds a Firecracker-optimized vmlinux from upstream sources. `scripts/publish-kernel.sh ` publishes it to the kernel catalog. - `scripts/publish-golden-image.sh` rebuilds + publishes the golden image bundle and patches the image catalog. diff --git a/README.md b/README.md index 9a74565..2f25623 100644 --- a/README.md +++ b/README.md @@ -110,15 +110,8 @@ banger image register --name base \ --kernel-ref generic-6.12 ``` -### `image build --from-image` — derived images - -```bash -banger image build --name devbox --from-image debian-bookworm --docker -``` - -Spins up a transient VM from a base image, applies opinionated -customisation (mise, claude, pi, tmux plugins), saves a new managed -image. +For custom images, write a Dockerfile and either publish to the +catalog (see `docs/image-catalog.md`) or pull it via the OCI path. ### Workspace + session primitives diff --git a/docs/oci-import.md b/docs/oci-import.md index 829fc84..1a9d93a 100644 --- a/docs/oci-import.md +++ b/docs/oci-import.md @@ -42,7 +42,6 @@ banger image pull ghcr.io/myorg/devimg:v2 --kernel-ref generic-6.12 `openssh-server` via the guest's package manager on first boot. Dispatches on `/etc/os-release` → `apt-get` / `apk` / `dnf` / `pacman` / `zypper`. Subsequent boots skip the install. -- Composition with `image build --from-image`. ## What doesn't yet work diff --git a/internal/api/types.go b/internal/api/types.go index ad36221..9610610 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -58,33 +58,6 @@ type VMCreateStatusResult struct { Operation VMCreateOperation `json:"operation"` } -type ImageBuildStatusParams struct { - ID string `json:"id"` -} - -type ImageBuildOperation struct { - ID string `json:"id"` - ImageID string `json:"image_id,omitempty"` - ImageName string `json:"image_name,omitempty"` - Stage string `json:"stage,omitempty"` - Detail string `json:"detail,omitempty"` - BuildLogPath string `json:"build_log_path,omitempty"` - StartedAt time.Time `json:"started_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - Done bool `json:"done"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` - Image *model.Image `json:"image,omitempty"` -} - -type ImageBuildBeginResult struct { - Operation ImageBuildOperation `json:"operation"` -} - -type ImageBuildStatusResult struct { - Operation ImageBuildOperation `json:"operation"` -} - type VMRefParams struct { IDOrName string `json:"id_or_name"` } @@ -242,16 +215,6 @@ type VMWorkspacePrepareResult struct { Workspace model.WorkspacePrepareResult `json:"workspace"` } -type ImageBuildParams struct { - Name string `json:"name,omitempty"` - FromImage string `json:"from_image,omitempty"` - Size string `json:"size,omitempty"` - KernelPath string `json:"kernel_path,omitempty"` - InitrdPath string `json:"initrd_path,omitempty"` - ModulesDir string `json:"modules_dir,omitempty"` - Docker bool `json:"docker,omitempty"` -} - type ImageRegisterParams struct { Name string `json:"name,omitempty"` RootfsPath string `json:"rootfs_path,omitempty"` diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 8545c88..304b935 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -1702,7 +1702,6 @@ func newImageCommand() *cobra.Command { RunE: helpNoArgs, } cmd.AddCommand( - newImageBuildCommand(), newImageRegisterCommand(), newImagePullCommand(), newImagePromoteCommand(), @@ -1713,40 +1712,6 @@ func newImageCommand() *cobra.Command { return cmd } -func newImageBuildCommand() *cobra.Command { - var params api.ImageBuildParams - cmd := &cobra.Command{ - Use: "build", - Short: "Build an image", - Args: noArgsUsage("usage: banger image build"), - RunE: func(cmd *cobra.Command, args []string) error { - if err := absolutizeImageBuildPaths(¶ms); err != nil { - return err - } - if err := system.EnsureSudo(cmd.Context()); err != nil { - return err - } - layout, _, err := ensureDaemon(cmd.Context()) - if err != nil { - return err - } - result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.build", params) - if err != nil { - return err - } - return printImageSummary(cmd.OutOrStdout(), result.Image) - }, - } - cmd.Flags().StringVar(¶ms.Name, "name", "", "image name") - cmd.Flags().StringVar(¶ms.FromImage, "from-image", "", "registered base image id or name") - cmd.Flags().StringVar(¶ms.Size, "size", "", "output image size") - cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") - cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") - cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") - cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "install docker") - return cmd -} - func newImageRegisterCommand() *cobra.Command { var params api.ImageRegisterParams cmd := &cobra.Command{ @@ -3181,10 +3146,6 @@ func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } -func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { - return absolutizePaths(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir) -} - func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error { return absolutizePaths( ¶ms.RootfsPath, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b8c4bb2..068b1ec 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1965,40 +1965,6 @@ func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) { } } -func TestAbsolutizeImageBuildPaths(t *testing.T) { - dir := t.TempDir() - prev, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(prev) - }) - - params := api.ImageBuildParams{ - FromImage: "base-image", - KernelPath: "/kernel", - InitrdPath: "boot/initrd.img", - ModulesDir: "modules", - } - if err := absolutizeImageBuildPaths(¶ms); err != nil { - t.Fatalf("absolutizeImageBuildPaths: %v", err) - } - - want := api.ImageBuildParams{ - FromImage: "base-image", - KernelPath: "/kernel", - InitrdPath: filepath.Join(dir, "boot/initrd.img"), - ModulesDir: filepath.Join(dir, "modules"), - } - if !reflect.DeepEqual(params, want) { - t.Fatalf("params = %+v, want %+v", params, want) - } -} - func testCLIResolvedVM(id, name string) model.VMRecord { return model.VMRecord{ID: id, Name: name} } diff --git a/internal/daemon/ARCHITECTURE.md b/internal/daemon/ARCHITECTURE.md index 306d7d9..eed47dd 100644 --- a/internal/daemon/ARCHITECTURE.md +++ b/internal/daemon/ARCHITECTURE.md @@ -14,17 +14,16 @@ owning types: - `createVMMu sync.Mutex` — serialises `CreateVM` (guards name uniqueness + guest IP allocation window). - `imageOpsMu sync.Mutex` — serialises image-registry mutations - (`BuildImage`, `RegisterImage`, `PromoteImage`, `DeleteImage`). + (`PullImage`, `RegisterImage`, `PromoteImage`, `DeleteImage`). - `createOps opstate.Registry[*vmCreateOperationState]` — in-flight VM create operations; owns its own lock. -- `imageBuildOps opstate.Registry[*imageBuildOperationState]` — in-flight - image build operations; owns its own lock. - `tapPool tapPool` — TAP interface pool; owns its own lock. - `sessions sessionRegistry` — active guest session controllers; owns its own lock. - `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking. - `vmCaps` — registered VM capability hooks. -- `imageBuild`, `requestHandler`, `guestWaitForSSH`, `guestDial`, +- `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`, + `requestHandler`, `guestWaitForSSH`, `guestDial`, `waitForGuestSessionReady` — injectable seams used by tests. ## Subpackages diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 8651764..c39fdae 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -38,7 +38,6 @@ type Daemon struct { imageOpsMu sync.Mutex createVMMu sync.Mutex createOps opstate.Registry[*vmCreateOperationState] - imageBuildOps opstate.Registry[*imageBuildOperationState] vmLocks vmLockSet sessions sessionRegistry tapPool tapPool @@ -51,7 +50,6 @@ type Daemon struct { webURL string vmDNS *vmdns.Server vmCaps []vmCapability - imageBuild func(context.Context, imageBuildSpec) error pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) finalizePulledRootfs func(ctx context.Context, ext4File string, meta imagepull.Metadata) error bundleFetch func(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) @@ -483,34 +481,6 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } image, err := d.FindImage(ctx, params.IDOrName) return marshalResultOrError(api.ImageShowResult{Image: image}, err) - case "image.build": - params, err := rpc.DecodeParams[api.ImageBuildParams](req) - if err != nil { - return rpc.NewError("bad_request", err.Error()) - } - image, err := d.BuildImage(ctx, params) - return marshalResultOrError(api.ImageShowResult{Image: image}, err) - case "image.build.begin": - params, err := rpc.DecodeParams[api.ImageBuildParams](req) - if err != nil { - return rpc.NewError("bad_request", err.Error()) - } - op, err := d.BeginImageBuild(ctx, params) - return marshalResultOrError(api.ImageBuildBeginResult{Operation: op}, err) - case "image.build.status": - params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req) - if err != nil { - return rpc.NewError("bad_request", err.Error()) - } - op, err := d.ImageBuildStatus(ctx, params.ID) - return marshalResultOrError(api.ImageBuildStatusResult{Operation: op}, err) - case "image.build.cancel": - params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req) - if err != nil { - return rpc.NewError("bad_request", err.Error()) - } - err = d.CancelImageBuild(ctx, params.ID) - return marshalResultOrError(api.Empty{}, err) case "image.register": params, err := rpc.DecodeParams[api.ImageRegisterParams](req) if err != nil { @@ -594,7 +564,6 @@ func (d *Daemon) backgroundLoop() { d.logger.Error("background stale sweep failed", "error", err.Error()) } d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute)) - d.pruneImageBuildOperations(time.Now().Add(-10 * time.Minute)) } } } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index af8058d..e0da9ff 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -16,19 +16,6 @@ import ( "banger/internal/system" ) -func TestBuildImageRequiresFromImage(t *testing.T) { - d := &Daemon{ - layout: paths.Layout{ImagesDir: t.TempDir(), StateDir: t.TempDir()}, - store: openDaemonStore(t), - runner: system.NewRunner(), - } - - _, err := d.BuildImage(context.Background(), api.ImageBuildParams{Name: "missing-base"}) - if err == nil || !strings.Contains(err.Error(), "from-image is required") { - t.Fatalf("BuildImage() error = %v", err) - } -} - func TestRegisterImageRequiresKernel(t *testing.T) { rootfs := filepath.Join(t.TempDir(), "rootfs.ext4") if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil { diff --git a/internal/daemon/doc.go b/internal/daemon/doc.go index 0f66d4b..2a4c184 100644 --- a/internal/daemon/doc.go +++ b/internal/daemon/doc.go @@ -12,7 +12,7 @@ // Subpackages: // // internal/daemon/opstate Generic Registry[T AsyncOp] for async -// operations (VM create, image build). +// operations (VM create). // internal/daemon/dmsnap Device-mapper COW snapshot lifecycle. // internal/daemon/fcproc Firecracker process helpers: bridge/tap, // binary resolution, PID lookup, wait/kill. @@ -46,8 +46,7 @@ // Image management (in this package): // // images.go register, promote, delete, find, list -// imagebuild.go orchestrates the transient firecracker build VM -// image_build_ops.go async begin/status/cancel (uses opstate.Registry) +// images_pull.go image pull: catalog (bundle) + OCI paths // image_seed.go managed work-seed SSH fingerprint refresh // // Guest interaction (in this package): diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index b29c312..6a53494 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -2,10 +2,10 @@ package daemon import ( "context" - "database/sql" "strings" "banger/internal/config" + "banger/internal/imagecat" "banger/internal/model" "banger/internal/paths" "banger/internal/store" @@ -41,7 +41,6 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report { report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") d.addCapabilityDoctorChecks(ctx, &report) - report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available") return report } @@ -56,44 +55,38 @@ func (d *Daemon) runtimeChecks() *system.Preflight { checks.Addf("%v", err) } if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" { - image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName) - switch { - case err == nil: + name := d.config.DefaultImageName + image, err := d.store.GetImageByName(context.Background(), name) + if err == nil { checks.RequireFile(image.RootfsPath, "default image rootfs", `re-register or rebuild the default image`) checks.RequireFile(image.KernelPath, "default image kernel", `re-register or rebuild the default image`) if strings.TrimSpace(image.InitrdPath) != "" { checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`) } - case err != nil && err != sql.ErrNoRows: - checks.Addf("failed to inspect default image %q: %v", d.config.DefaultImageName, err) - default: - checks.Addf("default image %q is not registered", d.config.DefaultImageName) + } else if !defaultImageInCatalog(name) { + checks.Addf("default image %q is not registered and not in the imagecat catalog", name) } + // If the default image isn't local but is cataloged, vm create + // will auto-pull it on first use — no error to surface. } return checks } +func defaultImageInCatalog(name string) bool { + catalog, err := imagecat.LoadEmbedded() + if err != nil { + return false + } + _, err = catalog.Lookup(name) + return err == nil +} + func (d *Daemon) coreVMLifecycleChecks() *system.Preflight { checks := system.NewPreflight() d.addBaseStartCommandPrereqs(checks) return checks } -func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight { - checks := system.NewPreflight() - if d.store == nil || strings.TrimSpace(d.config.DefaultImageName) == "" { - checks.Addf("default image is not available for build inheritance") - return checks - } - image, err := d.store.GetImageByName(ctx, d.config.DefaultImageName) - if err != nil { - checks.Addf("default image %q is not registered", d.config.DefaultImageName) - return checks - } - d.addImageBuildPrereqs(ctx, checks, image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, "") - return checks -} - func (d *Daemon) vsockChecks() *system.Preflight { checks := system.NewPreflight() if helper, err := d.vsockAgentBinary(); err == nil { diff --git a/internal/daemon/image_build_ops.go b/internal/daemon/image_build_ops.go deleted file mode 100644 index b4d83e1..0000000 --- a/internal/daemon/image_build_ops.go +++ /dev/null @@ -1,202 +0,0 @@ -package daemon - -import ( - "context" - "fmt" - "strings" - "sync" - "time" - - "banger/internal/api" - "banger/internal/model" -) - -func (op *imageBuildOperationState) ID() string { return op.snapshot().ID } -func (op *imageBuildOperationState) IsDone() bool { return op.snapshot().Done } -func (op *imageBuildOperationState) UpdatedAt() time.Time { return op.snapshot().UpdatedAt } -func (op *imageBuildOperationState) Cancel() { op.cancelOperation() } - -type imageBuildProgressKey struct{} - -type imageBuildOperationState struct { - mu sync.Mutex - cancel context.CancelFunc - op api.ImageBuildOperation -} - -func newImageBuildOperationState() (*imageBuildOperationState, error) { - id, err := model.NewID() - if err != nil { - return nil, err - } - now := model.Now() - return &imageBuildOperationState{ - op: api.ImageBuildOperation{ - ID: id, - Stage: "queued", - Detail: "waiting to start", - StartedAt: now, - UpdatedAt: now, - }, - }, nil -} - -func withImageBuildProgress(ctx context.Context, op *imageBuildOperationState) context.Context { - if op == nil { - return ctx - } - return context.WithValue(ctx, imageBuildProgressKey{}, op) -} - -func imageBuildProgressFromContext(ctx context.Context) *imageBuildOperationState { - if ctx == nil { - return nil - } - op, _ := ctx.Value(imageBuildProgressKey{}).(*imageBuildOperationState) - return op -} - -func imageBuildStage(ctx context.Context, stage, detail string) { - if op := imageBuildProgressFromContext(ctx); op != nil { - op.stage(stage, detail) - } -} - -func imageBuildBindImage(ctx context.Context, image model.Image) { - if op := imageBuildProgressFromContext(ctx); op != nil { - op.bindImage(image) - } -} - -func imageBuildSetLogPath(ctx context.Context, path string) { - if op := imageBuildProgressFromContext(ctx); op != nil { - op.setLogPath(path) - } -} - -func (op *imageBuildOperationState) setCancel(cancel context.CancelFunc) { - op.mu.Lock() - defer op.mu.Unlock() - op.cancel = cancel -} - -func (op *imageBuildOperationState) setLogPath(path string) { - op.mu.Lock() - defer op.mu.Unlock() - op.op.BuildLogPath = strings.TrimSpace(path) - op.op.UpdatedAt = model.Now() -} - -func (op *imageBuildOperationState) bindImage(image model.Image) { - op.mu.Lock() - defer op.mu.Unlock() - op.op.ImageID = image.ID - op.op.ImageName = image.Name -} - -func (op *imageBuildOperationState) stage(stage, detail string) { - op.mu.Lock() - defer op.mu.Unlock() - stage = strings.TrimSpace(stage) - detail = strings.TrimSpace(detail) - if stage == "" { - stage = op.op.Stage - } - if stage == op.op.Stage && detail == op.op.Detail { - return - } - op.op.Stage = stage - op.op.Detail = detail - op.op.UpdatedAt = model.Now() -} - -func (op *imageBuildOperationState) done(image model.Image) { - op.mu.Lock() - defer op.mu.Unlock() - imageCopy := image - op.op.ImageID = image.ID - op.op.ImageName = image.Name - op.op.Stage = "ready" - op.op.Detail = "image is ready" - op.op.Done = true - op.op.Success = true - op.op.Error = "" - op.op.Image = &imageCopy - op.op.UpdatedAt = model.Now() -} - -func (op *imageBuildOperationState) fail(err error) { - op.mu.Lock() - defer op.mu.Unlock() - op.op.Done = true - op.op.Success = false - if err != nil { - op.op.Error = err.Error() - } - if strings.TrimSpace(op.op.Detail) == "" { - op.op.Detail = "image build failed" - } - op.op.UpdatedAt = model.Now() -} - -func (op *imageBuildOperationState) snapshot() api.ImageBuildOperation { - op.mu.Lock() - defer op.mu.Unlock() - snapshot := op.op - if snapshot.Image != nil { - imageCopy := *snapshot.Image - snapshot.Image = &imageCopy - } - return snapshot -} - -func (op *imageBuildOperationState) cancelOperation() { - op.mu.Lock() - cancel := op.cancel - op.mu.Unlock() - if cancel != nil { - cancel() - } -} - -func (d *Daemon) BeginImageBuild(_ context.Context, params api.ImageBuildParams) (api.ImageBuildOperation, error) { - op, err := newImageBuildOperationState() - if err != nil { - return api.ImageBuildOperation{}, err - } - buildCtx, cancel := context.WithCancel(context.Background()) - op.setCancel(cancel) - d.imageBuildOps.Insert(op) - go d.runImageBuildOperation(withImageBuildProgress(buildCtx, op), op, params) - return op.snapshot(), nil -} - -func (d *Daemon) runImageBuildOperation(ctx context.Context, op *imageBuildOperationState, params api.ImageBuildParams) { - image, err := d.BuildImage(ctx, params) - if err != nil { - op.fail(err) - return - } - op.done(image) -} - -func (d *Daemon) ImageBuildStatus(_ context.Context, id string) (api.ImageBuildOperation, error) { - op, ok := d.imageBuildOps.Get(strings.TrimSpace(id)) - if !ok { - return api.ImageBuildOperation{}, fmt.Errorf("image build operation not found: %s", id) - } - return op.snapshot(), nil -} - -func (d *Daemon) CancelImageBuild(_ context.Context, id string) error { - op, ok := d.imageBuildOps.Get(strings.TrimSpace(id)) - if !ok { - return fmt.Errorf("image build operation not found: %s", id) - } - op.cancelOperation() - return nil -} - -func (d *Daemon) pruneImageBuildOperations(olderThan time.Time) { - d.imageBuildOps.Prune(olderThan) -} diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go deleted file mode 100644 index d248205..0000000 --- a/internal/daemon/imagebuild.go +++ /dev/null @@ -1,225 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "banger/internal/daemon/imagemgr" - "banger/internal/firecracker" - "banger/internal/guest" - "banger/internal/hostnat" - "banger/internal/model" - "banger/internal/system" - "banger/internal/vsockagent" - "strings" -) - -type imageBuildSpec struct { - ID string - Name string - SourceRootfs string - RootfsPath string - BuildLog io.Writer - KernelPath string - InitrdPath string - ModulesDir string - Packages []string - InstallDocker bool - Size string -} - -type imageBuildVM struct { - Name string - GuestIP string - TapDevice string - APISock string - PID int -} - -func (d *Daemon) runImageBuild(ctx context.Context, spec imageBuildSpec) error { - if d.imageBuild != nil { - return d.imageBuild(ctx, spec) - } - return d.runImageBuildNative(ctx, spec) -} - -func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (err error) { - if err := system.CopyFilePreferClone(spec.SourceRootfs, spec.RootfsPath); err != nil { - return err - } - if spec.Size != "" { - if err := imagemgr.ResizeRootfs(spec.SourceRootfs, spec.RootfsPath, spec.Size); err != nil { - return err - } - } - - vm, cleanup, err := d.startImageBuildVM(ctx, spec) - if err != nil { - return err - } - defer func() { - cleanupErr := cleanup(context.Background()) - if cleanupErr != nil { - err = errors.Join(err, cleanupErr) - } - }() - - sshAddress := vm.GuestIP + ":22" - if _, err := fmt.Fprintf(spec.BuildLog, "[image.build] waiting for ssh on %s\n", sshAddress); err != nil { - return err - } - waitCtx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - if err := guest.WaitForSSH(waitCtx, sshAddress, d.config.SSHKeyPath, time.Second); err != nil { - return err - } - - client, err := guest.Dial(ctx, sshAddress, d.config.SSHKeyPath) - if err != nil { - return err - } - defer client.Close() - authorizedKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) - if err != nil { - return err - } - - vsockAgentPath, err := d.vsockAgentBinary() - if err != nil { - return err - } - helperBytes, err := os.ReadFile(vsockAgentPath) - if err != nil { - return err - } - if err := imagemgr.WriteBuildLog(spec.BuildLog, "installing vsock agent"); err != nil { - return err - } - if err := client.UploadFile(ctx, vsockagent.GuestInstallPath, 0o755, helperBytes, spec.BuildLog); err != nil { - return err - } - if err := imagemgr.WriteBuildLog(spec.BuildLog, "configuring guest"); err != nil { - return err - } - if err := client.RunScript(ctx, imagemgr.BuildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), spec.Packages, spec.InstallDocker), spec.BuildLog); err != nil { - return err - } - if strings.TrimSpace(spec.ModulesDir) != "" { - if err := imagemgr.WriteBuildLog(spec.BuildLog, "copying kernel modules"); err != nil { - return err - } - if err := client.StreamTar(ctx, spec.ModulesDir, imagemgr.BuildModulesCommand(filepath.Base(spec.ModulesDir)), spec.BuildLog); err != nil { - return err - } - } - if err := imagemgr.WriteBuildLog(spec.BuildLog, "shutting down guest"); err != nil { - return err - } - if err := client.RunScript(ctx, "set -e\nsync\n", spec.BuildLog); err != nil { - return err - } - return d.shutdownImageBuildVM(ctx, vm) -} - -func (d *Daemon) startImageBuildVM(ctx context.Context, spec imageBuildSpec) (imageBuildVM, func(context.Context) error, error) { - if err := d.ensureBridge(ctx); err != nil { - return imageBuildVM{}, nil, err - } - if err := d.ensureSocketDir(); err != nil { - return imageBuildVM{}, nil, err - } - fcPath, err := d.firecrackerBinary() - if err != nil { - return imageBuildVM{}, nil, err - } - - shortID := system.ShortID(spec.ID) - guestIP, err := d.store.NextGuestIP(ctx, bridgePrefix(d.config.BridgeIP)) - if err != nil { - return imageBuildVM{}, nil, err - } - vm := imageBuildVM{ - Name: "image-build-" + shortID, - GuestIP: guestIP, - TapDevice: "tap-img-" + shortID, - APISock: filepath.Join(d.layout.RuntimeDir, "img-"+shortID+".sock"), - } - if err := os.RemoveAll(vm.APISock); err != nil && !os.IsNotExist(err) { - return imageBuildVM{}, nil, err - } - if err := d.createTap(ctx, vm.TapDevice); err != nil { - return imageBuildVM{}, nil, err - } - if err := hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, true); err != nil { - _, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice) - return imageBuildVM{}, nil, err - } - - firecrackerCtx := context.Background() - machine, err := firecracker.NewMachine(firecrackerCtx, firecracker.MachineConfig{ - BinaryPath: fcPath, - VMID: spec.ID, - SocketPath: vm.APISock, - LogPath: spec.RootfsPath + ".firecracker.log", - MetricsPath: filepath.Join(filepath.Dir(spec.RootfsPath), "metrics.json"), - KernelImagePath: spec.KernelPath, - InitrdPath: spec.InitrdPath, - KernelArgs: system.BuildBootArgsWithKernelIP(vm.Name, vm.GuestIP, d.config.BridgeIP, d.config.DefaultDNS), - Drives: []firecracker.DriveConfig{{ - ID: "rootfs", - Path: spec.RootfsPath, - ReadOnly: false, - IsRoot: true, - }}, - TapDevice: vm.TapDevice, - VCPUCount: model.DefaultVCPUCount, - MemoryMiB: model.DefaultMemoryMiB, - Logger: d.logger, - }) - if err != nil { - _ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false) - _, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice) - return imageBuildVM{}, nil, err - } - if err := machine.Start(firecrackerCtx); err != nil { - _ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false) - _, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice) - return imageBuildVM{}, nil, err - } - vm.PID = d.resolveFirecrackerPID(firecrackerCtx, machine, vm.APISock) - if err := d.ensureSocketAccess(ctx, vm.APISock, "firecracker api socket"); err != nil { - _ = d.killVMProcess(context.Background(), vm.PID) - _ = hostnat.Ensure(ctx, d.runner, vm.GuestIP, vm.TapDevice, false) - _, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.TapDevice) - return imageBuildVM{}, nil, err - } - - cleanup := func(cleanupCtx context.Context) error { - if vm.PID > 0 && system.ProcessRunning(vm.PID, vm.APISock) { - _ = d.killVMProcess(cleanupCtx, vm.PID) - _ = d.waitForExit(cleanupCtx, vm.PID, vm.APISock, 10*time.Second) - } - _ = hostnat.Ensure(cleanupCtx, d.runner, vm.GuestIP, vm.TapDevice, false) - if vm.TapDevice != "" { - _, _ = d.runner.RunSudo(cleanupCtx, "ip", "link", "del", vm.TapDevice) - } - if vm.APISock != "" { - _ = os.Remove(vm.APISock) - } - return nil - } - return vm, cleanup, nil -} - -func (d *Daemon) shutdownImageBuildVM(ctx context.Context, vm imageBuildVM) error { - buildVM := model.VMRecord{Runtime: model.VMRuntime{APISockPath: vm.APISock}} - if err := d.sendCtrlAltDel(ctx, buildVM); err != nil { - return err - } - return d.waitForExit(ctx, vm.PID, vm.APISock, 15*time.Second) -} diff --git a/internal/daemon/imagebuild_test.go b/internal/daemon/imagebuild_test.go deleted file mode 100644 index 6ad8731..0000000 --- a/internal/daemon/imagebuild_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package daemon - -import ( - "strings" - "testing" - - "banger/internal/daemon/imagemgr" -) - -func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) { - t.Parallel() - - script := imagemgr.BuildProvisionScript("devbox", "1.1.1.1", "ssh-ed25519 AAAATESTKEY banger", []string{"git", "curl"}, false) - for _, snippet := range []string{ - "mkdir -p /root/.ssh", - "cat > /root/.ssh/authorized_keys <<'EOF'", - "ssh-ed25519 AAAATESTKEY banger", - "cat > /usr/local/libexec/banger-network-bootstrap <<'EOF'", - "ip addr replace \"$guest_ip/$prefix\" dev \"$iface\"", - "cat > /etc/systemd/system/banger-network.service <<'EOF'", - "systemctl enable --now banger-network.service || true", - "curl -fsSL https://mise.run | MISE_INSTALL_PATH='/usr/local/bin/mise' MISE_VERSION='v2025.12.0' sh", - "'/usr/local/bin/mise' use -g 'node@22'", - "'/usr/local/bin/mise' use -g 'github:anomalyco/opencode'", - "'/usr/local/bin/mise' use -g 'npm:@anthropic-ai/claude-code'", - "'/usr/local/bin/mise' use -g 'npm:@mariozechner/pi-coding-agent'", - "'/usr/local/bin/mise' reshim", - "if [[ ! -e '/root/.local/share/mise/shims/node' ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi", - "if [[ ! -e '/root/.local/share/mise/shims/npm' ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi", - "if [[ ! -e '/root/.local/share/mise/shims/opencode' ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi", - "if [[ ! -e '/root/.local/share/mise/shims/claude' ]]; then echo 'claude shim not found after mise install' >&2; exit 1; fi", - "if [[ ! -e '/root/.local/share/mise/shims/pi' ]]; then echo 'pi shim not found after mise install' >&2; exit 1; fi", - "ln -snf '/root/.local/share/mise/shims/node' '/usr/local/bin/node'", - "ln -snf '/root/.local/share/mise/shims/npm' '/usr/local/bin/npm'", - "ln -snf '/root/.local/share/mise/shims/opencode' '/usr/local/bin/opencode'", - "ln -snf '/root/.local/share/mise/shims/claude' '/usr/local/bin/claude'", - "ln -snf '/root/.local/share/mise/shims/pi' '/usr/local/bin/pi'", - "cat > /etc/profile.d/mise.sh <<'EOF'", - "if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then", - `eval "$(/usr/local/bin/mise activate bash)"`, - `if ! grep -Fqx 'eval "$(/usr/local/bin/mise activate bash)"' '/etc/bash.bashrc'; then`, - "cat > /etc/systemd/system/banger-opencode.service <<'EOF'", - "RequiresMountsFor=/root", - "ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", - "systemctl enable --now banger-opencode.service || true", - `git clone --depth 1 'https://github.com/tmux-plugins/tpm' "$TMUX_PLUGIN_DIR/tpm"`, - `git clone --depth 1 'https://github.com/tmux-plugins/tmux-resurrect' "$TMUX_PLUGIN_DIR/tmux-resurrect"`, - `git clone --depth 1 'https://github.com/tmux-plugins/tmux-continuum' "$TMUX_PLUGIN_DIR/tmux-continuum"`, - "# >>> banger tmux plugins >>>", - "set -g @plugin 'tmux-plugins/tmux-resurrect'", - "set -g @plugin 'tmux-plugins/tmux-continuum'", - "set -g @continuum-save-interval '15'", - "set -g @continuum-restore 'off'", - "set -g @resurrect-dir '/root/.tmux/resurrect'", - "run '~/.tmux/plugins/tpm/tpm'", - "cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'", - "vmw_vsock_virtio_transport", - "cat > /etc/systemd/system/banger-vsock-agent.service <<'EOF'", - "ExecStart=/usr/local/bin/banger-vsock-agent", - "systemctl enable --now banger-vsock-agent.service || true", - "rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh", - } { - if !strings.Contains(script, snippet) { - t.Fatalf("BuildProvisionScript missing snippet %q\nscript:\n%s", snippet, script) - } - } -} diff --git a/internal/daemon/imagemgr/build.go b/internal/daemon/imagemgr/build.go deleted file mode 100644 index 51a338d..0000000 --- a/internal/daemon/imagemgr/build.go +++ /dev/null @@ -1,247 +0,0 @@ -package imagemgr - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "strings" - - "banger/internal/guestnet" - "banger/internal/model" - "banger/internal/opencode" - "banger/internal/system" - "banger/internal/vsockagent" -) - -const ( - defaultMiseVersion = "v2025.12.0" - defaultMiseInstallPath = "/usr/local/bin/mise" - defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"` - defaultNodeTool = "node@22" - defaultOpenCodeTool = "github:anomalyco/opencode" - defaultClaudeCodeTool = "npm:@anthropic-ai/claude-code" - defaultPiTool = "npm:@mariozechner/pi-coding-agent" - defaultTPMRepo = "https://github.com/tmux-plugins/tpm" - defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect" - defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum" - defaultTMUXPluginDir = "/root/.tmux/plugins" - defaultTMUXResurrectDir = "/root/.tmux/resurrect" - tmuxManagedBlockStart = "# >>> banger tmux plugins >>>" - tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<" -) - -// ResizeRootfs grows a rootfs ext4 image to sizeSpec bytes. sizeSpec must -// parse via model.ParseSize and must be >= the base image size. -func ResizeRootfs(baseRootfs, rootfsPath, sizeSpec string) error { - sizeBytes, err := model.ParseSize(sizeSpec) - if err != nil { - return err - } - info, err := os.Stat(baseRootfs) - if err != nil { - return err - } - if sizeBytes < info.Size() { - return fmt.Errorf("size must be >= base image size") - } - return system.ResizeExt4Image(context.Background(), system.NewRunner(), rootfsPath, sizeBytes) -} - -// WriteBuildLog emits a prefixed status line to w. Safe on a nil writer. -func WriteBuildLog(w io.Writer, message string) error { - if w == nil { - return nil - } - _, err := fmt.Fprintf(w, "[image.build] %s\n", message) - return err -} - -// BuildProvisionScript returns the bash script that configures a freshly -// booted build VM: host/dns files, authorized key, apt packages, mise + -// language shims, guest network unit, opencode service, tmux plugins, -// vsock agent, optional docker, and cleanup. -func BuildProvisionScript(vmName, dnsServer, authorizedKey string, packages []string, installDocker bool) string { - var script bytes.Buffer - script.WriteString("set -euo pipefail\n") - fmt.Fprintf(&script, "printf 'nameserver %%s\\n' %s > /etc/resolv.conf\n", shellQuote(dnsServer)) - fmt.Fprintf(&script, "printf '%%s\\n' %s > /etc/hostname\n", shellQuote(vmName)) - fmt.Fprintf(&script, "printf '127.0.0.1 localhost\\n127.0.1.1 %%s\\n' %s > /etc/hosts\n", shellQuote(vmName)) - script.WriteString("touch /etc/fstab\n") - script.WriteString("sed -i '\\|^/dev/vdb[[:space:]]\\+/home[[:space:]]|d; \\|^/dev/vdc[[:space:]]\\+/var[[:space:]]|d' /etc/fstab\n") - script.WriteString("if ! grep -q '^tmpfs /run ' /etc/fstab; then echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab; fi\n") - script.WriteString("if ! grep -q '^tmpfs /tmp ' /etc/fstab; then echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab; fi\n") - appendAuthorizedKeySetup(&script, authorizedKey) - script.WriteString("apt-get update\n") - script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y upgrade\n") - fmt.Fprintf(&script, "PACKAGES=%s\n", shellArray(packages)) - script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n") - appendGuestNetworkSetup(&script) - appendMiseSetup(&script) - appendOpenCodeServiceSetup(&script) - appendTmuxSetup(&script) - appendVSockPingSetup(&script) - if installDocker { - script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true\n") - script.WriteString("if ! DEBIAN_FRONTEND=noninteractive apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then\n") - script.WriteString(" DEBIAN_FRONTEND=noninteractive apt-get -y install docker.io\n") - script.WriteString("fi\n") - script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker || true; fi\n") - } - appendGuestCleanup(&script) - script.WriteString("git config --system init.defaultBranch main\n") - return script.String() -} - -// BuildModulesCommand returns the guest shell command that receives a tar -// stream on stdin, extracts it into /lib/modules/, runs depmod, -// and writes sysctl/modules-load config for docker networking. -func BuildModulesCommand(modulesBase string) string { - return fmt.Sprintf("bash -se <<'EOF'\nset -euo pipefail\nmkdir -p /lib/modules\ntar -C /lib/modules -xf -\ndepmod -a %s\nmkdir -p /etc/modules-load.d\nprintf 'nf_tables\\nnft_chain_nat\\nveth\\nbr_netfilter\\noverlay\\n' > /etc/modules-load.d/docker-netfilter.conf\nmkdir -p /etc/sysctl.d\ncat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'\nnet.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\nSYSCTL\nsysctl --system >/dev/null 2>&1 || true\nEOF", shellQuote(modulesBase)) -} - -func appendAuthorizedKeySetup(script *bytes.Buffer, authorizedKey string) { - script.WriteString("mkdir -p /root/.ssh\n") - script.WriteString("chmod 700 /root/.ssh\n") - script.WriteString("cat > /root/.ssh/authorized_keys <<'EOF'\n") - script.WriteString(strings.TrimSpace(authorizedKey)) - script.WriteString("\nEOF\n") - script.WriteString("chmod 600 /root/.ssh/authorized_keys\n") -} - -func appendMiseSetup(script *bytes.Buffer) { - const ( - nodeShimPath = "/root/.local/share/mise/shims/node" - npmShimPath = "/root/.local/share/mise/shims/npm" - claudeShimPath = "/root/.local/share/mise/shims/claude" - piShimPath = "/root/.local/share/mise/shims/pi" - ) - - fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion)) - fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultNodeTool)) - fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool)) - fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultClaudeCodeTool)) - fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultPiTool)) - fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath)) - fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi\n", shellQuote(nodeShimPath)) - fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi\n", shellQuote(npmShimPath)) - fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath)) - fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'claude shim not found after mise install' >&2; exit 1; fi\n", shellQuote(claudeShimPath)) - fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'pi shim not found after mise install' >&2; exit 1; fi\n", shellQuote(piShimPath)) - fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(nodeShimPath), shellQuote("/usr/local/bin/node")) - fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(npmShimPath), shellQuote("/usr/local/bin/npm")) - fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath)) - fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(claudeShimPath), shellQuote("/usr/local/bin/claude")) - fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(piShimPath), shellQuote("/usr/local/bin/pi")) - script.WriteString("mkdir -p /etc/profile.d\n") - script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n") - fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath)) - fmt.Fprintf(script, " %s\n", defaultMiseActivateLine) - script.WriteString("fi\n") - script.WriteString("EOF\n") - script.WriteString("chmod 0644 /etc/profile.d/mise.sh\n") - appendLineIfMissing(script, "/etc/bash.bashrc", defaultMiseActivateLine) -} - -func appendGuestNetworkSetup(script *bytes.Buffer) { - script.WriteString("mkdir -p /usr/local/libexec /etc/systemd/system\n") - script.WriteString("cat > " + guestnet.GuestScriptPath + " <<'EOF'\n") - script.WriteString(guestnet.BootstrapScript()) - script.WriteString("EOF\n") - script.WriteString("chmod 0755 " + guestnet.GuestScriptPath + "\n") - script.WriteString("cat > /etc/systemd/system/" + guestnet.SystemdServiceName + " <<'EOF'\n") - script.WriteString(guestnet.SystemdServiceUnit()) - script.WriteString("EOF\n") - script.WriteString("chmod 0644 /etc/systemd/system/" + guestnet.SystemdServiceName + "\n") - script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + guestnet.SystemdServiceName + " || true; fi\n") -} - -func appendOpenCodeServiceSetup(script *bytes.Buffer) { - script.WriteString("mkdir -p /etc/systemd/system\n") - script.WriteString("cat > /etc/systemd/system/" + opencode.ServiceName + " <<'EOF'\n") - script.WriteString(opencode.ServiceUnit()) - script.WriteString("EOF\n") - script.WriteString("chmod 0644 /etc/systemd/system/" + opencode.ServiceName + "\n") - script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + opencode.ServiceName + " || true; fi\n") -} - -func appendTmuxSetup(script *bytes.Buffer) { - fmt.Fprintf(script, "TMUX_PLUGIN_DIR=%s\n", shellQuote(defaultTMUXPluginDir)) - fmt.Fprintf(script, "TMUX_RESURRECT_DIR=%s\n", shellQuote(defaultTMUXResurrectDir)) - script.WriteString("mkdir -p \"$TMUX_PLUGIN_DIR\" \"$TMUX_RESURRECT_DIR\"\n") - appendGitRepo(script, "$TMUX_PLUGIN_DIR/tpm", defaultTPMRepo) - appendGitRepo(script, "$TMUX_PLUGIN_DIR/tmux-resurrect", defaultResurrectRepo) - appendGitRepo(script, "$TMUX_PLUGIN_DIR/tmux-continuum", defaultContinuumRepo) - script.WriteString("TMUX_CONF=/root/.tmux.conf\n") - fmt.Fprintf(script, "TMUX_MANAGED_START=%s\n", shellQuote(tmuxManagedBlockStart)) - fmt.Fprintf(script, "TMUX_MANAGED_END=%s\n", shellQuote(tmuxManagedBlockEnd)) - script.WriteString("tmp_tmux_conf=$(mktemp)\n") - script.WriteString("if [[ -f \"$TMUX_CONF\" ]]; then\n") - script.WriteString(" awk -v begin=\"$TMUX_MANAGED_START\" -v end=\"$TMUX_MANAGED_END\" '$0 == begin { skip = 1; next } $0 == end { skip = 0; next } !skip { print }' \"$TMUX_CONF\" > \"$tmp_tmux_conf\"\n") - script.WriteString("else\n") - script.WriteString(" : > \"$tmp_tmux_conf\"\n") - script.WriteString("fi\n") - script.WriteString("if [[ -s \"$tmp_tmux_conf\" ]]; then\n") - script.WriteString(" printf '\\n' >> \"$tmp_tmux_conf\"\n") - script.WriteString("fi\n") - script.WriteString("cat >> \"$tmp_tmux_conf\" <<'EOF'\n") - script.WriteString(tmuxManagedBlockStart + "\n") - script.WriteString("set -g @plugin 'tmux-plugins/tpm'\n") - script.WriteString("set -g @plugin 'tmux-plugins/tmux-resurrect'\n") - script.WriteString("set -g @plugin 'tmux-plugins/tmux-continuum'\n") - script.WriteString("set -g @continuum-save-interval '15'\n") - script.WriteString("set -g @continuum-restore 'off'\n") - script.WriteString("set -g @resurrect-dir '/root/.tmux/resurrect'\n") - script.WriteString("run '~/.tmux/plugins/tpm/tpm'\n") - script.WriteString(tmuxManagedBlockEnd + "\n") - script.WriteString("EOF\n") - script.WriteString("mv \"$tmp_tmux_conf\" \"$TMUX_CONF\"\n") - script.WriteString("chmod 0644 \"$TMUX_CONF\"\n") -} - -func appendVSockPingSetup(script *bytes.Buffer) { - script.WriteString("mkdir -p /etc/modules-load.d /etc/systemd/system\n") - script.WriteString("cat > /etc/modules-load.d/banger-vsock.conf <<'EOF'\n") - script.WriteString(vsockagent.ModulesLoadConfig()) - script.WriteString("EOF\n") - script.WriteString("chmod 0644 /etc/modules-load.d/banger-vsock.conf\n") - script.WriteString("cat > /etc/systemd/system/" + vsockagent.ServiceName + " <<'EOF'\n") - script.WriteString(vsockagent.ServiceUnit()) - script.WriteString("EOF\n") - script.WriteString("chmod 0644 /etc/systemd/system/" + vsockagent.ServiceName + "\n") - script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + vsockagent.ServiceName + " || true; fi\n") -} - -func appendGitRepo(script *bytes.Buffer, dir, repo string) { - fmt.Fprintf(script, "if [[ -d \"%s/.git\" ]]; then\n", dir) - fmt.Fprintf(script, " git -C \"%s\" fetch --depth 1 origin\n", dir) - fmt.Fprintf(script, " git -C \"%s\" reset --hard FETCH_HEAD\n", dir) - script.WriteString("else\n") - fmt.Fprintf(script, " rm -rf \"%s\"\n", dir) - fmt.Fprintf(script, " git clone --depth 1 %s \"%s\"\n", shellQuote(repo), dir) - script.WriteString("fi\n") -} - -func appendGuestCleanup(script *bytes.Buffer) { - script.WriteString("rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh\n") -} - -func appendLineIfMissing(script *bytes.Buffer, path, line string) { - fmt.Fprintf(script, "touch %s\n", shellQuote(path)) - fmt.Fprintf(script, "if ! grep -Fqx %s %s; then\n", shellQuote(line), shellQuote(path)) - fmt.Fprintf(script, " printf '\\n%%s\\n' %s >> %s\n", shellQuote(line), shellQuote(path)) - script.WriteString("fi\n") -} - -func shellArray(values []string) string { - quoted := make([]string, 0, len(values)) - for _, value := range values { - quoted = append(quoted, shellQuote(value)) - } - return "(" + strings.Join(quoted, " ") + ")" -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" -} diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 293aa1f..58d431f 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -16,146 +16,6 @@ import ( "banger/internal/system" ) -func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (image model.Image, err error) { - d.imageOpsMu.Lock() - defer d.imageOpsMu.Unlock() - op := d.beginOperation("image.build") - buildLogPath := "" - defer func() { - if err != nil { - err = annotateLogPath(err, buildLogPath) - op.fail(err, imageLogAttrs(image)...) - return - } - op.done(imageLogAttrs(image)...) - }() - - name := params.Name - imageBuildStage(ctx, "resolve_image", "resolving image build inputs") - if name == "" { - name = fmt.Sprintf("image-%d", model.Now().Unix()) - } - if _, err := d.FindImage(ctx, name); err == nil { - return model.Image{}, fmt.Errorf("image name already exists: %s", name) - } - fromImage := strings.TrimSpace(params.FromImage) - if fromImage == "" { - return model.Image{}, fmt.Errorf("from-image is required") - } - baseImage, err := d.FindImage(ctx, fromImage) - if err != nil { - return model.Image{}, err - } - id, err := model.NewID() - if err != nil { - return model.Image{}, err - } - now := model.Now() - artifactDir := filepath.Join(d.layout.ImagesDir, id) - buildLogDir := filepath.Join(d.layout.StateDir, "image-build") - if err := os.MkdirAll(buildLogDir, 0o755); err != nil { - return model.Image{}, err - } - buildLogPath = filepath.Join(buildLogDir, id+".log") - imageBuildSetLogPath(ctx, buildLogPath) - logFile, err := os.OpenFile(buildLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return model.Image{}, err - } - defer logFile.Close() - stageDir, err := os.MkdirTemp(d.layout.ImagesDir, id+".build-") - if err != nil { - return model.Image{}, err - } - cleanupStage := true - defer func() { - if cleanupStage { - _ = os.RemoveAll(stageDir) - } - }() - rootfsPath := filepath.Join(stageDir, "rootfs.ext4") - workSeedPath := filepath.Join(stageDir, "work-seed.ext4") - kernelSource := firstNonEmpty(params.KernelPath, baseImage.KernelPath) - initrdSource := firstNonEmpty(params.InitrdPath, baseImage.InitrdPath) - modulesSource := firstNonEmpty(params.ModulesDir, baseImage.ModulesDir) - if err := d.validateImageBuildPrereqs(ctx, baseImage.RootfsPath, kernelSource, initrdSource, modulesSource, params.Size); err != nil { - return model.Image{}, err - } - kernelPath, initrdPath, modulesDir, err := imagemgr.StageBootArtifacts(ctx, d.runner, stageDir, kernelSource, initrdSource, modulesSource) - if err != nil { - return model.Image{}, err - } - packages := imagemgr.DebianBasePackages() - metadataPackages := imagemgr.BuildMetadataPackages(params.Docker) - spec := imageBuildSpec{ - ID: id, - Name: name, - SourceRootfs: baseImage.RootfsPath, - RootfsPath: rootfsPath, - BuildLog: logFile, - KernelPath: kernelPath, - InitrdPath: initrdPath, - ModulesDir: modulesDir, - Packages: packages, - InstallDocker: params.Docker, - Size: params.Size, - } - op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir, "from_image", baseImage.Name) - imageBuildStage(ctx, "launch_builder", "building rootfs from base image") - if err := d.runImageBuild(ctx, spec); err != nil { - _ = logFile.Sync() - return model.Image{}, err - } - imageBuildStage(ctx, "prepare_work_seed", "building reusable work seed") - if err := system.BuildWorkSeedImage(ctx, d.runner, rootfsPath, workSeedPath); err != nil { - _ = logFile.Sync() - return model.Image{}, err - } - imageBuildStage(ctx, "seed_ssh", "seeding runtime SSH access") - seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) - if err != nil { - _ = logFile.Sync() - return model.Image{}, err - } - imageBuildStage(ctx, "write_metadata", "writing image metadata") - if err := imagemgr.WritePackagesMetadata(rootfsPath, metadataPackages); err != nil { - _ = logFile.Sync() - return model.Image{}, err - } - op.stage("activate_artifacts", "artifact_dir", artifactDir) - if err := os.Rename(stageDir, artifactDir); err != nil { - return model.Image{}, err - } - cleanupStage = false - image = model.Image{ - ID: id, - Name: name, - Managed: true, - ArtifactDir: artifactDir, - RootfsPath: filepath.Join(artifactDir, "rootfs.ext4"), - WorkSeedPath: filepath.Join(artifactDir, "work-seed.ext4"), - KernelPath: filepath.Join(artifactDir, "kernel"), - InitrdPath: imagemgr.StageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img"), - ModulesDir: imagemgr.StageOptionalArtifactPath(artifactDir, modulesDir, "modules"), - BuildSize: params.Size, - SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint, - Docker: params.Docker, - CreatedAt: now, - UpdatedAt: now, - } - imageBuildBindImage(ctx, image) - if err := d.store.UpsertImage(ctx, image); err != nil { - return model.Image{}, err - } - op.stage("persisted", "build_log_path", buildLogPath) - imageBuildStage(ctx, "persisted", "image metadata saved") - if d.logger != nil { - d.logger.Info("image build log preserved", append(imageLogAttrs(image), "build_log_path", buildLogPath)...) - } - _ = logFile.Sync() - return image, nil -} - func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) { d.imageOpsMu.Lock() defer d.imageOpsMu.Unlock() diff --git a/internal/daemon/logger_test.go b/internal/daemon/logger_test.go index 4ad9e29..df154ba 100644 --- a/internal/daemon/logger_test.go +++ b/internal/daemon/logger_test.go @@ -11,7 +11,6 @@ import ( "strings" "testing" - "banger/internal/api" "banger/internal/model" "banger/internal/paths" ) @@ -131,119 +130,6 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { } } -func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { - ctx := context.Background() - store := openDaemonStore(t) - stateDir := filepath.Join(t.TempDir(), "state") - imagesDir := filepath.Join(stateDir, "images") - if err := os.MkdirAll(imagesDir, 0o755); err != nil { - t.Fatalf("mkdir images dir: %v", err) - } - - binDir := t.TempDir() - for _, name := range []string{"sudo", "ip", "pgrep", "chown", "chmod", "kill", "iptables", "sysctl", "e2fsck", "resize2fs", "mkfs.ext4", "mount", "umount", "cp"} { - writeFakeExecutable(t, filepath.Join(binDir, name)) - } - t.Setenv("PATH", binDir) - - baseRootfs := filepath.Join(t.TempDir(), "base.ext4") - kernelPath := filepath.Join(t.TempDir(), "vmlinux") - sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") - firecrackerBin := filepath.Join(t.TempDir(), "firecracker") - vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent") - for _, path := range []string{baseRootfs, kernelPath, sshKeyPath} { - if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - } - if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatalf("write %s: %v", vsockHelper, err) - } - t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper) - if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatalf("write %s: %v", firecrackerBin, err) - } - runner := &scriptedRunner{ - t: t, - steps: []runnerStep{ - {call: runnerCall{name: "ip", args: []string{"route", "show", "default"}}, out: []byte("default via 192.0.2.1 dev eth0\n")}, - }, - } - - var buf bytes.Buffer - logger, _, err := newDaemonLogger(&buf, "info") - if err != nil { - t.Fatalf("newDaemonLogger: %v", err) - } - baseImage := model.Image{ - ID: "base-image", - Name: "base-image", - RootfsPath: baseRootfs, - KernelPath: kernelPath, - CreatedAt: model.Now(), - UpdatedAt: model.Now(), - } - if err := store.UpsertImage(ctx, baseImage); err != nil { - t.Fatalf("UpsertImage(base): %v", err) - } - d := &Daemon{ - layout: paths.Layout{ - StateDir: stateDir, - ImagesDir: imagesDir, - }, - config: model.DaemonConfig{ - DefaultImageName: "base-image", - SSHKeyPath: sshKeyPath, - FirecrackerBin: firecrackerBin, - }, - store: store, - runner: runner, - logger: logger, - imageBuild: func(ctx context.Context, spec imageBuildSpec) error { - if _, err := fmt.Fprintln(spec.BuildLog, "builder-stdout"); err != nil { - return err - } - if spec.SourceRootfs != baseRootfs || spec.KernelPath == kernelPath || len(spec.Packages) == 0 { - t.Fatalf("unexpected image build spec: %+v", spec) - } - return errors.New("builder failed") - }, - } - - _, err = d.BuildImage(ctx, api.ImageBuildParams{ - Name: "broken-image", - FromImage: baseImage.Name, - KernelPath: kernelPath, - }) - if err == nil || !strings.Contains(err.Error(), "inspect ") { - t.Fatalf("BuildImage() error = %v, want build log hint", err) - } - - buildLogs, globErr := filepath.Glob(filepath.Join(stateDir, "image-build", "*.log")) - if globErr != nil { - t.Fatalf("glob build logs: %v", globErr) - } - if len(buildLogs) != 1 { - t.Fatalf("build log count = %d, want 1", len(buildLogs)) - } - logData, readErr := os.ReadFile(buildLogs[0]) - if readErr != nil { - t.Fatalf("read build log: %v", readErr) - } - if !strings.Contains(string(logData), "builder-stdout") { - t.Fatalf("build log = %q, want builder output", string(logData)) - } - runner.assertExhausted() - - entries := parseLogEntries(t, buf.Bytes()) - if !hasLogEntry(entries, map[string]string{"msg": "operation stage", "operation": "image.build", "stage": "launch_builder"}) { - t.Fatalf("expected launch_builder log, got %v", entries) - } - if !strings.Contains(buf.String(), buildLogs[0]) { - t.Fatalf("daemon logs = %q, want build log path %s", buf.String(), buildLogs[0]) - } -} - func parseLogEntries(t *testing.T, data []byte) []map[string]any { t.Helper() lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) diff --git a/internal/daemon/preflight.go b/internal/daemon/preflight.go index 0d3c251..7ff9fa6 100644 --- a/internal/daemon/preflight.go +++ b/internal/daemon/preflight.go @@ -17,12 +17,6 @@ func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, im return checks.Err("vm start preflight failed") } -func (d *Daemon) validateImageBuildPrereqs(ctx context.Context, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec string) error { - checks := system.NewPreflight() - d.addImageBuildPrereqs(ctx, checks, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec) - return checks.Err("image build preflight failed") -} - func (d *Daemon) validateWorkDiskResizePrereqs() error { checks := system.NewPreflight() checks.RequireCommand("truncate", toolHint("truncate")) @@ -70,35 +64,6 @@ func (d *Daemon) addBaseStartCommandPrereqs(checks *system.Preflight) { } } -func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Preflight, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec string) { - for _, command := range []string{"sudo", "ip", "pgrep", "chown", "chmod", "kill"} { - checks.RequireCommand(command, toolHint(command)) - } - for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} { - checks.RequireCommand(command, toolHint(command)) - } - checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) - checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) - if helper, err := d.vsockAgentBinary(); err == nil { - checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) - } else { - checks.Addf("%v", err) - } - checks.RequireFile(baseRootfs, "base image rootfs", `pass --from-image with a valid registered image`) - checks.RequireFile(kernelPath, "kernel image", `pass --kernel or build from an image with a valid kernel`) - if strings.TrimSpace(initrdPath) != "" { - checks.RequireFile(initrdPath, "initrd image", `pass --initrd or build from an image with a valid initrd`) - } - if strings.TrimSpace(modulesDir) != "" { - checks.RequireDir(modulesDir, "modules directory", `pass --modules or build from an image with a valid modules dir`) - } - if strings.TrimSpace(sizeSpec) != "" { - checks.RequireCommand("e2fsck", toolHint("e2fsck")) - checks.RequireCommand("resize2fs", toolHint("resize2fs")) - } - d.addNATPrereqs(ctx, checks) -} - func toolHint(command string) string { switch command { case "ip": diff --git a/internal/model/types.go b/internal/model/types.go index b171311..bc14c3c 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -155,16 +155,6 @@ type VMSetRequest struct { NATEnabled *bool } -type ImageBuildRequest struct { - Name string - FromImage string - Size string - KernelPath string - InitrdPath string - ModulesDir string - Docker bool -} - type GuestSession struct { ID string `json:"id"` VMID string `json:"vm_id"` diff --git a/internal/webui/server.go b/internal/webui/server.go index 0199b41..19f8024 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -43,8 +43,6 @@ type Backend interface { PortsVM(context.Context, string) (api.VMPortsResult, error) ListImages(context.Context) ([]model.Image, error) FindImage(context.Context, string) (model.Image, error) - BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) - ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) PromoteImage(context.Context, string) (model.Image, error) DeleteImage(context.Context, string) (model.Image, error) @@ -84,16 +82,6 @@ type vmSetForm struct { NATEnabled bool } -type imageBuildForm struct { - Name string - FromImage string - Size string - KernelPath string - InitrdPath string - ModulesDir string - Docker bool -} - type imageRegisterForm struct { Name string RootfsPath string @@ -126,11 +114,9 @@ type pageData struct { Images []model.Image Image model.Image ImageUsers int - ImageBuildForm imageBuildForm ImageRegisterForm imageRegisterForm LogText string VMCreateOperation *api.VMCreateOperation - ImageBuildOperation *api.ImageBuildOperation OperationStatusURL string OperationSuccessURL string OperationLogPath string @@ -197,17 +183,13 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete)) mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet)) mux.HandleFunc("GET /images", s.wrap(s.handleImageList)) - mux.HandleFunc("GET /images/build", s.wrap(s.handleImageBuildForm)) - mux.HandleFunc("POST /images/build", s.wrap(s.handleImageBuild)) mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm)) mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister)) mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow)) mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote)) mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete)) mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage)) - mux.HandleFunc("GET /operations/image-build/{id}", s.wrap(s.handleImageBuildOperationPage)) mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI)) - mux.HandleFunc("GET /api/operations/image-build/{id}", s.wrap(s.handleImageBuildOperationAPI)) mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI)) } @@ -522,42 +504,6 @@ func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error { }) } -func (s *Server) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error { - return s.renderImageBuildPage(w, r, imageBuildForm{}, "") -} - -func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error { - return s.renderPage(w, r, http.StatusOK, "Build Image", "image_build_content", func(data *pageData) error { - data.Section = "images" - data.ImageBuildForm = form - data.ErrorMessage = formErr - return nil - }) -} - -func (s *Server) handleImageBuild(w http.ResponseWriter, r *http.Request) error { - if err := s.verifyPOST(w, r); err != nil { - return err - } - allowed, err := s.requireMutationAllowed(r.Context()) - if err != nil { - return err - } - form, params, err := s.parseImageBuildForm(r) - if err != nil { - return s.renderImageBuildPage(w, r, form, err.Error()) - } - if !allowed { - return s.renderImageBuildPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") - } - op, err := s.backend.BeginImageBuild(r.Context(), params) - if err != nil { - return s.renderImageBuildPage(w, r, form, err.Error()) - } - http.Redirect(w, r, "/operations/image-build/"+url.PathEscape(op.ID), http.StatusSeeOther) - return nil -} - func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error { return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "") } @@ -683,24 +629,6 @@ func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Requ }) } -func (s *Server) handleImageBuildOperationPage(w http.ResponseWriter, r *http.Request) error { - op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - return s.renderPage(w, r, http.StatusOK, "Building Image", "operation_content", func(data *pageData) error { - data.Section = "images" - data.OperationKind = "image" - data.ImageBuildOperation = &op - data.OperationStatusURL = "/api/operations/image-build/" + url.PathEscape(op.ID) - if op.ImageID != "" { - data.OperationSuccessURL = "/images/" + url.PathEscape(op.ImageID) - } - data.OperationLogPath = op.BuildLogPath - return nil - }) -} - func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error { op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id")) if err != nil { @@ -709,14 +637,6 @@ func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Reque return writeJSON(w, api.VMCreateStatusResult{Operation: op}) } -func (s *Server) handleImageBuildOperationAPI(w http.ResponseWriter, r *http.Request) error { - op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id")) - if err != nil { - return err - } - return writeJSON(w, api.ImageBuildStatusResult{Operation: op}) -} - func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error { path := strings.TrimSpace(r.URL.Query().Get("path")) if path == "" { @@ -977,31 +897,6 @@ func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetPa return params, nil } -func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.ImageBuildParams, error) { - if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { - return imageBuildForm{}, api.ImageBuildParams{}, err - } - form := imageBuildForm{ - Name: strings.TrimSpace(r.FormValue("name")), - FromImage: strings.TrimSpace(r.FormValue("from_image")), - Size: strings.TrimSpace(r.FormValue("size")), - KernelPath: strings.TrimSpace(r.FormValue("kernel_path")), - InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")), - ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")), - Docker: r.FormValue("docker") == "on", - } - params := api.ImageBuildParams{ - Name: form.Name, - FromImage: form.FromImage, - Size: form.Size, - KernelPath: form.KernelPath, - InitrdPath: form.InitrdPath, - ModulesDir: form.ModulesDir, - Docker: form.Docker, - } - return form, params, nil -} - func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) { if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { return imageRegisterForm{}, api.ImageRegisterParams{}, err diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 8e44dbb..ba6317e 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -26,7 +26,6 @@ type fakeBackend struct { image model.Image ports api.VMPortsResult createOp api.VMCreateOperation - buildOp api.ImageBuildOperation } func (f fakeBackend) Config() model.DaemonConfig { return f.config } @@ -55,12 +54,6 @@ func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, er func (f fakeBackend) PortsVM(context.Context, string) (api.VMPortsResult, error) { return f.ports, nil } func (f fakeBackend) ListImages(context.Context) ([]model.Image, error) { return f.images, nil } func (f fakeBackend) FindImage(context.Context, string) (model.Image, error) { return f.image, nil } -func (f fakeBackend) BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) { - return f.buildOp, nil -} -func (f fakeBackend) ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error) { - return f.buildOp, nil -} func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) { return f.image, nil } diff --git a/internal/webui/templates/dashboard.html b/internal/webui/templates/dashboard.html index aa18698..11124c0 100644 --- a/internal/webui/templates/dashboard.html +++ b/internal/webui/templates/dashboard.html @@ -35,7 +35,6 @@

Images

diff --git a/internal/webui/templates/images.html b/internal/webui/templates/images.html index f8e884b..db81932 100644 --- a/internal/webui/templates/images.html +++ b/internal/webui/templates/images.html @@ -3,7 +3,6 @@

Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.

@@ -32,48 +31,6 @@
{{end}} -{{define "image_build_content"}} -

Build a managed image from an existing registered image, then redirect into the async build progress view.

-{{if .ErrorMessage}} -
{{.ErrorMessage}}
-{{end}} -
- {{template "csrf_field" .}} - - - - - - - -
- Cancel - -
-
-{{end}} - {{define "image_register_content"}}

Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.

{{if .ErrorMessage}} diff --git a/internal/webui/templates/operation.html b/internal/webui/templates/operation.html index 87ff45e..1c32706 100644 --- a/internal/webui/templates/operation.html +++ b/internal/webui/templates/operation.html @@ -1,16 +1,11 @@ {{define "operation_content"}}
-

{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}

+

VM readiness

{{if .VMCreateOperation}}

{{.VMCreateOperation.Stage}}

{{.VMCreateOperation.Detail}}

{{.VMCreateOperation.Error}}

{{end}} - {{if .ImageBuildOperation}} -

{{.ImageBuildOperation.Stage}}

-

{{.ImageBuildOperation.Detail}}

-

{{.ImageBuildOperation.Error}}

- {{end}} {{if .OperationLogPath}}

Build log: {{.OperationLogPath}}

{{else}}