Virtualization: a disk-location picker on both create forms, locations by place, add or remove a location

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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 14:33:10 -07:00
co-authored by Claude Fable 5.1
parent a9b4d7969e
commit ae72e45bf5
8 changed files with 191 additions and 27 deletions
+2
View File
@@ -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))
+68
View File
@@ -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)
}
+66 -6
View File
@@ -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
}
+6 -3
View File
@@ -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)
}
-1
View File
@@ -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) }
+2 -2
View File
@@ -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"},
}