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:
co-authored by
Claude Fable 5.1
parent
a9b4d7969e
commit
ae72e45bf5
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user