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:
co-authored by
Claude Fable 5.1
parent
af53b45b67
commit
240129c83f
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user