Files
Mitchell-JohnsonandClaude Fable 5.1 ae72e45bf5 Virtualization: a disk-location picker on both create forms, locations by place, add or remove a location
Pools are shown by where their bytes land (plain files on this disk, a ZFS pool inside one
file, a dataset…) rather than by driver name, which read as the NAS pool. A location can be
added as a plain-files pool at any folder on the machine, and removed while nothing uses it.
The driver order at Install is as it was; the labels say what each one really is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 14:33:10 -07:00

1213 lines
43 KiB
Go

// 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 = "local" // plain files on the machine's own disk, in Ham Lab's folder
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 // <data>/virt
user string // the account the daemon runs as; joins incus-admin at install
version string
mu sync.Mutex
cpuPrev map[string]map[string]cpuMark // caller → instance → last reading, so two readers do not spoil each other's deltas
vga map[string]*vgaSession // instance → the console operation its browser channels share
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]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) {
return m.InstancesFor(ctx, "api")
}
// InstancesFor is Instances with its own CPU history per caller: the page and the sensors
// both read, at different rates, and a shared "previous reading" would give each the other's delta.
func (m *Manager) InstancesFor(ctx context.Context, caller string) ([]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()
prevs := m.cpuPrev[caller]
if prevs == nil {
prevs = map[string]cpuMark{}
m.cpuPrev[caller] = prevs
}
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 := prevs[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
}
prevs[r.Name] = cpuMark{ns: r.State.CPU.Usage, at: now}
seen[r.Name] = true
}
}
list = append(list, in)
}
for n := range prevs {
if !seen[n] {
delete(prevs, 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"`
Where string `json:"where"` // in words: what disk this is, so a pool is picked by place, not by driver
Used uint64 `json:"used"`
Total uint64 `json:"total"`
UsedBy int `json:"used_by"`
}
// poolWhere describes a pool by where its bytes land.
func (m *Manager) poolWhere(driver, source string) string {
inHam := strings.HasPrefix(source, m.dir+"/") || source == m.dir
switch driver {
case "dir":
if inHam {
return "this machine's own disk — plain files, each disk sized on its own"
}
return "plain files at " + source
case "zfs":
if strings.HasSuffix(source, ".img") || source == ZPoolName {
return "a ZFS pool inside one file on this machine's disk"
}
if strings.Contains(source, "/") {
return "ZFS dataset " + source
}
return "ZFS pool " + source
case "btrfs":
if inHam {
return "btrfs inside one file on this machine's disk"
}
return "btrfs at " + source
}
return driver + " at " + source
}
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"], Where: m.poolWhere(p.Driver, 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
}
// DefaultPool is where a new instance's disk goes unless the form says otherwise: the pool
// on the default profile's root device.
func (m *Manager) DefaultPool(ctx context.Context) string {
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := m.run(c, "query", "/1.0/profiles/default")
if err != nil {
return ""
}
var p struct {
Devices map[string]map[string]string `json:"devices"`
}
if json.Unmarshal(out, &p) != nil {
return ""
}
return p.Devices["root"]["pool"]
}
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"`
AttachedTo []string `json:"attached_to,omitempty"`
}
// Installed reports whether the incus binary is on this machine at all; the cheap gate the
// sensors use before asking it anything.
func Installed() bool { return have("incus") }
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])*$|^$`)
deviceRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,30}$`)
isoRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._ +-]{0,200}\.(?i:iso|img)$`)
)
func ValidName(s string) bool { return nameRe.MatchString(s) }
func ValidDevice(s string) bool { return deviceRe.MatchString(s) }
func ValidISOName(s string) bool { return isoRe.MatchString(s) && s == filepath.Base(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"`
Pool string `json:"pool"` // where the root disk goes; blank is the default profile's
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 sp.Pool != "" && !ValidName(sp.Pool):
return nil, errors.New("pool: a storage pool's name")
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)
}
// the root disk: the profile's, with the pool and size overridden where given
var root []string
if sp.Pool != "" {
root = append(root, "pool="+sp.Pool)
}
if sp.Disk != "" {
root = append(root, "size="+sp.Disk)
}
if len(root) > 0 {
args = append(args, "-d", "root,"+strings.Join(root, ","))
}
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 <<UNIT", NetUnit)
w("[Unit]")
w("Description=Ham Lab: let %s traffic through Docker's FORWARD policy", BridgeName)
w("After=docker.service incus.service network-online.target")
w("Wants=network-online.target")
w("")
w("[Service]")
w("Type=oneshot")
w("ExecStart=/bin/sh -c '$IPT -C DOCKER-USER -i %s -j ACCEPT 2>/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
}
// FromISOSpec is the New VM from ISO form: an empty VM with the ISO as its first boot device.
type FromISOSpec struct {
Name string `json:"name"`
ISO string `json:"iso"` // a file name in virt/isos
CPU string `json:"cpu"`
Memory string `json:"memory"`
Disk string `json:"disk"`
Pool string `json:"pool"`
Network string `json:"network"`
SecureBoot bool `json:"secureboot"`
Start bool `json:"start"`
}
// FromISOScript makes the VM in one shell script run through ShellCmd; the parameters carry
// the values so nothing is spliced into the text.
func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, err error) {
switch {
case !kvm:
return "", nil, errors.New("this machine has no KVM")
case !ValidName(sp.Name):
return "", nil, errors.New("name: letters, digits and dashes, starting with a letter, up to 63")
case !ValidISOName(sp.ISO):
return "", nil, errors.New("iso: pick one from the library")
case !ValidCPU(sp.CPU) || !ValidSize(sp.Memory) || !ValidSize(sp.Disk) || !ValidNetwork(sp.Network):
return "", nil, errors.New("cpu, memory, disk: a count and two sizes, like 2, 2GiB, 20GiB")
case sp.Pool != "" && !ValidName(sp.Pool):
return "", nil, errors.New("pool: a storage pool's name")
}
cpu, mem, disk := sp.CPU, sp.Memory, sp.Disk
if cpu == "" {
cpu = "2"
}
if mem == "" {
mem = "2GiB"
}
if disk == "" {
disk = "20GiB"
}
sb := "true"
if !sp.SecureBoot {
sb = "false"
}
start := "0"
if sp.Start {
start = "1"
}
// $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start $9 pool
script = `set -e
NET=""; [ -n "$6" ] && NET="-n $6"
ROOT="root,size=$5"; [ -n "$9" ] && ROOT="root,pool=$9,size=$5"
incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d "$ROOT" $NET
incus config device add "$1" iso0 disk source="$2" boot.priority=10
echo "made $1 with $2 as its first boot device"
[ "$8" = 1 ] && incus start "$1" && echo "started; open the VGA console to run the installer"
exit 0`
return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start, sp.Pool}, nil
}