From 240129c83fef3dfaa5db3671df42bcbc685dd539 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 9 Oct 2026 14:09:56 -0700 Subject: [PATCH] =?UTF-8?q?Virtualization=20tab,=20phase=201:=20Incus=20pe?= =?UTF-8?q?r=20machine=20=E2=80=94=20detect,=20install,=20instances,=20con?= =?UTF-8?q?soles,=20snapshots,=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab reports one of four states for the selected machine (ready, repair, installable, unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the pool drivers the kernel has — and acts on that machine only. Install is one followed task: packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file, btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a restart. Instances list with live CPU and memory, create from the images: catalogue or a local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/ delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry, limits editing. Images: local store, catalogue search and download (exported into the library too), import a tarball or a VM disk from a path, publish an instance. Co-Authored-By: Claude Fable 5.1 --- internal/api/server.go | 17 + internal/api/virt.go | 490 +++++++++++++++ internal/incus/incus.go | 1077 +++++++++++++++++++++++++++++++++ internal/incus/subnet.go | 130 ++++ internal/incus/subnet_test.go | 41 ++ web/src/Shell.tsx | 9 +- web/src/Virtualization.tsx | 509 ++++++++++++++++ web/src/api.ts | 28 + web/src/styles.css | 13 + 9 files changed, 2311 insertions(+), 3 deletions(-) create mode 100644 internal/api/virt.go create mode 100644 internal/incus/incus.go create mode 100644 internal/incus/subnet.go create mode 100644 internal/incus/subnet_test.go create mode 100644 web/src/Virtualization.tsx diff --git a/internal/api/server.go b/internal/api/server.go index 65256aa..0622cde 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -34,6 +34,7 @@ import ( "hamlab/internal/cron" "hamlab/internal/files" "hamlab/internal/hardware" + "hamlab/internal/incus" "hamlab/internal/metrics" "hamlab/internal/network" "hamlab/internal/nodes" @@ -75,6 +76,7 @@ type Server struct { update updateState events eventLog kvms kvmProxies + virt *incus.Manager fsLocal files.FS fsSudo files.FS // nil without passwordless sudo nodes *nodes.Manager @@ -104,6 +106,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me if s.user == "" { s.user = "?" } + s.virt = incus.New(cfg.DataDir(), s.user, version) s.migrateUsers() // The speed test and the outside view. Its history outlives a restart, and its last result // is published as sensors, so the line is graphable and alertable like anything else. @@ -239,6 +242,20 @@ func (s *Server) routes() *http.ServeMux { m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents)) m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec)) m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate)) + m.HandleFunc("GET /api/virt", s.auth(s.virtOverview)) + m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall)) + m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages)) + m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload)) + m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport)) + m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish)) + m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete)) + m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate)) + m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance)) + m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename)) + m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig)) + m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate)) + m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction)) + m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction)) 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 new file mode 100644 index 0000000..56b2467 --- /dev/null +++ b/internal/api/virt.go @@ -0,0 +1,490 @@ +package api + +// The Virtualization tab: LXC containers and KVM virtual machines through Incus, per +// machine. Three page states — ready, repairable, installable, or unavailable with the +// reason — and every action lands on the machine that was asked. Long work (install, create, +// image downloads, publish) runs as a followed task; consoles are terminal sessions like +// any other, so they reattach and work through the hub's proxy. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "hamlab/internal/incus" +) + +func readBody(r *http.Request, v any, max int64) error { + return json.NewDecoder(io.LimitReader(r.Body, max)).Decode(v) +} + +// virtOverview is the page: the machine's state, and when Incus answers, everything on it. +func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) { + st := s.virt.Detect(r.Context()) + out := map[string]any{"status": st} + if st.State == "ready" { + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + inst, err := s.virt.Instances(ctx) + if err != nil { + out["error"] = err.Error() + } + out["instances"] = inst + imgs, _ := s.virt.Images(ctx) + out["images"] = imgs + out["pools"] = s.virt.Pools(ctx) + out["networks"] = s.virt.Networks(ctx) + } + out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": s.virt.Library(s.virt.ISOsDir())} + jsonOK(w, 200, out) +} + +func (s *Server) virtInstall(w http.ResponseWriter, r *http.Request) { + var o incus.InstallOpts + if err := readBody(r, &o, 16<<10); err != nil { + jsonErr(w, 400, "bad request") + return + } + st := s.virt.Detect(r.Context()) + if st.State != "installable" && st.State != "repair" { + jsonErr(w, 409, "nothing to install here: "+st.State) + return + } + if err := s.virt.EnsureDirs(); err != nil { + jsonErr(w, 500, err.Error()) + return + } + script, lay, err := s.virt.InstallScript(st, o) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + if err := s.virt.SaveLayout(lay); err != nil { + jsonErr(w, 500, err.Error()) + return + } + // kept beside the layout, so what was run is on record + _ = os.WriteFile(filepath.Join(s.virt.Dir(), "install.sh"), []byte(script), 0o700) + t, err := s.tasks.Start("virt:install", "install Incus", s.virt.ShellCmd(r.Context(), script)) + if err != nil { + jsonErr(w, 409, err.Error()) + return + } + log.Printf("virt: install started as task %s — %s pool, %s (from %s)", t.ID, lay.Driver, lay.Subnet, clientIP(r)) + jsonOK(w, 202, t.Snapshot(false)) +} + +func (s *Server) virtReady(w http.ResponseWriter, r *http.Request) bool { + if st := s.virt.Detect(r.Context()); st.State != "ready" { + jsonErr(w, 409, "Incus isn't ready on this machine") + return false + } + return true +} + +// ---- images ---- + +func (s *Server) virtRemoteImages(w http.ResponseWriter, r *http.Request) { + if !s.virtReady(w, r) { + return + } + list, err := s.virt.Remote(r.Context(), r.URL.Query().Get("refresh") == "1") + if err != nil { + jsonErr(w, 502, err.Error()) + return + } + if t := r.URL.Query().Get("type"); t == "container" || t == "virtual-machine" { + var f []incus.Image + for _, im := range list { + if im.Type == t { + f = append(f, im) + } + } + list = f + } + if list == nil { + list = []incus.Image{} + } + jsonOK(w, 200, map[string]any{"images": list}) +} + +func (s *Server) virtImageDownload(w http.ResponseWriter, r *http.Request) { + var body struct { + Alias string `json:"alias"` + } + _ = readBody(r, &body, 4096) + alias := strings.TrimSpace(body.Alias) + if !incus.ValidImage(alias) || strings.Contains(alias, ":") { + jsonErr(w, 400, "alias: like debian/13 or ubuntu/24.04/cloud") + return + } + if !s.virtReady(w, r) { + return + } + local := strings.ReplaceAll(alias, "/", "-") + // copied into the local store under a flat alias, then exported to the library so the + // file is Ham Lab's whether or not Incus keeps its cache + script := `set -e; incus image copy "images:$1" local: --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"` + t, err := s.tasks.Start("virt:image", "download "+alias, s.virt.ShellCmd(r.Context(), script, alias, local, s.virt.ImagesDir())) + if err != nil { + jsonErr(w, 409, err.Error()) + return + } + log.Printf("virt: image %s download as task %s (from %s)", alias, t.ID, clientIP(r)) + jsonOK(w, 202, t.Snapshot(false)) +} + +func (s *Server) virtImageImport(w http.ResponseWriter, r *http.Request) { + var body struct { + Path string `json:"path"` + Alias string `json:"alias"` + Type string `json:"type"` // container | virtual-machine + } + _ = readBody(r, &body, 8192) + alias := strings.TrimSpace(body.Alias) + if !incus.ValidImage(alias) || strings.Contains(alias, ":") { + jsonErr(w, 400, "alias: letters, digits, dots, dashes") + return + } + p := filepath.Clean(body.Path) + st, err := os.Stat(p) + if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() { + jsonErr(w, 400, "path: a file on this machine") + return + } + if !s.virtReady(w, r) { + return + } + dest := filepath.Join(s.virt.ImagesDir(), filepath.Base(p)) + var script string + if body.Type == "virtual-machine" { + // a bare disk image needs the metadata Incus wants beside it; a small tarball Ham Lab writes + script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; META="$3/$4.metadata.tar.gz"; T=$(mktemp -d); printf 'architecture: %s\ncreation_date: %s\nproperties:\n description: "%s"\n os: custom\n' "$5" "$(date +%s)" "$4" > "$T/metadata.yaml"; tar -C "$T" -czf "$META" metadata.yaml; rm -rf "$T"; incus image import "$META" "$2" --alias "$4"; echo "imported $4 from $2"` + } else { + script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; incus image import "$2" --alias "$4"; echo "imported $4 from $2"` + } + t, err := s.tasks.Start("virt:image", "import "+alias, s.virt.ShellCmd(r.Context(), script, p, dest, s.virt.ImagesDir(), alias, incus.IncusArch())) + if err != nil { + jsonErr(w, 409, err.Error()) + return + } + log.Printf("virt: image import %s from %s as task %s (from %s)", alias, p, t.ID, clientIP(r)) + jsonOK(w, 202, t.Snapshot(false)) +} + +func (s *Server) virtImagePublish(w http.ResponseWriter, r *http.Request) { + var body struct { + Instance string `json:"instance"` + Alias string `json:"alias"` + } + _ = readBody(r, &body, 4096) + alias := strings.TrimSpace(body.Alias) + if !incus.ValidName(body.Instance) || !incus.ValidImage(alias) || strings.Contains(alias, ":") { + jsonErr(w, 400, "an instance and an alias") + return + } + if !s.virtReady(w, r) { + return + } + script := `set -e; incus publish "$1" --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"` + t, err := s.tasks.Start("virt:image", "publish "+body.Instance+" as "+alias, s.virt.ShellCmd(r.Context(), script, body.Instance, alias, s.virt.ImagesDir())) + if err != nil { + jsonErr(w, 409, err.Error()) + return + } + log.Printf("virt: publish %s as %s, task %s (from %s)", body.Instance, alias, t.ID, clientIP(r)) + jsonOK(w, 202, t.Snapshot(false)) +} + +func (s *Server) virtImageDelete(w http.ResponseWriter, r *http.Request) { + var body struct { + Ref string `json:"ref"` + } + _ = readBody(r, &body, 4096) + if !incus.ValidImage(body.Ref) { + jsonErr(w, 400, "bad image") + return + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + if out, err := s.virt.Cmd(ctx, "image", "delete", body.Ref).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: image %s deleted (from %s)", body.Ref, clientIP(r)) + w.WriteHeader(204) +} + +// ---- instances ---- + +func (s *Server) virtCreate(w http.ResponseWriter, r *http.Request) { + var sp incus.CreateSpec + if err := readBody(r, &sp, 512<<10); err != nil { + jsonErr(w, 400, "bad request") + return + } + st := s.virt.Detect(r.Context()) + if st.State != "ready" { + jsonErr(w, 409, "Incus isn't ready on this machine") + return + } + args, err := incus.CreateArgs(sp, st.KVM) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + if inst, err := s.virt.Instances(r.Context()); err == nil { + for _, in := range inst { + if in.Name == sp.Name { + jsonErr(w, 409, "there is already an instance called "+sp.Name) + return + } + } + } + kind := "container" + if sp.VM { + kind = "VM" + } + t, err := s.tasks.Start("virt:create", fmt.Sprintf("create %s %s", kind, sp.Name), s.virt.Cmd(r.Context(), args...)) + if err != nil { + jsonErr(w, 409, err.Error()) + return + } + log.Printf("virt: create %s %s from %s as task %s (from %s)", kind, sp.Name, sp.Image, t.ID, clientIP(r)) + jsonOK(w, 202, t.Snapshot(false)) +} + +func (s *Server) virtInstance(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !incus.ValidName(name) { + jsonErr(w, 400, "bad name") + return + } + list, err := s.virt.Instances(r.Context()) + if err != nil { + jsonErr(w, 502, err.Error()) + return + } + for _, in := range list { + if in.Name == name { + jsonOK(w, 200, in) + return + } + } + jsonErr(w, 404, "no such instance") +} + +// virtAction is start / stop / restart / kill / delete, and the two consoles. +func (s *Server) virtAction(w http.ResponseWriter, r *http.Request) { + name, action := r.PathValue("name"), r.PathValue("action") + if !incus.ValidName(name) { + jsonErr(w, 400, "bad name") + return + } + if !s.virtReady(w, r) { + return + } + var args []string + switch action { + case "start": + args = []string{"start", name} + case "stop": + args = []string{"stop", name} + case "restart": + args = []string{"restart", name} + case "kill": + args = []string{"stop", "--force", name} + case "delete": + var body struct { + Confirm string `json:"confirm"` + Force bool `json:"force"` + } + _ = readBody(r, &body, 4096) + if body.Confirm != name { + jsonErr(w, 400, "type the instance's name to delete it") + return + } + args = []string{"delete", name} + if body.Force { + args = []string{"delete", "--force", name} + } + case "console", "exec": + s.virtTerminal(w, r, name, action) + return + default: + jsonErr(w, 400, "unknown action") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: %s %s (from %s)", action, name, clientIP(r)) + w.WriteHeader(204) +} + +// virtTerminal opens the serial console or a shell as a terminal session. +func (s *Server) virtTerminal(w http.ResponseWriter, r *http.Request, name, kind string) { + var argv []string + title := "" + if kind == "console" { + argv = s.virt.Argv(r.Context(), "console", name) + title = "console " + name + } else { + argv = s.virt.Argv(r.Context(), "exec", name, "--", "sh", "-c", "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi") + title = "sh " + name + } + sess, err := s.mgr.CreateCmd("", title, argv) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("virt: %s of %s as terminal %s (from %s)", kind, name, sess.ID, clientIP(r)) + jsonOK(w, 200, map[string]string{"id": sess.ID, "name": title}) +} + +func (s *Server) virtRename(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + var body struct { + Name string `json:"name"` + } + _ = readBody(r, &body, 4096) + if !incus.ValidName(name) || !incus.ValidName(body.Name) { + jsonErr(w, 400, "bad name") + return + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + if out, err := s.virt.Cmd(ctx, "rename", name, body.Name).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: renamed %s to %s (from %s)", name, body.Name, clientIP(r)) + w.WriteHeader(204) +} + +// virtConfig sets limits, the snapshot schedule, or a description — the config keys the page +// edits, each validated for its shape. An empty value unsets the key. +func (s *Server) virtConfig(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + var body map[string]string + if err := readBody(r, &body, 16<<10); err != nil || !incus.ValidName(name) { + jsonErr(w, 400, "bad request") + return + } + var set, unset []string + for k, v := range body { + v = strings.TrimSpace(v) + ok := false + switch k { + case "limits.cpu": + ok = incus.ValidCPU(v) + case "limits.memory": + ok = incus.ValidSize(v) + case "snapshots.schedule": + ok = incus.ValidCron(v) + case "snapshots.expiry": + ok = incus.ValidExpiry(v) + case "description": + ok = len(v) <= 200 && !strings.ContainsAny(v, "\n\r") + case "boot.autostart": + ok = v == "" || v == "true" || v == "false" + } + if !ok { + jsonErr(w, 400, "bad value for "+k) + return + } + if v == "" { + unset = append(unset, k) + } else { + set = append(set, k+"="+v) + } + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + if len(set) > 0 { + if out, err := s.virt.Cmd(ctx, append([]string{"config", "set", name}, set...)...).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + } + for _, k := range unset { + _, _ = s.virt.Cmd(ctx, "config", "unset", name, k).CombinedOutput() // unsetting what isn't set is fine + } + log.Printf("virt: config %s set %v unset %v (from %s)", name, set, unset, clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) virtSnapshotCreate(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + var body struct { + Name string `json:"name"` + } + _ = readBody(r, &body, 4096) + snap := strings.TrimSpace(body.Name) + if snap == "" { + snap = time.Now().Format("2006-01-02_15.04.05") + } + if !incus.ValidName(name) || !incus.ValidSnap(snap) { + jsonErr(w, 400, "bad name") + return + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + if out, err := s.virt.Cmd(ctx, "snapshot", "create", name, snap).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: snapshot %s/%s (from %s)", name, snap, clientIP(r)) + jsonOK(w, 200, map[string]string{"name": snap}) +} + +func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) { + name, snap, action := r.PathValue("name"), r.PathValue("snap"), r.PathValue("action") + if !incus.ValidName(name) || !incus.ValidSnap(snap) { + jsonErr(w, 400, "bad name") + return + } + var args []string + switch action { + case "restore": + args = []string{"snapshot", "restore", name, snap} + case "delete": + args = []string{"snapshot", "delete", name, snap} + default: + jsonErr(w, 400, "unknown action") + return + } + if !s.virtReady(w, r) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer cancel() + if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { + jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) + return + } + log.Printf("virt: snapshot %s %s/%s (from %s)", action, name, snap, clientIP(r)) + w.WriteHeader(204) +} diff --git a/internal/incus/incus.go b/internal/incus/incus.go new file mode 100644 index 0000000..25439ac --- /dev/null +++ b/internal/incus/incus.go @@ -0,0 +1,1077 @@ +// Package incus is the Virtualization tab's engine: LXC containers and KVM virtual machines +// through Incus, driven the way the Docker tab drives Docker — the CLI, `--format json`, as +// this user first and as root if the socket says no. It also decides, per machine, whether +// Incus is running, installable or out of reach, and owns the install: what to apt/dnf in, +// where the pool's backing file goes (always Ham Lab's own folder), which subnet the bridge +// takes, and the two iptables rules that keep Docker from dropping the bridge's traffic. +package incus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/netip" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "hamlab/internal/priv" +) + +const ( + PoolName = "default" + BridgeName = "incusbr0" + ZPoolName = "hamlab-virt" // the ZFS pool made on the backing file; Incus is handed it by name + AdminGroup = "incus-admin" + HamlabUnit = "hamlab.service" + NetUnit = "hamlab-virt-net.service" // the Docker FORWARD fix, re-applied after every boot + remoteTTL = time.Hour +) + +// Layout is what Ham Lab chose at install, kept as virt.json beside the data so the page can +// say where everything is and a reinstall can put the pool back from the same file. +type Layout struct { + Driver string `json:"driver"` // zfs | btrfs | dir + PoolSource string `json:"pool_source"` + PoolSizeGiB int `json:"pool_size_gib,omitempty"` + Bridge string `json:"bridge"` + Subnet string `json:"subnet"` + DockerRules bool `json:"docker_rules"` + ARCMaxGiB int `json:"arc_max_gib,omitempty"` + Attempted time.Time `json:"attempted"` + Version string `json:"hamlab_version,omitempty"` +} + +type Manager struct { + dir string // /virt + user string // the account the daemon runs as; joins incus-admin at install + version string + + mu sync.Mutex + cpuPrev map[string]cpuMark + remote struct { + at time.Time + list []Image + } + pm struct { + at time.Time + name string + candidate bool + } +} + +type cpuMark struct { + ns int64 + at time.Time +} + +func New(dataDir, user, version string) *Manager { + return &Manager{dir: filepath.Join(dataDir, "virt"), user: user, version: version, cpuPrev: map[string]cpuMark{}} +} + +func (m *Manager) Dir() string { return m.dir } +func (m *Manager) ImagesDir() string { return filepath.Join(m.dir, "images") } +func (m *Manager) ISOsDir() string { return filepath.Join(m.dir, "isos") } +func (m *Manager) ExportsDir() string { + return filepath.Join(m.dir, "exports") +} + +// EnsureDirs makes the tree, as this user, before root ever writes into it. +func (m *Manager) EnsureDirs() error { + for _, d := range []string{m.dir, m.ImagesDir(), m.ISOsDir(), m.ExportsDir()} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + return nil +} + +func (m *Manager) layoutPath() string { return filepath.Join(m.dir, "virt.json") } + +func (m *Manager) Layout() *Layout { + b, err := os.ReadFile(m.layoutPath()) + if err != nil { + return nil + } + var l Layout + if json.Unmarshal(b, &l) != nil { + return nil + } + return &l +} + +func (m *Manager) SaveLayout(l Layout) error { + if err := m.EnsureDirs(); err != nil { + return err + } + b, _ := json.MarshalIndent(l, "", " ") + return os.WriteFile(m.layoutPath(), b, 0o644) +} + +// ---- detection ---- + +// Status is what the page shows first: can this machine do it, and what is in the way. +type Status struct { + State string `json:"state"` // ready | repair | installable | unavailable + Reason string `json:"reason,omitempty"` + KVM bool `json:"kvm"` + Arch string `json:"arch"` // Incus's name for it: x86_64, aarch64… + Version string `json:"version,omitempty"` + PM string `json:"pm,omitempty"` + Drivers []string `json:"drivers"` // storage drivers this machine can back a pool with, best first + Docker bool `json:"docker"` + ZFSHost bool `json:"zfs_host"` + ARCMaxGiB int `json:"arc_max_gib,omitempty"` + MemTotal uint64 `json:"mem_total"` + MemAvail uint64 `json:"mem_avail"` + DiskFree uint64 `json:"disk_free"` + Dir string `json:"dir"` + Subnet string `json:"subnet"` // proposed when installable, in use when ready + PoolSize int `json:"pool_size_gib,omitempty"` // proposed + Layout *Layout `json:"layout,omitempty"` + User string `json:"user"` + Root bool `json:"root"` + Unit string `json:"unit,omitempty"` + BtrfsTools bool `json:"btrfs_tools"` +} + +// IncusArch maps Go's architecture names to the ones Incus and its image server use. +func IncusArch() string { + switch runtime.GOARCH { + case "amd64": + return "x86_64" + case "arm64": + return "aarch64" + case "arm": + return "armv7l" + case "386": + return "i686" + case "riscv64": + return "riscv64" + case "ppc64le": + return "ppc64le" + case "s390x": + return "s390x" + } + return runtime.GOARCH +} + +func have(bin string) bool { + if _, err := exec.LookPath(bin); err == nil { + return true + } + for _, d := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/bin"} { // a daemon's PATH often lacks sbin + if st, err := os.Stat(filepath.Join(d, bin)); err == nil && !st.IsDir() { + return true + } + } + return false +} + +func meminfo() (total, avail uint64) { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0 + } + for _, l := range strings.Split(string(b), "\n") { + f := strings.Fields(l) + if len(f) < 2 { + continue + } + v, _ := strconv.ParseUint(f[1], 10, 64) + switch f[0] { + case "MemTotal:": + total = v << 10 + case "MemAvailable:": + avail = v << 10 + } + } + return +} + +func diskFree(path string) uint64 { + var st syscall.Statfs_t + for p := path; ; p = filepath.Dir(p) { + if err := syscall.Statfs(p, &st); err == nil { + return st.Bavail * uint64(st.Bsize) + } + if p == "/" || p == "." { + return 0 + } + } +} + +// packageManager finds one Ham Lab can drive and whether it offers incus. The candidate +// check is an exec — dnf's can take seconds — so the answer is kept for an hour. +func (m *Manager) packageManager(ctx context.Context) (string, bool) { + m.mu.Lock() + if time.Since(m.pm.at) < time.Hour { + n, c := m.pm.name, m.pm.candidate + m.mu.Unlock() + return n, c + } + m.mu.Unlock() + name, cand := "", false + c, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + switch { + case have("apt-get"): + name = "apt" + if out, err := exec.CommandContext(c, "apt-cache", "policy", "incus").Output(); err == nil { + cand = strings.Contains(string(out), "Candidate:") && !strings.Contains(string(out), "Candidate: (none)") + } + case have("dnf"): + name = "dnf" + cand = exec.CommandContext(c, "dnf", "-q", "info", "incus").Run() == nil + case have("pacman"): + name = "pacman" + cand = exec.CommandContext(c, "pacman", "-Si", "incus").Run() == nil + case have("apk"): + name = "apk" + if out, err := exec.CommandContext(c, "apk", "search", "-e", "incus").Output(); err == nil { + cand = strings.TrimSpace(string(out)) != "" + } + case have("opkg"): + name = "opkg" + } + m.mu.Lock() + m.pm.at, m.pm.name, m.pm.candidate = time.Now(), name, cand + m.mu.Unlock() + return name, cand +} + +func kernelHas(fs string) bool { + if b, err := os.ReadFile("/proc/filesystems"); err == nil && strings.Contains(string(b), fs) { + return true + } + if exec.Command("modinfo", "-n", fs).Run() == nil { + return true + } + matches, _ := filepath.Glob(fmt.Sprintf("/lib/modules/*/kernel/fs/%s/%s.ko*", fs, fs)) + return len(matches) > 0 +} + +func arcMaxGiB() int { + b, err := os.ReadFile("/sys/module/zfs/parameters/zfs_arc_max") + if err != nil { + return 0 + } + v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) + if v <= 0 { // 0 means the module default: half of RAM, or the whole of it on 2.x + total, _ := meminfo() + return int(total >> 31) + } + return int(v >> 30) +} + +// Detect works out the page's state. Cheap enough to run on every load: a few stats, one +// `incus query` when the binary is there, and a cached package-manager answer otherwise. +func (m *Manager) Detect(ctx context.Context) Status { + total, avail := meminfo() + st := Status{Arch: IncusArch(), Dir: m.dir, User: m.user, Root: priv.Available(), Unit: HamlabUnit, + MemTotal: total, MemAvail: avail, DiskFree: diskFree(m.dir), Layout: m.Layout(), Docker: have("docker") || have("podman")} + if _, err := os.Stat("/dev/kvm"); err == nil { + st.KVM = true + } + if _, err := os.Stat("/sys/module/zfs"); err == nil { + st.ZFSHost = true + st.ARCMaxGiB = arcMaxGiB() + if have("zpool") && have("zfs") { + st.Drivers = append(st.Drivers, "zfs") + } + } + if kernelHas("btrfs") { + st.Drivers = append(st.Drivers, "btrfs") + st.BtrfsTools = have("mkfs.btrfs") + } + st.Drivers = append(st.Drivers, "dir") + // a quarter of what is free, between 10 GiB and 300 GiB, rounded down to whole GiB + st.PoolSize = int(st.DiskFree >> 30 / 4) + if st.PoolSize > 300 { + st.PoolSize = 300 + } + if st.PoolSize < 10 { + st.PoolSize = 10 + } + + if have("incus") { + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := m.run(c, "query", "/1.0") + if err == nil { + var info struct { + Environment struct { + ServerVersion string `json:"server_version"` + } `json:"environment"` + } + _ = json.Unmarshal(out, &info) + st.State, st.Version = "ready", info.Environment.ServerVersion + if st.Layout != nil { + st.Subnet = st.Layout.Subnet + } else if b, err := m.run(c, "query", "/1.0/networks/"+BridgeName); err == nil { + var n struct { + Config map[string]string `json:"config"` + } + if json.Unmarshal(b, &n) == nil { + st.Subnet = n.Config["ipv4.address"] + } + } + return st + } + low := strings.ToLower(err.Error()) + st.State = "repair" + switch { + case strings.Contains(low, "permission denied"): + st.Reason = fmt.Sprintf("Incus is installed, but Ham Lab's account (%s) isn't in the %s group yet.", m.user, AdminGroup) + case strings.Contains(low, "connection refused"), strings.Contains(low, "no such file"), strings.Contains(low, "not running"): + st.Reason = "Incus is installed but its daemon isn't running." + default: + st.Reason = "Incus is installed but didn't answer: " + err.Error() + } + if !st.Root { + st.State, st.Reason = "unavailable", st.Reason+" Ham Lab has no root here to fix that." + } + return st + } + + pm, cand := m.packageManager(ctx) + st.PM = pm + switch { + case pm == "opkg": + st.State, st.Reason = "unavailable", "Incus isn't packaged for OpenWrt." + case pm == "": + st.State, st.Reason = "unavailable", "No package manager Ham Lab knows how to drive here (apt, dnf, pacman, apk)." + case !cand: + st.State, st.Reason = "unavailable", fmt.Sprintf("This machine's package manager (%s) offers no incus package.", pm) + case !st.Root: + st.State, st.Reason = "unavailable", "Incus can be installed with "+pm+", but Ham Lab has no root on this machine to do it." + default: + st.State = "installable" + st.Subnet = Gateway(PickSubnet(TakenPrefixes(ctx))) + } + if st.State == "unavailable" && !st.KVM { + st.Reason += " This kernel has no KVM either." + } + return st +} + +// ---- running the CLI ---- + +func lastLine(out []byte, err error) error { + msg := strings.TrimSpace(string(out)) + if i := strings.LastIndex(msg, "\n"); i >= 0 { + msg = msg[i+1:] + } + msg = strings.TrimPrefix(msg, "Error: ") + if msg == "" { + msg = err.Error() + } + return errors.New(msg) +} + +// run executes incus as this user, and again as root when the socket refuses — the state a +// fresh install is in until the daemon restarts with its new group. +func (m *Manager) run(ctx context.Context, args ...string) ([]byte, error) { + out, err := exec.CommandContext(ctx, "incus", args...).CombinedOutput() + if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") { + full := priv.Wrap(append([]string{"incus"}, args...)...) + out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput() + } + if err != nil { + return out, lastLine(out, err) + } + return out, nil +} + +// Reachable reports whether incus answers this user without help; a terminal's argv is +// wrapped in sudo when it does not. +func (m *Manager) Reachable(ctx context.Context) bool { + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return exec.CommandContext(c, "incus", "query", "/1.0").Run() == nil +} + +// Argv is a command line for incus, wrapped for root when this user cannot reach it. +func (m *Manager) Argv(ctx context.Context, args ...string) []string { + argv := append([]string{"incus"}, args...) + if !m.Reachable(ctx) && priv.Available() { + return priv.Wrap(argv...) + } + return argv +} + +// Cmd is the same as Argv, as an exec.Cmd for the task runner. +func (m *Manager) Cmd(ctx context.Context, args ...string) *exec.Cmd { + argv := m.Argv(ctx, args...) + return exec.Command(argv[0], argv[1:]...) +} + +// ShellCmd runs a small script with positional parameters, as root when needed. Values +// travel as $1, $2… rather than being spliced into the script, so nothing needs quoting. +func (m *Manager) ShellCmd(ctx context.Context, script string, params ...string) *exec.Cmd { + argv := append([]string{"sh", "-c", script, "hamlab"}, params...) + if !m.Reachable(ctx) && priv.Available() { + argv = priv.Wrap(argv...) + } + return exec.Command(argv[0], argv[1:]...) +} + +// ---- instances ---- + +type Snapshot struct { + Name string `json:"name"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Size int64 `json:"size,omitempty"` +} + +type Instance struct { + Name string `json:"name"` + Type string `json:"type"` // container | virtual-machine + Status string `json:"status"` + Arch string `json:"arch"` + Description string `json:"description,omitempty"` + Image string `json:"image"` + OS string `json:"os,omitempty"` + Release string `json:"release,omitempty"` + CPU string `json:"cpu,omitempty"` + Memory string `json:"memory,omitempty"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"last_used"` + IPv4 []string `json:"ipv4"` + Snapshots []Snapshot `json:"snapshots"` + Profiles []string `json:"profiles"` + Ephemeral bool `json:"ephemeral"` + CPUPct float64 `json:"cpu_pct"` + MemUsage uint64 `json:"mem_usage"` + MemPeak uint64 `json:"mem_peak"` + DiskUsage uint64 `json:"disk_usage"` + DiskTotal uint64 `json:"disk_total"` + PID int `json:"pid"` + Processes int `json:"processes"` + Config map[string]string `json:"config"` + Devices map[string]map[string]string `json:"devices"` + Schedule string `json:"schedule,omitempty"` + Expiry string `json:"expiry,omitempty"` +} + +type rawInstance struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Arch string `json:"architecture"` + Description string `json:"description"` + Config map[string]string `json:"expanded_config"` + Devices map[string]map[string]string `json:"expanded_devices"` + Profiles []string `json:"profiles"` + Ephemeral bool `json:"ephemeral"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` + State *struct { + CPU struct { + Usage int64 `json:"usage"` + } `json:"cpu"` + Memory struct { + Usage uint64 `json:"usage"` + UsagePeak uint64 `json:"usage_peak"` + } `json:"memory"` + Disk map[string]struct { + Usage uint64 `json:"usage"` + Total uint64 `json:"total"` + } `json:"disk"` + Network map[string]struct { + Addresses []struct { + Family string `json:"family"` + Address string `json:"address"` + Scope string `json:"scope"` + } `json:"addresses"` + } `json:"network"` + PID int `json:"pid"` + Processes int `json:"processes"` + } `json:"state"` + Snapshots []struct { + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at"` + Size int64 `json:"size"` + } `json:"snapshots"` +} + +// secretKeys are config values never sent to the browser whole: cloud-init carries passwords. +func secretKey(k string) bool { + return strings.Contains(k, "user-data") || strings.Contains(k, "vendor-data") || strings.Contains(k, "network-config") || strings.Contains(k, "user.") +} + +func (m *Manager) Instances(ctx context.Context) ([]Instance, error) { + c, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + out, err := m.run(c, "list", "--format", "json") + if err != nil { + return nil, err + } + var raw []rawInstance + if err := json.Unmarshal(out, &raw); err != nil { + return nil, errors.New("unreadable answer from incus") + } + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + seen := map[string]bool{} + var list []Instance + for _, r := range raw { + in := Instance{Name: r.Name, Type: r.Type, Status: r.Status, Arch: r.Arch, Description: r.Description, Profiles: r.Profiles, Ephemeral: r.Ephemeral, + Created: r.CreatedAt, LastUsed: r.LastUsedAt, IPv4: []string{}, Snapshots: []Snapshot{}, Config: map[string]string{}, Devices: r.Devices} + if in.Devices == nil { + in.Devices = map[string]map[string]string{} + } + for k, v := range r.Config { + if strings.HasPrefix(k, "volatile.") { + continue + } + if secretKey(k) { + v = fmt.Sprintf("(set · %d bytes)", len(v)) + } + in.Config[k] = v + } + in.Image = r.Config["image.description"] + if in.Image == "" { + in.Image = r.Config["volatile.base_image"] + if len(in.Image) > 12 { + in.Image = in.Image[:12] + } + } + in.OS, in.Release = r.Config["image.os"], r.Config["image.release"] + in.CPU, in.Memory = r.Config["limits.cpu"], r.Config["limits.memory"] + in.Schedule, in.Expiry = r.Config["snapshots.schedule"], r.Config["snapshots.expiry"] + for _, s := range r.Snapshots { + in.Snapshots = append(in.Snapshots, Snapshot{Name: s.Name, Created: s.CreatedAt, Expires: s.ExpiresAt, Size: s.Size}) + } + if r.State != nil { + in.MemUsage, in.MemPeak = r.State.Memory.Usage, r.State.Memory.UsagePeak + in.PID, in.Processes = r.State.PID, r.State.Processes + if d, ok := r.State.Disk["root"]; ok { + in.DiskUsage, in.DiskTotal = d.Usage, d.Total + } + names := make([]string, 0, len(r.State.Network)) + for n := range r.State.Network { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + if n == "lo" { + continue + } + for _, a := range r.State.Network[n].Addresses { + if a.Family == "inet" && a.Scope == "global" { + in.IPv4 = append(in.IPv4, a.Address) + } + } + } + if r.Status == "Running" { + if prev, ok := m.cpuPrev[r.Name]; ok && now.After(prev.at) && r.State.CPU.Usage >= prev.ns { + in.CPUPct = float64(r.State.CPU.Usage-prev.ns) / float64(now.Sub(prev.at).Nanoseconds()) * 100 + } + m.cpuPrev[r.Name] = cpuMark{ns: r.State.CPU.Usage, at: now} + seen[r.Name] = true + } + } + list = append(list, in) + } + for n := range m.cpuPrev { + if !seen[n] { + delete(m.cpuPrev, n) + } + } + if list == nil { + list = []Instance{} + } + return list, nil +} + +// ---- pools and networks ---- + +type Pool struct { + Name string `json:"name"` + Driver string `json:"driver"` + Source string `json:"source"` + Used uint64 `json:"used"` + Total uint64 `json:"total"` + UsedBy int `json:"used_by"` +} + +type Network struct { + Name string `json:"name"` + Type string `json:"type"` + Managed bool `json:"managed"` + Address string `json:"address,omitempty"` + NAT bool `json:"nat"` + UsedBy int `json:"used_by"` +} + +func (m *Manager) Pools(ctx context.Context) []Pool { + c, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := m.run(c, "storage", "list", "--format", "json") + if err != nil { + return nil + } + var raw []struct { + Name string `json:"name"` + Driver string `json:"driver"` + Config map[string]string `json:"config"` + UsedBy []string `json:"used_by"` + } + if json.Unmarshal(out, &raw) != nil { + return nil + } + var out2 []Pool + for _, p := range raw { + pool := Pool{Name: p.Name, Driver: p.Driver, Source: 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 { + Used uint64 `json:"used"` + Total uint64 `json:"total"` + } `json:"space"` + } + if json.Unmarshal(b, &res) == nil { + pool.Used, pool.Total = res.Space.Used, res.Space.Total + } + } + out2 = append(out2, pool) + } + return out2 +} + +func (m *Manager) Networks(ctx context.Context) []Network { + c, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := m.run(c, "network", "list", "--format", "json") + if err != nil { + return nil + } + var raw []struct { + Name string `json:"name"` + Type string `json:"type"` + Managed bool `json:"managed"` + Config map[string]string `json:"config"` + UsedBy []string `json:"used_by"` + } + if json.Unmarshal(out, &raw) != nil { + return nil + } + var list []Network + for _, n := range raw { + if n.Type == "loopback" || strings.HasPrefix(n.Name, "veth") || strings.HasPrefix(n.Name, "tap") { + continue + } + list = append(list, Network{Name: n.Name, Type: n.Type, Managed: n.Managed, Address: n.Config["ipv4.address"], NAT: n.Config["ipv4.nat"] == "true", UsedBy: len(n.UsedBy)}) + } + return list +} + +// ---- images ---- + +type Image struct { + Fingerprint string `json:"fingerprint"` + Aliases []string `json:"aliases"` + Arch string `json:"arch"` + Type string `json:"type"` // container | virtual-machine + Size uint64 `json:"size"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"last_used"` + Description string `json:"description"` + OS string `json:"os,omitempty"` + Release string `json:"release,omitempty"` + Variant string `json:"variant,omitempty"` + Cached bool `json:"cached"` + Source string `json:"source,omitempty"` + Public bool `json:"public"` +} + +type rawImage struct { + Fingerprint string `json:"fingerprint"` + Aliases []struct { + Name string `json:"name"` + } `json:"aliases"` + Architecture string `json:"architecture"` + Type string `json:"type"` + Size uint64 `json:"size"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` + Properties map[string]string `json:"properties"` + Cached bool `json:"cached"` + Public bool `json:"public"` + UpdateSource *struct { + Alias string `json:"alias"` + Server string `json:"server"` + } `json:"update_source"` +} + +func toImage(r rawImage) Image { + im := Image{Fingerprint: r.Fingerprint, Arch: r.Architecture, Type: r.Type, Size: r.Size, Created: r.CreatedAt, LastUsed: r.LastUsedAt, Cached: r.Cached, Public: r.Public, + Description: r.Properties["description"], OS: r.Properties["os"], Release: r.Properties["release"], Variant: r.Properties["variant"], Aliases: []string{}} + if im.Type == "" { + im.Type = "container" + } + for _, a := range r.Aliases { + im.Aliases = append(im.Aliases, a.Name) + } + if r.UpdateSource != nil { + im.Source = r.UpdateSource.Alias + if im.Source == "" { + im.Source = r.UpdateSource.Server + } + } + return im +} + +func (m *Manager) Images(ctx context.Context) ([]Image, error) { + c, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + out, err := m.run(c, "image", "list", "--format", "json") + if err != nil { + return nil, err + } + var raw []rawImage + if err := json.Unmarshal(out, &raw); err != nil { + return nil, errors.New("unreadable image list") + } + list := make([]Image, 0, len(raw)) + for _, r := range raw { + list = append(list, toImage(r)) + } + sort.Slice(list, func(i, j int) bool { return list[i].Description < list[j].Description }) + return list, nil +} + +// Remote is the public catalogue (the `images:` remote Incus ships pointed at +// images.linuxcontainers.org), narrowed to what this machine can run. A few megabytes of +// JSON, so it is kept for an hour. +func (m *Manager) Remote(ctx context.Context, refresh bool) ([]Image, error) { + m.mu.Lock() + if !refresh && time.Since(m.remote.at) < remoteTTL && m.remote.list != nil { + l := m.remote.list + m.mu.Unlock() + return l, nil + } + m.mu.Unlock() + c, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + out, err := m.run(c, "image", "list", "images:", "--format", "json", "architecture="+IncusArch()) + if err != nil { + return nil, err + } + var raw []rawImage + if err := json.Unmarshal(out, &raw); err != nil { + return nil, errors.New("unreadable catalogue") + } + arch := IncusArch() + var list []Image + for _, r := range raw { + if r.Architecture != arch || len(r.Aliases) == 0 { + continue + } + list = append(list, toImage(r)) + } + sort.Slice(list, func(i, j int) bool { + if list[i].OS != list[j].OS { + return list[i].OS < list[j].OS + } + return list[i].Description < list[j].Description + }) + m.mu.Lock() + m.remote.at, m.remote.list = time.Now(), list + m.mu.Unlock() + return list, nil +} + +// LibraryFile is one file in virt/images or virt/isos. +type LibraryFile struct { + Name string `json:"name"` + Size int64 `json:"size"` + Modified time.Time `json:"modified"` +} + +func (m *Manager) Library(dir string) []LibraryFile { + ents, err := os.ReadDir(dir) + if err != nil { + return []LibraryFile{} + } + out := []LibraryFile{} + for _, e := range ents { + if e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + if info, err := e.Info(); err == nil { + out = append(out, LibraryFile{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()}) + } + } + return out +} + +// ---- validation ---- + +var ( + nameRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]{0,62}$`) + imageRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$`) + sizeRe = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?(B|[KMGT]i?B)?$`) + cpuRe = regexp.MustCompile(`^[0-9]{1,3}(-[0-9]{1,3})?(,[0-9]{1,3}(-[0-9]{1,3})?)*$`) + snapRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`) + cronRe = regexp.MustCompile(`^(@(hourly|daily|weekly|monthly|yearly|annually|startup|never)|[0-9*/,-]+( [0-9*/,-]+){4})$`) + expRe = regexp.MustCompile(`^[0-9]+[mHdwMy]( [0-9]+[mHdwMy])*$|^$`) +) + +func ValidName(s string) bool { return nameRe.MatchString(s) } +func ValidImage(s string) bool { return imageRe.MatchString(s) } +func ValidSize(s string) bool { return s == "" || sizeRe.MatchString(s) } +func ValidCPU(s string) bool { return s == "" || cpuRe.MatchString(s) } +func ValidSnap(s string) bool { return snapRe.MatchString(s) } +func ValidCron(s string) bool { return s == "" || cronRe.MatchString(s) } +func ValidExpiry(s string) bool { return expRe.MatchString(s) } +func ValidNetwork(s string) bool { return s == "" || nameRe.MatchString(s) } + +// CreateSpec is the New instance form. +type CreateSpec struct { + Name string `json:"name"` + Image string `json:"image"` // "images:debian/13" or a local alias + VM bool `json:"vm"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + Disk string `json:"disk"` + Network string `json:"network"` + CloudInit string `json:"cloud_init"` + Profiles []string `json:"profiles"` + Description string `json:"description"` + Start bool `json:"start"` +} + +// CreateArgs turns the form into the incus command line, or says what is wrong with it. +func CreateArgs(sp CreateSpec, kvm bool) ([]string, error) { + switch { + case !ValidName(sp.Name): + return nil, errors.New("name: letters, digits and dashes, starting with a letter, up to 63") + case !ValidImage(sp.Image): + return nil, errors.New("image: an alias like images:debian/13") + case sp.VM && !kvm: + return nil, errors.New("this machine has no KVM, so it can run containers only") + case !ValidCPU(sp.CPU): + return nil, errors.New("cpu: a count, like 2") + case !ValidSize(sp.Memory): + return nil, errors.New("memory: a size, like 2GiB") + case !ValidSize(sp.Disk): + return nil, errors.New("disk: a size, like 20GiB") + case !ValidNetwork(sp.Network): + return nil, errors.New("network: a bridge name") + case len(sp.CloudInit) > 256<<10: + return nil, errors.New("cloud-init: too long") + case len(sp.Description) > 200 || strings.ContainsAny(sp.Description, "\n\r"): + return nil, errors.New("description: one short line") + } + verb := "init" + if sp.Start { + verb = "launch" + } + args := []string{verb, sp.Image, sp.Name} + if sp.VM { + args = append(args, "--vm") + } + if sp.CPU != "" { + args = append(args, "-c", "limits.cpu="+sp.CPU) + } + if sp.Memory != "" { + args = append(args, "-c", "limits.memory="+sp.Memory) + } + if sp.Disk != "" { + args = append(args, "-d", "root,size="+sp.Disk) + } + if sp.Network != "" { + args = append(args, "-n", sp.Network) + } + if strings.TrimSpace(sp.CloudInit) != "" { + args = append(args, "-c", "cloud-init.user-data="+sp.CloudInit) + } + for _, p := range sp.Profiles { + if !ValidName(p) { + return nil, errors.New("profile: " + p) + } + args = append(args, "--profile", p) + } + return args, nil +} + +// ---- install ---- + +// InstallOpts is what the Install dialog lets him change. Where things go is not among them. +type InstallOpts struct { + Driver string `json:"driver"` + PoolSizeGiB int `json:"pool_size_gib"` + Subnet string `json:"subnet"` // the bridge's address, CIDR: 172.16.100.1/24 + DockerRules bool `json:"docker_rules"` + ARCMaxGiB int `json:"arc_max_gib"` // 0: leave it +} + +func sq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// InstallScript is the whole install as one shell script, run as root as one followed task. +// Every step checks before it acts, so running it again repairs rather than duplicates. +func (m *Manager) InstallScript(st Status, o InstallOpts) (string, Layout, error) { + var lay Layout + ok := false + for _, d := range st.Drivers { + if d == o.Driver { + ok = true + } + } + if !ok { + return "", lay, fmt.Errorf("driver %q isn't available on this machine", o.Driver) + } + if o.PoolSizeGiB < 5 && o.Driver != "dir" { + return "", lay, errors.New("pool: at least 5 GiB") + } + if uint64(o.PoolSizeGiB)<<30 > st.DiskFree && o.Driver != "dir" { + return "", lay, fmt.Errorf("pool: only %d GiB free where Ham Lab's data lives", st.DiskFree>>30) + } + gw, err := netip.ParsePrefix(o.Subnet) + if err != nil || !gw.Addr().Is4() || gw.Bits() < 16 || gw.Bits() > 29 || gw.Addr() == gw.Masked().Addr() { + return "", lay, errors.New("subnet: the bridge's own address with its prefix, like 172.16.100.1/24") + } + if o.ARCMaxGiB < 0 || (o.ARCMaxGiB > 0 && uint64(o.ARCMaxGiB)<<30 > st.MemTotal) { + return "", lay, errors.New("arc: more than this machine has") + } + dir := m.dir + lay = Layout{Driver: o.Driver, Bridge: BridgeName, Subnet: o.Subnet, DockerRules: o.DockerRules && st.Docker, ARCMaxGiB: o.ARCMaxGiB, Attempted: time.Now(), Version: m.version} + var b strings.Builder + w := func(f string, a ...any) { fmt.Fprintf(&b, f+"\n", a...) } + w("#!/bin/sh") + w("# Ham Lab: install Incus and lay out %s — generated %s", dir, time.Now().Format(time.RFC3339)) + w("set -eu") + w("export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DEBIAN_FRONTEND=noninteractive") + w("say() { printf '\\n== %%s\\n' \"$*\"; }") + w("DIR=%s", sq(dir)) + w("mkdir -p \"$DIR/images\" \"$DIR/isos\" \"$DIR/exports\"") + + w("say packages") + pkgs := []string{"incus"} + switch st.PM { + case "apt": + if o.Driver == "btrfs" && !st.BtrfsTools { + pkgs = append(pkgs, "btrfs-progs") + } + w("if ! command -v incus >/dev/null 2>&1 || [ -n %s ]; then apt-get -q update; apt-get -q -y -o Dpkg::Options::=--force-confold install %s; else echo 'incus already installed'; fi", + sq(strings.Join(pkgs[1:], " ")), strings.Join(pkgs, " ")) + case "dnf": + switch st.Arch { + case "x86_64": + pkgs = append(pkgs, "qemu-system-x86-core", "edk2-ovmf") + case "aarch64": + pkgs = append(pkgs, "qemu-system-aarch64-core", "edk2-aarch64") + } + if o.Driver == "btrfs" && !st.BtrfsTools { + pkgs = append(pkgs, "btrfs-progs") + } + w("dnf -y install %s", strings.Join(pkgs, " ")) + case "pacman": + pkgs = append(pkgs, "qemu-base", "edk2-ovmf") + if o.Driver == "btrfs" && !st.BtrfsTools { + pkgs = append(pkgs, "btrfs-progs") + } + w("pacman -S --noconfirm --needed %s", strings.Join(pkgs, " ")) + case "apk": + pkgs = append(pkgs, "incus-client", "incus-openrc") + if o.Driver == "btrfs" && !st.BtrfsTools { + pkgs = append(pkgs, "btrfs-progs") + } + w("apk add %s", strings.Join(pkgs, " ")) + default: + if st.State != "repair" { + return "", lay, errors.New("no package manager to install with") + } + w("echo 'incus is already installed; repairing the setup around it'") + } + w("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now incus.socket 2>/dev/null || true; systemctl enable --now incus.service 2>/dev/null || systemctl start incus 2>/dev/null || true; fi") + w("for i in 1 2 3 4 5 6 7 8 9 10; do incus query /1.0 >/dev/null 2>&1 && break; sleep 1; done") + w("incus query /1.0 >/dev/null || { echo 'incus is not answering'; exit 1; }") + + w("say storage") + switch o.Driver { + case "zfs": + lay.PoolSource, lay.PoolSizeGiB = ZPoolName, o.PoolSizeGiB + w("if ! zpool list %s >/dev/null 2>&1; then", ZPoolName) + w(" if [ ! -e \"$DIR/pool.img\" ]; then truncate -s %dG \"$DIR/pool.img\"; echo \"made $DIR/pool.img (%d GiB, sparse)\"; fi", o.PoolSizeGiB, o.PoolSizeGiB) + w(" zpool import -d \"$DIR\" %s 2>/dev/null || zpool create -m none -o ashift=12 -O compression=lz4 -O atime=off -O xattr=sa %s \"$DIR/pool.img\"", ZPoolName, ZPoolName) + w(" echo 'zfs pool %s on the backing file'", ZPoolName) + w("else echo 'zfs pool %s already there'; fi", ZPoolName) + w("if ! incus storage show %s >/dev/null 2>&1; then incus storage create %s zfs source=%s; fi", PoolName, PoolName, ZPoolName) + // Incus must not start before the pool is imported, or it will try to import it itself + // by scanning /dev, where a file-backed vdev is not. + w("mkdir -p /etc/systemd/system/incus.service.d && printf '[Unit]\\nAfter=zfs-import.target zfs-mount.service\\n' > /etc/systemd/system/incus.service.d/hamlab-zfs.conf && systemctl daemon-reload || true") + case "btrfs": + lay.PoolSource, lay.PoolSizeGiB = filepath.Join(dir, "pool"), o.PoolSizeGiB + w("if [ ! -e \"$DIR/pool.img\" ]; then truncate -s %dG \"$DIR/pool.img\"; mkfs.btrfs -q \"$DIR/pool.img\"; echo \"made $DIR/pool.img (%d GiB, sparse, btrfs)\"; fi", o.PoolSizeGiB, o.PoolSizeGiB) + w("mkdir -p \"$DIR/pool\"") + w("grep -qsF \"$DIR/pool.img\" /etc/fstab || printf '%%s %%s btrfs loop,nofail,noatime 0 0\\n' \"$DIR/pool.img\" \"$DIR/pool\" >> /etc/fstab") + w("systemctl daemon-reload 2>/dev/null || true") + w("mountpoint -q \"$DIR/pool\" || mount \"$DIR/pool\"") + w("if ! incus storage show %s >/dev/null 2>&1; then incus storage create %s btrfs source=\"$DIR/pool\"; fi", PoolName, PoolName) + default: + lay.PoolSource = filepath.Join(dir, "pool") + w("mkdir -p \"$DIR/pool\"") + w("if ! incus storage show %s >/dev/null 2>&1; then incus storage create %s dir source=\"$DIR/pool\"; fi", PoolName, PoolName) + } + + w("say network") + w("if ! incus network show %s >/dev/null 2>&1; then incus network create %s ipv4.address=%s ipv4.nat=true ipv6.address=none; else echo '%s already there'; fi", BridgeName, BridgeName, sq(o.Subnet), BridgeName) + + w("say profile") + w("incus profile device show default | grep -q '^root:' || incus profile device add default root disk path=/ pool=%s", PoolName) + w("incus profile device show default | grep -q '^eth0:' || incus profile device add default eth0 nic network=%s name=eth0", BridgeName) + + if lay.DockerRules { + w("say docker") + // Docker sets FORWARD to DROP; without these the bridge's traffic goes nowhere. A unit + // re-applies them after every boot, since Docker rebuilds its chains when it starts. + w("IPT=$(command -v iptables || echo /usr/sbin/iptables)") + w("cat > /etc/systemd/system/%s </dev/null || $IPT -I DOCKER-USER -i %s -j ACCEPT; $IPT -C DOCKER-USER -o %s -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || $IPT -I DOCKER-USER -o %s -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT'", BridgeName, BridgeName, BridgeName, BridgeName) + w("RemainAfterExit=yes") + w("") + w("[Install]") + w("WantedBy=multi-user.target docker.service") + w("UNIT") + w("systemctl daemon-reload && systemctl enable --now %s && echo 'DOCKER-USER rules in place'", NetUnit) + } + + if o.ARCMaxGiB > 0 && st.ZFSHost { + w("say arc") + w("echo %d > /sys/module/zfs/parameters/zfs_arc_max && echo 'options zfs zfs_arc_max=%d' > /etc/modprobe.d/hamlab-zfs-arc.conf && echo 'ARC capped at %d GiB'", uint64(o.ARCMaxGiB)<<30, uint64(o.ARCMaxGiB)<<30, o.ARCMaxGiB) + } + + w("say access") + if m.user != "" && m.user != "root" { + w("getent group %s >/dev/null && usermod -aG %s %s && echo '%s is in %s'", AdminGroup, AdminGroup, sq(m.user), m.user, AdminGroup) + w("if [ -f /etc/systemd/system/%s ]; then mkdir -p /etc/systemd/system/%s.d && printf '[Service]\\nSupplementaryGroups=%s\\n' > /etc/systemd/system/%s.d/virt.conf && systemctl daemon-reload; fi", HamlabUnit, HamlabUnit, AdminGroup, HamlabUnit) + } + w("chown %s \"$DIR\" \"$DIR/images\" \"$DIR/isos\" \"$DIR/exports\" 2>/dev/null || true", sq(m.user)) + w("say done") + w("incus version || true") + w("echo 'Ham Lab restarts in 3 seconds to pick up its new group; the page reconnects by itself.'") + w("if systemctl is-active --quiet %s; then systemd-run --quiet --on-active=3 --unit=hamlab-virt-restart systemctl restart %s; fi", HamlabUnit, HamlabUnit) + return b.String(), lay, nil +} diff --git a/internal/incus/subnet.go b/internal/incus/subnet.go new file mode 100644 index 0000000..2fe6bd0 --- /dev/null +++ b/internal/incus/subnet.go @@ -0,0 +1,130 @@ +package incus + +// The bridge subnet. Incus's own `auto` picks a random 10.x.y.0/24, and on a LAN that is +// itself 10.0.0.0/8 (this one) every such pick is on-link and collides. So Ham Lab chooses: +// the first /24 that nothing on the machine already routes, is not an address it holds, and +// is outside Docker's configured pools — tried in an order that also stays clear of Docker's +// *defaults* (172.17–172.31 and 192.168.0.0/16 carved into /20s), which a Docker that has no +// daemon.json will hand out later without asking anyone. + +import ( + "context" + "encoding/json" + "fmt" + "net/netip" + "os" + "os/exec" + "strings" + "time" +) + +// candidates in order of preference: 172.16.0.0/16 is the one RFC 1918 block Docker never +// takes by default; then the top of 192.168/16, which Docker reaches last; then the rest. +func candidates() []netip.Prefix { + var out []netip.Prefix + for i := 100; i <= 254; i++ { + out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i))) + } + for i := 254; i >= 200; i-- { + out = append(out, netip.MustParsePrefix(fmt.Sprintf("192.168.%d.0/24", i))) + } + for i := 1; i < 100; i++ { + out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i))) + } + return out +} + +func overlaps(a, b netip.Prefix) bool { + return a.Contains(b.Addr()) || b.Contains(a.Addr()) +} + +// PickSubnet returns the first candidate /24 clear of every prefix in taken. +func PickSubnet(taken []netip.Prefix) netip.Prefix { + for _, c := range candidates() { + clear := true + for _, t := range taken { + if overlaps(c, t) { + clear = false + break + } + } + if clear { + return c + } + } + return netip.MustParsePrefix("172.16.100.0/24") // every candidate taken: not a machine this will run on +} + +// Gateway is the bridge's own address in a subnet: its first host, as CIDR, the way +// Incus's ipv4.address wants it. +func Gateway(p netip.Prefix) string { + return fmt.Sprintf("%s/%d", p.Masked().Addr().Next(), p.Bits()) +} + +// TakenPrefixes reads what the machine already uses: its routes, its addresses, and the +// pools Docker is configured to carve networks from. +func TakenPrefixes(ctx context.Context) []netip.Prefix { + var out []netip.Prefix + add := func(s string) { + if p, err := netip.ParsePrefix(s); err == nil && p.Addr().Is4() { + out = append(out, p.Masked()) + } else if a, err := netip.ParseAddr(s); err == nil && a.Is4() { + out = append(out, netip.PrefixFrom(a, 32)) + } + } + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if b, err := exec.CommandContext(c, "ip", "-j", "-4", "route", "show").Output(); err == nil { + var routes []struct{ Dst string } + if json.Unmarshal(b, &routes) == nil { + for _, r := range routes { + if r.Dst != "" && r.Dst != "default" { + add(r.Dst) + } + } + } + } + if b, err := exec.CommandContext(c, "ip", "-j", "-4", "addr", "show").Output(); err == nil { + var links []struct { + AddrInfo []struct { + Local string `json:"local"` + Prefixlen int `json:"prefixlen"` + } `json:"addr_info"` + } + if json.Unmarshal(b, &links) == nil { + for _, l := range links { + for _, a := range l.AddrInfo { + add(fmt.Sprintf("%s/%d", a.Local, a.Prefixlen)) + } + } + } + } + for _, p := range dockerPools() { + add(p) + } + return out +} + +// dockerPools reads default-address-pools from Docker's daemon.json, and falls back to +// Docker's built-in defaults when the file names none. +func dockerPools() []string { + var cfg struct { + Pools []struct { + Base string `json:"base"` + } `json:"default-address-pools"` + } + if b, err := os.ReadFile("/etc/docker/daemon.json"); err == nil && json.Unmarshal(b, &cfg) == nil && len(cfg.Pools) > 0 { + var out []string + for _, p := range cfg.Pools { + out = append(out, p.Base) + } + return out + } + if _, err := exec.LookPath("docker"); err != nil { + return nil + } + return []string{"172.17.0.0/12", "192.168.0.0/16"} +} + +// String form used in the layout file and on the page. +func prefixString(p netip.Prefix) string { return strings.TrimSpace(p.Masked().String()) } diff --git a/internal/incus/subnet_test.go b/internal/incus/subnet_test.go new file mode 100644 index 0000000..1b62375 --- /dev/null +++ b/internal/incus/subnet_test.go @@ -0,0 +1,41 @@ +package incus + +import ( + "net/netip" + "testing" +) + +func TestPickSubnetAvoidsTaken(t *testing.T) { + taken := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), // the LAN, a /8 + netip.MustParsePrefix("172.16.100.0/24"), // first choice gone + netip.MustParsePrefix("172.16.101.0/24"), // and the second + netip.MustParsePrefix("172.28.0.0/14"), // Docker's configured pool + netip.MustParsePrefix("192.168.80.0/20"), // a Docker network + } + got := PickSubnet(taken) + if got.String() != "172.16.102.0/24" { + t.Fatalf("got %s", got) + } + if Gateway(got) != "172.16.102.1/24" { + t.Fatalf("gateway %s", Gateway(got)) + } +} + +func TestPickSubnetWholeBlockTaken(t *testing.T) { + // 172.16.0.0/12 covers every 172.16.x candidate; the picker must move to 192.168 + got := PickSubnet([]netip.Prefix{netip.MustParsePrefix("172.16.0.0/12")}) + if got.String() != "192.168.254.0/24" { + t.Fatalf("got %s", got) + } +} + +func TestOverlapsEitherWay(t *testing.T) { + a := netip.MustParsePrefix("172.16.5.0/24") + if !overlaps(a, netip.MustParsePrefix("172.16.0.0/12")) || !overlaps(netip.MustParsePrefix("172.16.0.0/12"), a) { + t.Fatal("a /24 inside a /12 overlaps both ways") + } + if overlaps(a, netip.MustParsePrefix("172.16.6.0/24")) { + t.Fatal("neighbours do not overlap") + } +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 82915d5..a60605b 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -15,6 +15,7 @@ import Hardware from './Hardware' import Network from './Network' import VPN from './VPN' import ZFS from './ZFS' +import Virtualization from './Virtualization' import KVM from './KVM' import Files from './Files' import Top from './Top' @@ -81,6 +82,7 @@ const GROUPS: Group[] = [ { id: 'usage', label: 'Usage', icon: '▧' }, { id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'docker', label: 'Docker', icon: DockerIcon }, + { id: 'virt', label: 'Virtualization', icon: '▣' }, { id: 'kvm', label: 'KVM', icon: '⌨' }, { id: 'hardware', label: 'Hardware', icon: '⬡' }, { id: 'updates', label: 'Updates', icon: '⇪' }, @@ -91,8 +93,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' -const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] +type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' +const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and @@ -329,6 +331,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'machines' && { switchMachine(n); setView('procs') }} />} {view === 'widgets' && } {view === 'docker' && } + {view === 'virt' && } {view === 'alerts' && m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'procs' && } {view === 'services' && } @@ -366,7 +369,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'virt' ? 'Virtualization' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/Virtualization.tsx b/web/src/Virtualization.tsx new file mode 100644 index 0000000..a6eab1f --- /dev/null +++ b/web/src/Virtualization.tsx @@ -0,0 +1,509 @@ +// Virtualization: LXC containers and KVM virtual machines through Incus, per machine. The +// page has four states — ready, repair, installable, unavailable — decided by what the +// selected machine can do, never by a rule about what should run where. Everything the +// tab makes lives under Ham Lab's own folder on that machine. +import { FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import { api, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api' +import TaskOutput from './TaskOutput' +import { useHashSel } from './hashsel' +import { useRefreshMs } from './prefs' +import { fmtBytes } from './fmt' +import { askText, askYes, Btn, CloseBtn, Loading, toast, useEscape } from './ui' + +type Tab = 'instances' | 'images' | 'library' +const GiB = 1 << 30 +const dot = (i: VirtInstance) => (i.status === 'Running' ? 'ok' : i.status === 'Stopped' ? '' : i.status === 'Error' ? 'bad' : 'warn') +const kind = (i: VirtInstance) => (i.type === 'virtual-machine' ? 'VM' : 'LXC') +const when = (iso?: string) => { if (!iso) return '—'; const d = new Date(iso); return isNaN(d.getTime()) || d.getFullYear() < 1980 ? '—' : d.toLocaleString() } +const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'failed') +const imgLabel = (im: VirtImage) => im.aliases[0] ?? im.fingerprint.slice(0, 12) + +export default function Virtualization() { + const [ov, setOv] = useState(null) + const [err, setErr] = useState('') + const [tab, setTab] = useState('instances') + const [sel, setSel] = useHashSel('virt') + const [making, setMaking] = useState(null) + const [task, setTask] = useState<{ id: string; title: string } | null>(null) + const [filter, setFilter] = useState('') + const rate = useRefreshMs() + const load = useCallback(async () => { try { setOv(await api.virt()); setErr('') } catch (e) { setErr(errMsg(e)) } }, []) + useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) + const startTask = (t: TaskInfo) => setTask({ id: t.id, title: t.title }) + + if (!ov) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Virtualization tab. Update it from Machines." : err}
: }
+ const st = ov.status + if (st.state !== 'ready') return + const instances = ov.instances ?? [] + const images = ov.images ?? [] + const cur = instances.find((i) => i.name === sel) + const running = instances.filter((i) => i.status === 'Running').length + const f = filter.trim().toLowerCase() + const shown = instances.filter((i) => !f || `${i.name} ${i.image} ${i.type} ${i.status}`.toLowerCase().includes(f)) + const libCount = ov.library.images.length + ov.library.isos.length + return ( +
+
+
+
+ {(['instances', 'images', 'library'] as Tab[]).map((t) => ( + + ))} +
+ {tab === 'instances' && ( +
+ + + {st.kvm ? 'containers and VMs' : 'containers only — no KVM on this machine'} +
+ )} + {tab === 'instances' && instances.length > 3 && setFilter(e.target.value)} placeholder="filter instances" />} + {ov.error &&
{ov.error}
} + {task && { load(); if (t.exit_code === 0 && !t.error) toast(`${task.title}: done`); else toast(`${task.title}: failed`, 'err') }} />} + {task &&
} +
+ {tab === 'instances' && ( +
+ {shown.map((i) => ( +
setSel(i.name)}> + +
{i.name} {kind(i)}
{i.image || '—'}{i.ipv4.length ? ` · ${i.ipv4.join(' ')}` : ''}
+
{i.status === 'Running' ? <>{fmtBytes(i.mem_usage)}
{i.cpu_pct.toFixed(0)}% : i.status.toLowerCase()}
+
+ ))} + {instances.length === 0 &&
Nothing here yet. “+ New instance” makes a container{st.kvm ? ' or a VM' : ''} from the catalogue.
} +
+ )} + {tab === 'images' && { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />} + {tab === 'library' && } +
+ {tab === 'instances' && (cur ? setSel('')} onChanged={load} onTask={startTask} /> : )} + {making && setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />} +
+ ) +} + +// ---- the three states before "ready" ---- + +function Gate({ st, onChanged }: { st: VirtStatus; onChanged: () => void }) { + const [open, setOpen] = useState(false) + const [task, setTask] = useState('') + const [waiting, setWaiting] = useState(false) + // the install ends by restarting Ham Lab, so poll until it answers again + useEffect(() => { + if (!waiting) return + const t = setInterval(async () => { try { const o = await api.virt(); if (o.status.state === 'ready') { setWaiting(false); onChanged() } } catch { /* restarting */ } }, 3000) + return () => clearInterval(t) + }, [waiting, onChanged]) + const headline = st.state === 'installable' + ? `Incus isn't installed on this machine. Ham Lab can install it with ${st.pm}: ${st.kvm ? 'containers and virtual machines' : 'containers only, since this kernel has no KVM'}.` + : st.reason + return ( +
+

