Remove image build --from-image; doctor treats catalog images as OK

The `image build` flow spun up a transient Firecracker VM, SSHed in,
and ran a large bash provisioning script to derive a new managed
image from an existing one. It overlapped heavily with the golden-
image Dockerfile flow (same mise/docker/tmux/opencode install logic
duplicated in Go as `imagemgr.BuildProvisionScript`) and had far more
machinery: async op state, RPC begin/status/cancel, webui form +
operation page, preflight checks, API types, tests. For custom
images, writing a Dockerfile is simpler and more reproducible.

Removed end-to-end:
- CLI `image build` subcommand + `absolutizeImageBuildPaths`.
- Daemon: BuildImage method, imagebuild.go (transient-VM orchestration),
  image_build_ops.go (async begin/status/cancel), imagemgr/build.go
  (the 247-line provisioning script generator and all its append*
  helpers), validateImageBuildPrereqs + addImageBuildPrereqs.
- RPC dispatches for image.build / .begin / .status / .cancel.
- opstate registry `imageBuildOps`, daemon seam `imageBuild`,
  background pruner call.
- API types: ImageBuildParams, ImageBuildOperation, ImageBuildBeginResult,
  ImageBuildStatusParams, ImageBuildStatusResult; model type
  ImageBuildRequest.
- Web UI: Backend interface methods, handlers, form, routes, template
  branches (images.html build form, operation.html build branch,
  dashboard.html Build button).
- Tests that directly exercised BuildImage.

Doctor polish (task C):
- Drop the "image build" preflight section entirely (its raison d'être
  is gone).
- Default-image check now accepts "not local but in imagecat" as OK:
  vm create auto-pulls on first use. Only flag when the image is
  neither locally registered nor in the catalog.

Net: 24 files touched, 1,373 lines deleted, 25 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-18 15:54:29 -03:00
parent ace4782fce
commit ac7974f5b9
No known key found for this signature in database
GPG key ID: 33112E6833C34679
24 changed files with 25 additions and 1398 deletions

View file

@ -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 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 <name>` uses the bundle catalog (fast) when `<name>` 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 pull <name>` uses the bundle catalog (fast) when `<name>` 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 register ...` registers an unmanaged host-side image stack.
- `./build/bin/banger image build --from-image <image>` builds a managed image from an existing one.
- `./build/bin/banger image promote <image>` copies an unmanaged image into daemon-owned managed artifacts. - `./build/bin/banger image promote <image>` 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 <name>` publishes it to the kernel catalog. - `scripts/make-generic-kernel.sh` builds a Firecracker-optimized vmlinux from upstream sources. `scripts/publish-kernel.sh <name>` publishes it to the kernel catalog.
- `scripts/publish-golden-image.sh` rebuilds + publishes the golden image bundle and patches the image catalog. - `scripts/publish-golden-image.sh` rebuilds + publishes the golden image bundle and patches the image catalog.

View file

