spice-html5 opens a websocket per channel (main, display, inputs, cursor, playback), and each one was making Ham Lab open a new console operation, which Incus refuses as already connected. Now the first channel opens the operation and holds its control websocket; every further channel is one more data websocket on the same operation, which Incus allows; the operation is let go when the last channel closes. A stale operation from a previous Ham Lab process is taken over with force. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1153 lines
41 KiB
Go
1153 lines
41 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 = "default"
|
|
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"`
|
|
Used uint64 `json:"used"`
|
|
Total uint64 `json:"total"`
|
|
UsedBy int `json:"used_by"`
|
|
}
|
|
|
|
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"], 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
|
|
}
|
|
|
|
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"`
|
|
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 !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)
|
|
}
|
|
if sp.Disk != "" {
|
|
args = append(args, "-d", "root,size="+sp.Disk)
|
|
}
|
|
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"`
|
|
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")
|
|
}
|
|
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
|
|
script = `set -e
|
|
NET=""; [ -n "$6" ] && NET="-n $6"
|
|
incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d root,size="$5" $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}, nil
|
|
}
|