Virtualization tab, phase 1: Incus per machine — detect, install, instances, consoles, snapshots, images

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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-09 14:09:56 -07:00
co-authored by Claude Fable 5.1
parent af53b45b67
commit 240129c83f
9 changed files with 2311 additions and 3 deletions
+490
View File
@@ -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)
}