@ -110,15 +110,8 @@ banger image register --name base \
--kernel-ref generic-6.12 --kernel-ref generic-6.12
``` ```
### `image build --from-image` — derived images For custom images, write a Dockerfile and either publish to the
catalog (see `docs/image-catalog.md`) or pull it via the OCI path.
```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.
### Workspace + session primitives ### Workspace + session primitives

View file

@ -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. `openssh-server` via the guest's package manager on first boot.
Dispatches on `/etc/os-release``apt-get` / `apk` / `dnf` / Dispatches on `/etc/os-release``apt-get` / `apk` / `dnf` /
`pacman` / `zypper`. Subsequent boots skip the install. `pacman` / `zypper`. Subsequent boots skip the install.
- Composition with `image build --from-image`.
## What doesn't yet work ## What doesn't yet work

View file

@ -58,33 +58,6 @@ type VMCreateStatusResult struct {
Operation VMCreateOperation `json:"operation"` 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 { type VMRefParams struct {
IDOrName string `json:"id_or_name"` IDOrName string `json:"id_or_name"`
} }
@ -242,16 +215,6 @@ type VMWorkspacePrepareResult struct {
Workspace model.WorkspacePrepareResult `json:"workspace"` 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 { type ImageRegisterParams struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
RootfsPath string `json:"rootfs_path,omitempty"` RootfsPath string `json:"rootfs_path,omitempty"`

View file

@ -1702,7 +1702,6 @@ func newImageCommand() *cobra.Command {
RunE: helpNoArgs, RunE: helpNoArgs,
} }
cmd.AddCommand( cmd.AddCommand(
newImageBuildCommand(),
newImageRegisterCommand(), newImageRegisterCommand(),
newImagePullCommand(), newImagePullCommand(),
newImagePromoteCommand(), newImagePromoteCommand(),
@ -1713,40 +1712,6 @@ func newImageCommand() *cobra.Command {
return cmd 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(&params); 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(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.FromImage, "from-image", "", "registered base image id or name")
cmd.Flags().StringVar(&params.Size, "size", "", "output image size")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().BoolVar(&params.Docker, "docker", false, "install docker")
return cmd
}
func newImageRegisterCommand() *cobra.Command { func newImageRegisterCommand() *cobra.Command {
var params api.ImageRegisterParams var params api.ImageRegisterParams
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -3181,10 +3146,6 @@ func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
} }
func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
return absolutizePaths(&params.KernelPath, &params.InitrdPath, &params.ModulesDir)
}
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error { func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
return absolutizePaths( return absolutizePaths(
&params.RootfsPath, &params.RootfsPath,

View file

@ -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(&params); 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 { func testCLIResolvedVM(id, name string) model.VMRecord {
return model.VMRecord{ID: id, Name: name} return model.VMRecord{ID: id, Name: name}
} }

View file

@ -14,17 +14,16 @@ owning types:
- `createVMMu sync.Mutex` — serialises `CreateVM` (guards name uniqueness - `createVMMu sync.Mutex` — serialises `CreateVM` (guards name uniqueness
+ guest IP allocation window). + guest IP allocation window).
- `imageOpsMu sync.Mutex` — serialises image-registry mutations - `imageOpsMu sync.Mutex` — serialises image-registry mutations
(`BuildImage`, `RegisterImage`, `PromoteImage`, `DeleteImage`). (`PullImage`, `RegisterImage`, `PromoteImage`, `DeleteImage`).
- `createOps opstate.Registry[*vmCreateOperationState]` — in-flight VM - `createOps opstate.Registry[*vmCreateOperationState]` — in-flight VM
create operations; owns its own lock. 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. - `tapPool tapPool` — TAP interface pool; owns its own lock.
- `sessions sessionRegistry` — active guest session controllers; owns - `sessions sessionRegistry` — active guest session controllers; owns
its own lock. its own lock.
- `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking. - `listener`, `webListener`, `webServer`, `webURL`, `vmDNS` — networking.
- `vmCaps` — registered VM capability hooks. - `vmCaps` — registered VM capability hooks.
- `imageBuild`, `requestHandler`, `guestWaitForSSH`, `guestDial`, - `pullAndFlatten`, `finalizePulledRootfs`, `bundleFetch`,
`requestHandler`, `guestWaitForSSH`, `guestDial`,
`waitForGuestSessionReady` — injectable seams used by tests. `waitForGuestSessionReady` — injectable seams used by tests.
## Subpackages ## Subpackages

View file

@ -38,7 +38,6 @@ type Daemon struct {
imageOpsMu sync.Mutex imageOpsMu sync.Mutex
createVMMu sync.Mutex createVMMu sync.Mutex
createOps opstate.Registry[*vmCreateOperationState] createOps opstate.Registry[*vmCreateOperationState]
imageBuildOps opstate.Registry[*imageBuildOperationState]
vmLocks vmLockSet vmLocks vmLockSet
sessions sessionRegistry sessions sessionRegistry
tapPool tapPool tapPool tapPool
@ -51,7 +50,6 @@ type Daemon struct {
webURL string webURL string
vmDNS *vmdns.Server vmDNS *vmdns.Server
vmCaps []vmCapability vmCaps []vmCapability
imageBuild func(context.Context, imageBuildSpec) error
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
finalizePulledRootfs func(ctx context.Context, ext4File string, meta 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) 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) image, err := d.FindImage(ctx, params.IDOrName)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) 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": case "image.register":
params, err := rpc.DecodeParams[api.ImageRegisterParams](req) params, err := rpc.DecodeParams[api.ImageRegisterParams](req)
if err != nil { if err != nil {
@ -594,7 +564,6 @@ func (d *Daemon) backgroundLoop() {
d.logger.Error("background stale sweep failed", "error", err.Error()) d.logger.Error("background stale sweep failed", "error", err.Error())
} }
d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute)) d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute))
d.pruneImageBuildOperations(time.Now().Add(-10 * time.Minute))
} }
} }
} }

