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
+17
View File
@@ -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))
+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)
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -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()) }
+41
View File
@@ -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")
}
}