The `internal/opencode` package and the `opencodeCapability` that
consumed it were hard-wired to wait for opencode on guest port 4096
when an image shipped an initrd. After the prune commits (void /
alpine / customize.sh / image build all removed), nothing banger
produces today carries an initrd, so the capability's wait path was
unreachable: every startup short-circuited to the "direct-boot, skip
opencode" branch.
Same logic for `banger vm acp`: it SSHes to `opencode acp --cwd
<path>`, a binary the golden image no longer ships. Users who run
their own image with opencode can still invoke
`ssh vm -- opencode acp --cwd /root/repo` directly — no banger
scaffolding required.
Removed:
- internal/opencode/ (whole package, 255 LOC incl. tests)
- internal/daemon/opencode.go (opencodeCapability)
- cli `vm acp` command + its helpers (runVMACP, sshACPCommandArgs,
vmACPRemoteCommand) + their tests
- The opencodeCapability{} entry in registeredCapabilities() plus
the test that pinned its presence
- `wait_opencode` progress-stage label from the vm-create renderer
- Stale mentions in daemon/doc.go, README, and webui test fixtures
~480 lines gone, 12 added. `banger/internal` is now 25 packages
instead of 26.
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: "devserver"},
|
|
},
|
|
},
|
|
}
|
|
|
|
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{"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)
|
|
}
|
|
}
|
|
}
|