View file

@ -16,19 +16,6 @@ import (
"banger/internal/system" "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) { func TestRegisterImageRequiresKernel(t *testing.T) {
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4") rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil { if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil {

View file

@ -12,7 +12,7 @@
// Subpackages: // Subpackages:
// //
// internal/daemon/opstate Generic Registry[T AsyncOp] for async // 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/dmsnap Device-mapper COW snapshot lifecycle.
// internal/daemon/fcproc Firecracker process helpers: bridge/tap, // internal/daemon/fcproc Firecracker process helpers: bridge/tap,
// binary resolution, PID lookup, wait/kill. // binary resolution, PID lookup, wait/kill.
@ -46,8 +46,7 @@
// Image management (in this package): // Image management (in this package):
// //
// images.go register, promote, delete, find, list // images.go register, promote, delete, find, list
// imagebuild.go orchestrates the transient firecracker build VM // images_pull.go image pull: catalog (bundle) + OCI paths
// image_build_ops.go async begin/status/cancel (uses opstate.Registry)
// image_seed.go managed work-seed SSH fingerprint refresh // image_seed.go managed work-seed SSH fingerprint refresh
// //
// Guest interaction (in this package): // Guest interaction (in this package):

View file

@ -2,10 +2,10 @@ package daemon
import ( import (
"context" "context"
"database/sql"
"strings" "strings"
"banger/internal/config" "banger/internal/config"
"banger/internal/imagecat"
"banger/internal/model" "banger/internal/model"
"banger/internal/paths" "banger/internal/paths"
"banger/internal/store" "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("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available")
d.addCapabilityDoctorChecks(ctx, &report) d.addCapabilityDoctorChecks(ctx, &report)
report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available")
return report return report
} }
@ -56,44 +55,38 @@ func (d *Daemon) runtimeChecks() *system.Preflight {
checks.Addf("%v", err) checks.Addf("%v", err)
} }
if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" { if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" {
image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName) name := d.config.DefaultImageName
switch { image, err := d.store.GetImageByName(context.Background(), name)
case err == nil: if err == nil {
checks.RequireFile(image.RootfsPath, "default image rootfs", `re-register or rebuild the default image`) 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`) checks.RequireFile(image.KernelPath, "default image kernel", `re-register or rebuild the default image`)
if strings.TrimSpace(image.InitrdPath) != "" { if strings.TrimSpace(image.InitrdPath) != "" {
checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`) checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`)
} }
case err != nil && err != sql.ErrNoRows: } else if !defaultImageInCatalog(name) {
checks.Addf("failed to inspect default image %q: %v", d.config.DefaultImageName, err) checks.Addf("default image %q is not registered and not in the imagecat catalog", name)
default:
checks.Addf("default image %q is not registered", d.config.DefaultImageName)
} }
// 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 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 { func (d *Daemon) coreVMLifecycleChecks() *system.Preflight {
checks := system.NewPreflight() checks := system.NewPreflight()
d.addBaseStartCommandPrereqs(checks) d.addBaseStartCommandPrereqs(checks)
return 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 { func (d *Daemon) vsockChecks() *system.Preflight {
checks := system.NewPreflight() checks := system.NewPreflight()
if helper, err := d.vsockAgentBinary(); err == nil { if helper, err := d.vsockAgentBinary(); err == nil {

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}
}

View file

@ -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/<modulesBase>, 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, "'", `'"'"'`) + "'"
}

View file

