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>
224 lines
7.8 KiB
Go
224 lines
7.8 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
)
|
|
|
|
type fakeBackend struct {
|
|
layout paths.Layout
|
|
config model.DaemonConfig
|
|
summary api.DashboardSummary
|
|
vms []model.VMRecord
|
|
images []model.Image
|
|
vm model.VMRecord
|
|
image model.Image
|
|
ports api.VMPortsResult
|
|
createOp api.VMCreateOperation
|
|
}
|
|
|
|
func (f fakeBackend) Config() model.DaemonConfig { return f.config }
|
|
func (f fakeBackend) Layout() paths.Layout { return f.layout }
|
|
func (f fakeBackend) DashboardSummary(context.Context) (api.DashboardSummary, error) {
|
|
return f.summary, nil
|
|
}
|
|
func (f fakeBackend) ListVMs(context.Context) ([]model.VMRecord, error) { return f.vms, nil }
|
|
func (f fakeBackend) FindVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
func (f fakeBackend) GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) {
|
|
return f.vm, f.vm.Stats, nil
|
|
}
|
|
func (f fakeBackend) BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) {
|
|
return f.createOp, nil
|
|
}
|
|
func (f fakeBackend) VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) {
|
|
return f.createOp, nil
|
|
}
|
|
func (f fakeBackend) StartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
func (f fakeBackend) StopVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
func (f fakeBackend) RestartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
func (f fakeBackend) DeleteVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil }
|
|
func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) {
|
|
return f.vm, 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) FindImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
|
func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) {
|
|
return f.image, nil
|
|
}
|
|
func (f fakeBackend) PromoteImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
|
func (f fakeBackend) DeleteImage(context.Context, string) (model.Image, error) { return f.image, nil }
|
|
|
|
func TestDashboardPageRendersSummaryAndTables(t *testing.T) {
|
|
backend := fakeBackend{
|
|
layout: paths.Layout{StateDir: t.TempDir()},
|
|
config: model.DaemonConfig{SSHKeyPath: "/tmp/id"},
|
|
summary: api.DashboardSummary{
|
|
Host: api.HostSummary{CPUCount: 8, TotalMemoryBytes: 16 << 30, StateFilesystemFreeBytes: 9 << 30, StateFilesystemTotalBytes: 20 << 30},
|
|
Sudo: api.SudoStatus{Available: true, Command: "sudo -v"},
|
|
Banger: api.BangerSummary{
|
|
VMCount: 1, RunningVMCount: 1, ImageCount: 1, ManagedImageCount: 1, ConfiguredVCPUCount: 2,
|
|
ConfiguredMemoryBytes: 1 << 30,
|
|
ConfiguredDiskBytes: 8 << 30,
|
|
UsedWorkDiskBytes: 3 << 30,
|
|
},
|
|
},
|
|
vms: []model.VMRecord{{ID: "vm-1", Name: "smth", State: model.VMStateRunning, CreatedAt: model.Now(), Runtime: model.VMRuntime{GuestIP: "172.16.0.2"}, Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}},
|
|
images: []model.Image{{ID: "img-1", Name: "void", Managed: true, RootfsPath: "/tmp/rootfs.ext4", CreatedAt: model.Now()}},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
NewHandler(backend).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"vCPU", "2 / 8", "1G / 16G", "8G / 20G", "9G free", "smth", "void", "Create VM"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("body missing %q\n%s", want, body)
|
|
}
|
|
}
|
|
if len(rec.Result().Cookies()) == 0 {
|
|
t.Fatal("expected csrf cookie to be set")
|
|
}
|
|
}
|
|
|
|
func TestVMActionRejectsMissingCSRF(t *testing.T) {
|
|
backend := fakeBackend{
|
|
layout: paths.Layout{StateDir: t.TempDir()},
|
|
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}},
|
|
vm: model.VMRecord{ID: "vm-1", Name: "smth"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/vms/vm-1/start", strings.NewReader(""))
|
|
req.Header.Set("Origin", "http://example.com")
|
|
rec := httptest.NewRecorder()
|
|
NewHandler(backend).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFSAPIListsEntries(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "rootfs.ext4"), []byte("data"), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
backend := fakeBackend{
|
|
layout: paths.Layout{StateDir: dir},
|
|
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/fs?path="+url.QueryEscape(dir)+"&kind=file", nil)
|
|
rec := httptest.NewRecorder()
|
|
NewHandler(backend).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
data, err := io.ReadAll(rec.Body)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
body := string(data)
|
|
for _, want := range []string{"rootfs.ext4", "nested"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("body missing %q\n%s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVMShowPageRendersRunningActions(t *testing.T) {
|
|
backend := fakeBackend{
|
|
layout: paths.Layout{StateDir: t.TempDir()},
|
|
config: model.DaemonConfig{SSHKeyPath: "/tmp/id"},
|
|
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}},
|
|
vm: model.VMRecord{
|
|
ID: "vm-1",
|
|
Name: "smth",
|
|
State: model.VMStateRunning,
|
|
Runtime: model.VMRuntime{
|
|
GuestIP: "172.16.0.2",
|
|
},
|
|
Spec: model.VMSpec{
|
|
VCPUCount: 2,
|
|
MemoryMiB: 1024,
|
|
WorkDiskSizeBytes: 8 << 30,
|
|
},
|
|
Stats: model.VMStats{
|
|
CPUPercent: 12.5,
|
|
RSSBytes: 64 << 20,
|
|
SystemOverlayBytes: 2 << 20,
|
|
WorkDiskBytes: 32 << 20,
|
|
},
|
|
},
|
|
image: model.Image{ID: "img-1", Name: "void"},
|
|
ports: api.VMPortsResult{
|
|
Name: "smth",
|
|
Ports: []api.VMPort{
|
|
{Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "opencode"},
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/vms/vm-1", nil)
|
|
rec := httptest.NewRecorder()
|
|
NewHandler(backend).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"Stop", "Restart", "href=\"http://172.16.0.2:4096\"", "data-confirm=\"Stop VM smth?\"", "data-confirm=\"Delete VM smth?\""} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("body missing %q\n%s", want, body)
|
|
}
|
|
}
|
|
for _, unwanted := range []string{"opencode attach", "root@172.16.0.2"} {
|
|
if strings.Contains(body, unwanted) {
|
|
t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVMListShowsImageNameAndLink(t *testing.T) {
|
|
backend := fakeBackend{
|
|
layout: paths.Layout{StateDir: t.TempDir()},
|
|
summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}},
|
|
vms: []model.VMRecord{
|
|
{ID: "vm-1", Name: "smth", ImageID: "img-1", State: model.VMStateRunning, CreatedAt: model.Now(), Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}},
|
|
},
|
|
images: []model.Image{
|
|
{ID: "img-1", Name: "void"},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/vms", nil)
|
|
rec := httptest.NewRecorder()
|
|
NewHandler(backend).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{">void</a>", "href=\"/images/img-1\""} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("body missing %q\n%s", want, body)
|
|
}
|
|
}
|
|
}
|