Virtualization

+
+
{headline}
+
+
KVM{st.kvm ? 'yes — virtual machines possible' : 'no — containers only'}
+
Architecture{st.arch}
+ {st.pm &&
Packages{st.pm}
} +
Pool drivers{st.drivers.join(', ')}
+
Memory{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}
+
Disk{fmtBytes(st.disk_free)} free where Ham Lab's data lives
+
Docker{st.docker ? 'present — its FORWARD policy needs two rules, Install adds them' : 'not here'}
+
Data{st.dir}
+ {st.layout &&
Last attempt{when(st.layout.attempted)} · {st.layout.driver} pool · {st.layout.subnet}
} +
+ {(st.state === 'installable' || st.state === 'repair') && !task && ( +
setOpen(true)}>{st.state === 'repair' ? 'Finish setup' : 'Install Incus'}
+ )} + {task && { if (t.exit_code === 0 && !t.error) setWaiting(true); else { toast('install failed — the output says where', 'err'); setTask('') } }} />} + {waiting &&
Ham Lab is restarting to pick up its new group. The page reconnects by itself.
} +
+ {open && setOpen(false)} onStarted={(id) => { setOpen(false); setTask(id) }} />} +
+ ) +} + +function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: () => void; onStarted: (id: string) => void }) { + const [driver, setDriver] = useState(st.layout?.driver ?? st.drivers[0] ?? 'dir') + const [size, setSize] = useState(String(st.layout?.pool_size_gib || st.pool_size_gib || 20)) + const [subnet, setSubnet] = useState(st.layout?.subnet || st.subnet || '172.16.100.1/24') + const [docker, setDocker] = useState(st.docker) + const [arc, setArc] = useState(st.layout?.arc_max_gib ? String(st.layout.arc_max_gib) : '') + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + useEscape(onClose) + const submit = async (e: FormEvent) => { + e.preventDefault(); setErr(''); setBusy(true) + try { const t = await api.virtInstall({ driver, pool_size_gib: Number(size) || 0, subnet: subnet.trim(), docker_rules: docker, arc_max_gib: Number(arc) || 0 }); onStarted(t.id) } + catch (e) { setErr(errMsg(e)); setBusy(false) } + } + return ( +
+
e.stopPropagation()} onSubmit={submit}> +

{st.state === 'repair' ? 'Finish setting up Incus' : 'Install Incus'}

+
Everything goes under {st.dir}: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.
+ + {driver !== 'dir' && ( + + )} + + {st.docker && ( + + )} + {st.zfs_host && ( + + )} +
Ham Lab's account ({st.user}) joins the incus-admin group and Ham Lab restarts at the end.
+ {err &&
{err}
} +
+ + + +
+
+
+ ) +} + +// ---- the right-hand pane when nothing is selected: what this machine's Incus is ---- + +function Summary({ ov }: { ov: VirtOverview }) { + const st = ov.status + return ( +
+
Incus {st.version}{st.arch}{!st.kvm && no KVM}
+
+
Data{st.dir}
+
Memory{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}
+ {st.layout &&
Installed{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}
} + {!st.layout &&
Installedoutside Ham Lab — pools and networks below are as found
} +
+