@ -16,146 +16,6 @@ import (
"banger/internal/system" "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) { func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) {
d.imageOpsMu.Lock() d.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock() defer d.imageOpsMu.Unlock()

View file

@ -11,7 +11,6 @@ import (
"strings" "strings"
"testing" "testing"
"banger/internal/api"
"banger/internal/model" "banger/internal/model"
"banger/internal/paths" "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 { func parseLogEntries(t *testing.T, data []byte) []map[string]any {
t.Helper() t.Helper()
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))

View file

@ -17,12 +17,6 @@ func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, im
return checks.Err("vm start preflight failed") 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 { func (d *Daemon) validateWorkDiskResizePrereqs() error {
checks := system.NewPreflight() checks := system.NewPreflight()
checks.RequireCommand("truncate", toolHint("truncate")) 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 { func toolHint(command string) string {
switch command { switch command {
case "ip": case "ip":

View file

@ -155,16 +155,6 @@ type VMSetRequest struct {
NATEnabled *bool NATEnabled *bool
} }
type ImageBuildRequest struct {
Name string
FromImage string
Size string
KernelPath string
InitrdPath string
ModulesDir string
Docker bool
}
type GuestSession struct { type GuestSession struct {
ID string `json:"id"` ID string `json:"id"`
VMID string `json:"vm_id"` VMID string `json:"vm_id"`

View file

@ -43,8 +43,6 @@ type Backend interface {
PortsVM(context.Context, string) (api.VMPortsResult, error) PortsVM(context.Context, string) (api.VMPortsResult, error)
ListImages(context.Context) ([]model.Image, error) ListImages(context.Context) ([]model.Image, error)
FindImage(context.Context, string) (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) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error)
PromoteImage(context.Context, string) (model.Image, error) PromoteImage(context.Context, string) (model.Image, error)
DeleteImage(context.Context, string) (model.Image, error) DeleteImage(context.Context, string) (model.Image, error)
@ -84,16 +82,6 @@ type vmSetForm struct {
NATEnabled bool NATEnabled bool
} }
type imageBuildForm struct {
Name string
FromImage string
Size string
KernelPath string
InitrdPath string
ModulesDir string
Docker bool
}
type imageRegisterForm struct { type imageRegisterForm struct {
Name string Name string
RootfsPath string RootfsPath string
@ -126,11 +114,9 @@ type pageData struct {
Images []model.Image Images []model.Image
Image model.Image Image model.Image
ImageUsers int ImageUsers int
ImageBuildForm imageBuildForm
ImageRegisterForm imageRegisterForm ImageRegisterForm imageRegisterForm
LogText string LogText string
VMCreateOperation *api.VMCreateOperation VMCreateOperation *api.VMCreateOperation
ImageBuildOperation *api.ImageBuildOperation
OperationStatusURL string OperationStatusURL string
OperationSuccessURL string OperationSuccessURL string
OperationLogPath 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}/delete", s.wrap(s.handleVMDelete))
mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet)) mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet))
mux.HandleFunc("GET /images", s.wrap(s.handleImageList)) 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("GET /images/register", s.wrap(s.handleImageRegisterForm))
mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister)) mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister))
mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow)) mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow))
mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote)) mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote))
mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete)) 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/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/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)) 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 { func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error {
return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "") 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 { func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error {
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id")) op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
if err != nil { if err != nil {
@ -709,14 +637,6 @@ func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Reque
return writeJSON(w, api.VMCreateStatusResult{Operation: op}) 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 { func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error {
path := strings.TrimSpace(r.URL.Query().Get("path")) path := strings.TrimSpace(r.URL.Query().Get("path"))
if path == "" { if path == "" {
@ -977,31 +897,6 @@ func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetPa
return params, nil 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) { func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) {
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
return imageRegisterForm{}, api.ImageRegisterParams{}, err return imageRegisterForm{}, api.ImageRegisterParams{}, err

View file

@ -26,7 +26,6 @@ type fakeBackend struct {
image model.Image image model.Image
ports api.VMPortsResult ports api.VMPortsResult
createOp api.VMCreateOperation createOp api.VMCreateOperation
buildOp api.ImageBuildOperation
} }
func (f fakeBackend) Config() model.DaemonConfig { return f.config } 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) 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) 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) 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) { func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) {
return f.image, nil return f.image, nil
} }

View file

@ -35,7 +35,6 @@
<h3>Images</h3> <h3>Images</h3>
<div class="stack-inline"> <div class="stack-inline">
<a class="button secondary" href="/images/register">Register</a> <a class="button secondary" href="/images/register">Register</a>
<a class="button" href="/images/build">Build</a>
</div> </div>
</div> </div>
<table> <table>

View file

@ -3,7 +3,6 @@
<p class="muted">Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.</p> <p class="muted">Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.</p>
<div class="stack-inline"> <div class="stack-inline">
<a class="button secondary" href="/images/register">Register Image</a> <a class="button secondary" href="/images/register">Register Image</a>
<a class="button" href="/images/build">Build Image</a>
</div> </div>
</div> </div>
<table> <table>
@ -32,48 +31,6 @@
</table> </table>
{{end}} {{end}}
{{define "image_build_content"}}
<p class="muted">Build a managed image from an existing registered image, then redirect into the async build progress view.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/images/build" class="form-grid">
{{template "csrf_field" .}}
<label><span>Name</span><input type="text" name="name" value="{{.ImageBuildForm.Name}}" placeholder="generated when empty"></label>
<label><span>From Image</span><input type="text" name="from_image" value="{{.ImageBuildForm.FromImage}}" placeholder="image id or name"></label>
<label><span>Size Override</span><input type="text" name="size" value="{{.ImageBuildForm.Size}}" placeholder="optional"></label>
<label class="picker-field">
<span>Kernel Path</span>
<div class="picker-input">
<input type="text" name="kernel_path" value="{{.ImageBuildForm.KernelPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="kernel_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Initrd Path</span>
<div class="picker-input">
<input type="text" name="initrd_path" value="{{.ImageBuildForm.InitrdPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="initrd_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Modules Directory</span>
<div class="picker-input">
<input type="text" name="modules_dir" value="{{.ImageBuildForm.ModulesDir}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="modules_dir" data-picker-kind="dir">Browse</button>
</div>
</label>
<label class="checkbox">
<input type="checkbox" name="docker" {{if .ImageBuildForm.Docker}}checked{{end}}>
<span>Install Docker</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/images">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Build Image</button>
</div>
</form>
{{end}}
{{define "image_register_content"}} {{define "image_register_content"}}
<p class="muted">Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.</p> <p class="muted">Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.</p>
{{if .ErrorMessage}} {{if .ErrorMessage}}

View file

@ -1,16 +1,11 @@
{{define "operation_content"}} {{define "operation_content"}}
<section class="operation-card" data-operation-url="{{.OperationStatusURL}}" {{if .OperationSuccessURL}}data-operation-success="{{.OperationSuccessURL}}"{{end}}> <section class="operation-card" data-operation-url="{{.OperationStatusURL}}" {{if .OperationSuccessURL}}data-operation-success="{{.OperationSuccessURL}}"{{end}}>
<h2>{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}</h2> <h2>VM readiness</h2>
{{if .VMCreateOperation}} {{if .VMCreateOperation}}
<h3 id="operation-stage">{{.VMCreateOperation.Stage}}</h3> <h3 id="operation-stage">{{.VMCreateOperation.Stage}}</h3>
<p id="operation-detail">{{.VMCreateOperation.Detail}}</p> <p id="operation-detail">{{.VMCreateOperation.Detail}}</p>
<p class="muted" id="operation-error">{{.VMCreateOperation.Error}}</p> <p class="muted" id="operation-error">{{.VMCreateOperation.Error}}</p>
{{end}} {{end}}
{{if .ImageBuildOperation}}
<h3 id="operation-stage">{{.ImageBuildOperation.Stage}}</h3>
<p id="operation-detail">{{.ImageBuildOperation.Detail}}</p>
<p class="muted" id="operation-error">{{.ImageBuildOperation.Error}}</p>
{{end}}
{{if .OperationLogPath}} {{if .OperationLogPath}}
<p class="muted">Build log: <code id="operation-log">{{.OperationLogPath}}</code></p> <p class="muted">Build log: <code id="operation-log">{{.OperationLogPath}}</code></p>
{{else}} {{else}}