From ae72e45bf5f1cfb345fb90b261b628fdcd6ea6d9 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sun, 11 Oct 2026 14:33:10 -0700 Subject: [PATCH] Virtualization: a disk-location picker on both create forms, locations by place, add or remove a location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pools are shown by where their bytes land (plain files on this disk, a ZFS pool inside one file, a dataset…) rather than by driver name, which read as the NAS pool. A location can be added as a plain-files pool at any folder on the machine, and removed while nothing uses it. The driver order at Install is as it was; the labels say what each one really is. Co-Authored-By: Claude Fable 5.1 --- internal/api/server.go | 2 + internal/api/virt.go | 68 ++++++++++++++++++++++++++++ internal/incus/incus.go | 72 +++++++++++++++++++++++++++--- internal/incus/install_test.go | 9 ++-- internal/storage/snapshots.go | 1 - internal/storage/snapshots_test.go | 4 +- web/src/Virtualization.tsx | 52 ++++++++++++++++----- web/src/api.ts | 10 +++-- 8 files changed, 191 insertions(+), 27 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index a9e1e25..c588b60 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -259,6 +259,8 @@ func (s *Server) routes() *http.ServeMux { m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun)) m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete)) m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys)) + m.HandleFunc("POST /api/virt/pools", s.auth(s.virtPoolCreate)) + m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete)) m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet)) m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut)) m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest)) diff --git a/internal/api/virt.go b/internal/api/virt.go index f5d8b92..03cfd7d 100644 --- a/internal/api/virt.go +++ b/internal/api/virt.go @@ -48,6 +48,7 @@ func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) { imgs, _ := s.virt.Images(ctx) out["images"] = imgs out["pools"] = s.virt.Pools(ctx) + out["default_pool"] = s.virt.DefaultPool(ctx) out["networks"] = s.virt.Networks(ctx) } isos := s.virt.Library(s.virt.ISOsDir()) @@ -1094,3 +1095,70 @@ func (s *Server) virtSSHKeys(w http.ResponseWriter, _ *http.Request) { } jsonOK(w, 200, map[string]any{"keys": keys, "user": s.user}) } + +// ---- locations: storage pools, added as plain-files pools at a path of his choosing ---- + +func (s *Server) virtPoolCreate(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Path string `json:"path"` + } + _ = readBody(r, &body, 8192) + p := filepath.Clean(body.Path) + name := strings.TrimSpace(body.Name) + if name == "" { + name = sensors.Seg(filepath.Base(p)) + } + if !incus.ValidName(name) { + jsonErr(w, 400, "name: letters, digits and dashes, starting with a letter") + return + } + if !filepath.IsAbs(p) || p == "/" { + jsonErr(w, 400, "path: an absolute path to a folder on this machine") + return + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + if st, err := os.Stat(p); err == nil && !st.IsDir() { + jsonErr(w, 400, p+" is a file, not a folder") + return + } else if err != nil { + mk := priv.Wrap("mkdir", "-p", p) // Incus wants the folder there; it fills it as root + if out, err := exec.CommandContext(ctx, mk[0], mk[1:]...).CombinedOutput(); err != nil { + jsonErr(w, 500, "could not make "+p+": "+strings.TrimSpace(string(out))) + return + } + } + if out, err := s.virt.Cmd(ctx, "storage", "create", name, "dir", "source="+p).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: location %s added at %s (from %s)", name, p, clientIP(r)) + jsonOK(w, 200, map[string]string{"name": name, "path": p}) +} + +func (s *Server) virtPoolDelete(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + _ = readBody(r, &body, 4096) + if !incus.ValidName(body.Name) { + jsonErr(w, 400, "bad name") + return + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + // Incus refuses a pool anything still uses; that refusal is the answer + if out, err := s.virt.Cmd(ctx, "storage", "delete", body.Name).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: location %s removed (from %s)", body.Name, clientIP(r)) + w.WriteHeader(204) +} diff --git a/internal/incus/incus.go b/internal/incus/incus.go index a0636c1..7050058 100644 --- a/internal/incus/incus.go +++ b/internal/incus/incus.go @@ -28,7 +28,7 @@ import ( ) const ( - PoolName = "default" + PoolName = "local" // plain files on the machine's own disk, in Ham Lab's folder BridgeName = "incusbr0" ZPoolName = "hamlab-virt" // the ZFS pool made on the backing file; Incus is handed it by name AdminGroup = "incus-admin" @@ -615,11 +615,38 @@ type Pool struct { Name string `json:"name"` Driver string `json:"driver"` Source string `json:"source"` + Where string `json:"where"` // in words: what disk this is, so a pool is picked by place, not by driver Used uint64 `json:"used"` Total uint64 `json:"total"` UsedBy int `json:"used_by"` } +// poolWhere describes a pool by where its bytes land. +func (m *Manager) poolWhere(driver, source string) string { + inHam := strings.HasPrefix(source, m.dir+"/") || source == m.dir + switch driver { + case "dir": + if inHam { + return "this machine's own disk — plain files, each disk sized on its own" + } + return "plain files at " + source + case "zfs": + if strings.HasSuffix(source, ".img") || source == ZPoolName { + return "a ZFS pool inside one file on this machine's disk" + } + if strings.Contains(source, "/") { + return "ZFS dataset " + source + } + return "ZFS pool " + source + case "btrfs": + if inHam { + return "btrfs inside one file on this machine's disk" + } + return "btrfs at " + source + } + return driver + " at " + source +} + type Network struct { Name string `json:"name"` Type string `json:"type"` @@ -647,7 +674,7 @@ func (m *Manager) Pools(ctx context.Context) []Pool { } var out2 []Pool for _, p := range raw { - pool := Pool{Name: p.Name, Driver: p.Driver, Source: p.Config["source"], UsedBy: len(p.UsedBy)} + pool := Pool{Name: p.Name, Driver: p.Driver, Source: p.Config["source"], Where: m.poolWhere(p.Driver, p.Config["source"]), UsedBy: len(p.UsedBy)} if b, err := m.run(c, "query", "/1.0/storage-pools/"+p.Name+"/resources"); err == nil { var res struct { Space struct { @@ -664,6 +691,24 @@ func (m *Manager) Pools(ctx context.Context) []Pool { return out2 } +// DefaultPool is where a new instance's disk goes unless the form says otherwise: the pool +// on the default profile's root device. +func (m *Manager) DefaultPool(ctx context.Context) string { + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := m.run(c, "query", "/1.0/profiles/default") + if err != nil { + return "" + } + var p struct { + Devices map[string]map[string]string `json:"devices"` + } + if json.Unmarshal(out, &p) != nil { + return "" + } + return p.Devices["root"]["pool"] +} + func (m *Manager) Networks(ctx context.Context) []Network { c, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() @@ -869,6 +914,7 @@ type CreateSpec struct { CPU string `json:"cpu"` Memory string `json:"memory"` Disk string `json:"disk"` + Pool string `json:"pool"` // where the root disk goes; blank is the default profile's Network string `json:"network"` CloudInit string `json:"cloud_init"` Profiles []string `json:"profiles"` @@ -891,6 +937,8 @@ func CreateArgs(sp CreateSpec, kvm bool) ([]string, error) { return nil, errors.New("memory: a size, like 2GiB") case !ValidSize(sp.Disk): return nil, errors.New("disk: a size, like 20GiB") + case sp.Pool != "" && !ValidName(sp.Pool): + return nil, errors.New("pool: a storage pool's name") case !ValidNetwork(sp.Network): return nil, errors.New("network: a bridge name") case len(sp.CloudInit) > 256<<10: @@ -912,8 +960,16 @@ func CreateArgs(sp CreateSpec, kvm bool) ([]string, error) { if sp.Memory != "" { args = append(args, "-c", "limits.memory="+sp.Memory) } + // the root disk: the profile's, with the pool and size overridden where given + var root []string + if sp.Pool != "" { + root = append(root, "pool="+sp.Pool) + } if sp.Disk != "" { - args = append(args, "-d", "root,size="+sp.Disk) + root = append(root, "size="+sp.Disk) + } + if len(root) > 0 { + args = append(args, "-d", "root,"+strings.Join(root, ",")) } if sp.Network != "" { args = append(args, "-n", sp.Network) @@ -1104,6 +1160,7 @@ type FromISOSpec struct { CPU string `json:"cpu"` Memory string `json:"memory"` Disk string `json:"disk"` + Pool string `json:"pool"` Network string `json:"network"` SecureBoot bool `json:"secureboot"` Start bool `json:"start"` @@ -1121,6 +1178,8 @@ func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, er return "", nil, errors.New("iso: pick one from the library") case !ValidCPU(sp.CPU) || !ValidSize(sp.Memory) || !ValidSize(sp.Disk) || !ValidNetwork(sp.Network): return "", nil, errors.New("cpu, memory, disk: a count and two sizes, like 2, 2GiB, 20GiB") + case sp.Pool != "" && !ValidName(sp.Pool): + return "", nil, errors.New("pool: a storage pool's name") } cpu, mem, disk := sp.CPU, sp.Memory, sp.Disk if cpu == "" { @@ -1140,13 +1199,14 @@ func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, er if sp.Start { start = "1" } - // $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start + // $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start $9 pool script = `set -e NET=""; [ -n "$6" ] && NET="-n $6" -incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d root,size="$5" $NET +ROOT="root,size=$5"; [ -n "$9" ] && ROOT="root,pool=$9,size=$5" +incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d "$ROOT" $NET incus config device add "$1" iso0 disk source="$2" boot.priority=10 echo "made $1 with $2 as its first boot device" [ "$8" = 1 ] && incus start "$1" && echo "started; open the VGA console to run the installer" exit 0` - return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start}, nil + return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start, sp.Pool}, nil } diff --git a/internal/incus/install_test.go b/internal/incus/install_test.go index d6bc653..f12c98e 100644 --- a/internal/incus/install_test.go +++ b/internal/incus/install_test.go @@ -21,13 +21,13 @@ func TestInstallScriptShapes(t *testing.T) { }{ {"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30}, InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16}, - []string{"apt-get", "zpool create", "incus storage create default zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}}, + []string{"apt-get", "zpool create", "incus storage create local zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}}, {"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30}, InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true}, - []string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create default btrfs source="}}, + []string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create local btrfs source="}}, {"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30}, InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"}, - []string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create default dir source="}}, + []string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create local dir source="}}, {"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30}, InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"}, []string{"already installed"}}, @@ -75,6 +75,9 @@ func TestCreateArgs(t *testing.T) { t.Fatal(err) } want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0" + if a, err := CreateArgs(CreateSpec{Name: "x", Image: "images:alpine/3.22", Pool: "local"}, true); err != nil || strings.Join(a, " ") != "init images:alpine/3.22 x -d root,pool=local" { + t.Fatalf("pool override: %v %v", a, err) + } if got := strings.Join(args, " "); got != want { t.Fatalf("got %q", got) } diff --git a/internal/storage/snapshots.go b/internal/storage/snapshots.go index 9f5768e..5d361ca 100644 --- a/internal/storage/snapshots.go +++ b/internal/storage/snapshots.go @@ -66,7 +66,6 @@ type Policy struct { KeepMin int `json:"keep_min"` Prefix string `json:"prefix"` Enabled bool `json:"enabled"` - } func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) } diff --git a/internal/storage/snapshots_test.go b/internal/storage/snapshots_test.go index 9c39d26..e70093a 100644 --- a/internal/storage/snapshots_test.go +++ b/internal/storage/snapshots_test.go @@ -106,8 +106,8 @@ func TestValidateRefusesNonsense(t *testing.T) { {Dataset: "tank", Every: "custom", KeepFor: "6w"}, {Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"}, {Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"}, - {Dataset: "tank", Every: "daily", At: "02:30"}, // no retention - {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable + {Dataset: "tank", Every: "daily", At: "02:30"}, // no retention + {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable {Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one {Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"}, } diff --git a/web/src/Virtualization.tsx b/web/src/Virtualization.tsx index 11ca41c..430d53b 100644 --- a/web/src/Virtualization.tsx +++ b/web/src/Virtualization.tsx @@ -81,7 +81,7 @@ export default function Virtualization() { {tab === 'images' && { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />} {tab === 'library' && setFromIso(iso)} />} - {tab === 'instances' && (cur ? setSel('')} onChanged={load} onTask={startTask} /> : )} + {tab === 'instances' && (cur ? setSel('')} onChanged={load} onTask={startTask} /> : )} {making && setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />} {fromIso !== null && setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />} @@ -151,11 +151,11 @@ function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: ()
Everything goes under {st.dir}: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.
{driver !== 'dir' && ( -