Storage pools

+ + {(ov.pools ?? []).map((p) => )}
PoolDriverSourceUsedOfUsers
{p.name}{p.driver}{p.source}{p.total ? fmtBytes(p.used) : '—'}{p.total ? fmtBytes(p.total) : '—'}{p.used_by}
+

Networks

+ + {(ov.networks ?? []).filter((n) => n.managed).map((n) => )}
NetworkTypeAddressNATUsers
{n.name}{n.type}{n.address ?? '—'}{n.nat ? 'yes' : 'no'}{n.used_by}
+
Pick an instance on the left, or make one. Consoles open as tabs on the Terminal page.
+
+ ) +} + +// ---- one instance ---- + +function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: VirtStatus; onBack: () => void; onChanged: () => void; onTask: (t: TaskInfo) => void }) { + const [tab, setTab] = useState<'overview' | 'snapshots' | 'config'>('overview') + const running = i.status === 'Running' + const stopped = i.status === 'Stopped' + const act = (a: 'start' | 'stop' | 'restart' | 'kill') => async () => { try { await api.virtAction(i.name, a); toast(`${i.name}: ${a} ok`); onChanged() } catch (e) { toast(`${i.name}: ${errMsg(e)}`, 'err') } } + const term = (k: 'console' | 'exec') => async () => { + try { const t = await api.virtTerminal(i.name, k); toast(`${k === 'exec' ? 'shell' : 'console'} open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) } + catch (e) { toast(errMsg(e), 'err') } + } + const rename = async () => { const to = await askText(`Rename ${i.name} to`, i.name, 'Rename'); if (!to || to === i.name) return; try { await api.virtRename(i.name, to); toast('renamed'); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } } + const del = async () => { + const typed = await askText(`Delete ${i.name}${running ? ', which is running' : ''}? Its disk and ${i.snapshots.length} snapshot${i.snapshots.length === 1 ? '' : 's'} go with it. Type its name to confirm.`, '', 'Delete') + if (typed === null) return + if (typed !== i.name) { toast('name did not match', 'err'); return } + try { await api.virtDelete(i.name, running); toast(`${i.name} deleted`); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } + } + const publish = async () => { + const alias = await askText(`Publish ${i.name} as an image — an alias for it`, `${i.name}-image`, 'Publish') + if (!alias) return + try { onTask(await api.virtImagePublish(i.name, alias)) } catch (e) { toast(errMsg(e), 'err') } + } + const snap = async () => { + const name = await askText(`Snapshot ${i.name} — a name, or blank for the date`, '', 'Snapshot') + if (name === null) return + try { const r = await api.virtSnapshot(i.name, name); toast(`snapshot ${r.name} taken`); onChanged() } catch (e) { toast(errMsg(e), 'err') } + } + return ( +
+ +
{i.name}{kind(i)}{i.status.toLowerCase()}
+
+
Image{i.image || '—'}{i.arch ? · {i.arch} : ''}
+ {i.description &&
About{i.description}
} + {running &&
Using{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes ? ` · ${i.processes} processes` : ''}
} +
Limits{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}
+ {i.disk_total > 0 &&
Disk{fmtBytes(i.disk_usage)} of {fmtBytes(i.disk_total)}
} +
Network{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}
+
Created{when(i.created)}
+
+
+ {!running && Start} + {running && Shell} + Console + {running && Restart} + {running && Stop} + {running && Force stop} + Snapshot + {stopped && Rename} + {stopped && Publish} + Delete… +
+
+ + + +
+ {tab === 'overview' && } + {tab === 'snapshots' && } + {tab === 'config' && ( + <> +
{Object.entries(i.config).sort().map(([k, v]) => {k}{v})}
+

Devices

+
{Object.entries(i.devices).sort().map(([d, cfg]) => {d}{Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join(' ')})}
+
Profiles: {i.profiles.join(', ') || '—'}
+ + )} +
+ ) +} + +function Limits({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) { + const [cpu, setCpu] = useState(i.cpu ?? '') + const [mem, setMem] = useState(i.memory ?? '') + const [auto, setAuto] = useState(i.config['boot.autostart'] ?? '') + const [desc, setDesc] = useState(i.description ?? '') + useEffect(() => { setCpu(i.cpu ?? ''); setMem(i.memory ?? ''); setAuto(i.config['boot.autostart'] ?? ''); setDesc(i.description ?? '') }, [i.name, i.cpu, i.memory, i.description, i.config]) + const changed = cpu !== (i.cpu ?? '') || mem !== (i.memory ?? '') || auto !== (i.config['boot.autostart'] ?? '') || desc !== (i.description ?? '') + const save = async () => { try { await api.virtConfig(i.name, { 'limits.cpu': cpu, 'limits.memory': mem, 'boot.autostart': auto, description: desc }); toast('saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } } + return ( +
+
+ + +
+
+ + +
+
{i.type === 'container' ? 'Containers take new limits live.' : 'A VM takes CPU and memory changes live where its agent is running, otherwise on its next start.'} {fmtBytes(st.mem_avail)} is free on this machine right now.
+
Save
+
+ ) +} + +function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void }) { + const [sched, setSched] = useState(i.schedule ?? '') + const [exp, setExp] = useState(i.expiry ?? '') + useEffect(() => { setSched(i.schedule ?? ''); setExp(i.expiry ?? '') }, [i.name, i.schedule, i.expiry]) + const saveSched = async () => { try { await api.virtConfig(i.name, { 'snapshots.schedule': sched.trim(), 'snapshots.expiry': exp.trim() }); toast('schedule saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } } + const on = async (s: string, a: 'restore' | 'delete') => { + const ok = await askYes(a === 'restore' ? `Restore ${i.name} to snapshot ${s}? Everything since it is lost${i.status === 'Running' ? '; it is stopped first' : ''}.` : `Delete snapshot ${s} of ${i.name}?`, a === 'restore' ? 'Restore' : 'Delete') + if (!ok) return + try { await api.virtSnapshotAction(i.name, s, a); toast(`${a} ${s}: ok`); onChanged() } catch (e) { toast(errMsg(e), 'err') } + } + return ( + <> + {i.snapshots.length === 0 &&
No snapshots. “Snapshot” above takes one now; a schedule below keeps taking them.
} + {i.snapshots.length > 0 && ( + + {[...i.snapshots].sort((a, b) => b.created.localeCompare(a.created)).map((s) => ( + + + ))}
SnapshotTakenExpiresSize
{s.name}{when(s.created)}{s.expires ? when(s.expires) : 'never'}{s.size ? fmtBytes(s.size) : '—'} on(s.name, 'restore')}>Restore on(s.name, 'delete')}>Delete
+ )} +
+
+ + +
+
Incus takes and expires these itself, so they carry on whether or not this console is up.
+
Save schedule
+
+ + ) +} + +// ---- making one ---- + +function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) { + const [vm, setVm] = useState(!!preset.vm && st.kvm) + const [image, setImage] = useState(preset.image ?? '') + const [name, setName] = useState('') + const [cpu, setCpu] = useState('') + const [mem, setMem] = useState('') + const [disk, setDisk] = useState('') + const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge') + const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '') + const [cloud, setCloud] = useState('') + const [showCloud, setShowCloud] = useState(false) + const [desc, setDesc] = useState('') + const [start, setStart] = useState(true) + const [q, setQ] = useState('') + const [remote, setRemote] = useState(null) + const [remoteErr, setRemoteErr] = useState('') + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + const type = vm ? 'virtual-machine' : 'container' + useEscape(onClose) + useEffect(() => { setRemote(null); setRemoteErr(''); api.virtRemote(type).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type]) + const local = (ov.images ?? []).filter((im) => im.type === type) + const picks = useMemo(() => { + const f = q.trim().toLowerCase() + const match = (im: VirtImage) => !f || `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release}`.toLowerCase().includes(f) + const seen = new Set() + const out: { ref: string; label: string; sub: string; here: boolean }[] = [] + for (const im of local) { if (!match(im)) continue; const ref = imgLabel(im); if (seen.has(ref)) continue; seen.add(ref); out.push({ ref, label: ref, sub: `${im.description} · on this machine · ${fmtBytes(im.size)}`, here: true }) } + for (const im of remote ?? []) { + const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0] + if (!a || !match(im)) continue + const ref = `images:${a}` + if (seen.has(ref)) continue + seen.add(ref); out.push({ ref, label: a, sub: `${im.description}${im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · ${fmtBytes(im.size)}`, here: false }) + } + return out.slice(0, 60) + }, [q, local, remote]) + const submit = async (e: FormEvent) => { + e.preventDefault(); setErr(''); setBusy(true) + try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) } + catch (e) { setErr(errMsg(e)); setBusy(false) } + } + return ( +
+
e.stopPropagation()} onSubmit={submit}> +

New instance

+
+ + +
+ +
+ {remote === null && !remoteErr &&
Reading the catalogue…
} + {remoteErr &&
catalogue: {remoteErr}
} + {picks.map((p) => ( +
setImage(p.ref)}> +
{p.label}{p.here && here}
{p.sub}
+
+ ))} + {remote && picks.length === 0 &&
Nothing in the catalogue matches.
} +
+
+ + +
+
+ + + +
+
{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}
+ + + {showCloud &&