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 desc, hasDesc := body["description"] delete(body, "description") if hasDesc && (len(desc) > 200 || strings.ContainsAny(desc, "\n\r")) { jsonErr(w, 400, "bad value for description") return } 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 "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 } if hasDesc { // the description is a field of the instance, not a config key, so it goes in through the API patch, _ := json.Marshal(map[string]string{"description": strings.TrimSpace(desc)}) if out, err := s.virt.Cmd(ctx, "query", "-X", "PATCH", "-d", string(patch), "/1.0/instances/"+name).CombinedOutput(); err != nil { jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) return } } 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) }