Cut virtualization from the app: out of scope
Ham Lab is meant to be lightweight; VMs, passthrough and their consoles are too much. Removes the Virtualization tab, the incus package, the /api/virt routes, the instance.* sensors and their alert default, the virt-export command, the VGA console and the vendored spice-html5, and VIRTUALIZATION.md. A saved instance-down rule is dropped on load. Everything removed is kept, as it was, on the virtualization branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a2674bea6a
commit
d70ddb6d67
+6
-19
@@ -71,9 +71,6 @@ type alertSaved struct {
|
||||
Channels []AlertChannel `json:"channels"`
|
||||
Events []AlertEvent `json:"events"`
|
||||
Seeded bool `json:"seeded"`
|
||||
// SawInstanceRule marks that the instance-down default has been offered once; set so that
|
||||
// removing it stays removed.
|
||||
SawInstanceRule bool `json:"saw_instance_rule,omitempty"`
|
||||
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
||||
// carries on where it was and doesn't announce the same alerts all over again.
|
||||
Firing map[string]alertKept `json:"firing,omitempty"`
|
||||
@@ -105,7 +102,6 @@ var defaultRules = []AlertRule{
|
||||
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
|
||||
{ID: "instance-down", Name: "VM or container down", Sensor: "instance.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
|
||||
@@ -126,23 +122,14 @@ func newAlertEngine(s *Server) *alertEngine {
|
||||
for key, k := range e.saved.Firing {
|
||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||
}
|
||||
// a default added after an install was seeded joins its rules once; removing it afterwards sticks
|
||||
if e.saved.Seeded && !e.saved.SawInstanceRule {
|
||||
has := false
|
||||
for _, r := range e.saved.Rules {
|
||||
if r.ID == "instance-down" {
|
||||
has = true
|
||||
}
|
||||
// the Virtualization tab's default went with the tab (2026-10-15): nothing publishes instance.* now
|
||||
kept := e.saved.Rules[:0]
|
||||
for _, r := range e.saved.Rules {
|
||||
if r.ID != "instance-down" {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
if !has {
|
||||
for _, d := range defaultRules {
|
||||
if d.ID == "instance-down" {
|
||||
e.saved.Rules = append(e.saved.Rules, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.saved.SawInstanceRule = true
|
||||
}
|
||||
e.saved.Rules = kept
|
||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/hardware"
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/network"
|
||||
@@ -77,7 +76,6 @@ type Server struct {
|
||||
update updateState
|
||||
events eventLog
|
||||
kvms kvmProxies
|
||||
virt *incus.Manager
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
thumbs *thumbSvc
|
||||
@@ -113,7 +111,6 @@ 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.
|
||||
@@ -237,37 +234,6 @@ 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/from-iso", s.auth(s.virtFromISO))
|
||||
m.HandleFunc("GET /api/virt/instances/{name}/vga", s.auth(s.virtVGA))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/iso", s.auth(s.virtISOAttach))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/iso/detach", s.auth(s.virtISODetach))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/mounts", s.auth(s.virtMountAdd))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/mounts/remove", s.auth(s.virtMountRemove))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
|
||||
m.HandleFunc("POST /api/virt/isos/fetch", s.auth(s.virtISOFetch))
|
||||
m.HandleFunc("POST /api/virt/isos/import", s.auth(s.virtISOImport))
|
||||
m.HandleFunc("POST /api/virt/isos/delete", s.auth(s.virtISODelete))
|
||||
m.HandleFunc("POST /api/virt/isos/rename", s.auth(s.virtISORename))
|
||||
m.HandleFunc("GET /api/virt/exports", s.auth(s.virtExports))
|
||||
m.HandleFunc("PUT /api/virt/exports", s.auth(s.virtExportsPut))
|
||||
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
|
||||
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
|
||||
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
|
||||
m.HandleFunc("POST /api/virt/pools", s.auth(s.virtPoolCreate))
|
||||
m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete))
|
||||
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))
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/sensors"
|
||||
"hamlab/internal/storage"
|
||||
@@ -55,7 +54,6 @@ func newStatusSensors(s *Server) *statusSensors {
|
||||
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
|
||||
go p.loop("services", servicesEvery, p.services)
|
||||
go p.loop("containers", containersEvery, p.containers)
|
||||
go p.loop("instances", containersEvery, p.instances)
|
||||
go p.loop("updates", updatesEvery, p.updates)
|
||||
go p.loop("zfs", zfsEvery, p.zfs)
|
||||
go p.loop("smart", smartEvery, p.smart)
|
||||
@@ -322,43 +320,6 @@ type cpuMark struct {
|
||||
t int64
|
||||
}
|
||||
|
||||
// instances are Incus's containers and VMs. "Down" means it should be up and is not: an
|
||||
// instance marked to start with the machine that is stopped, or one in Error — a stopped
|
||||
// instance with no such mark was stopped on purpose, as with Docker containers.
|
||||
func (p *statusSensors) instances(ctx context.Context, t int64) []sensors.Sensor {
|
||||
if !incus.Installed() {
|
||||
return nil
|
||||
}
|
||||
list, err := p.s.virt.InstancesFor(ctx, "sensors")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var res []sensors.Sensor
|
||||
var running, total float64
|
||||
for _, in := range list {
|
||||
total++
|
||||
id := "instance." + sensors.Seg(in.Name)
|
||||
l := map[string]string{"instance": in.Name, "type": in.Type, "image": in.Image}
|
||||
up := in.Status == "Running"
|
||||
if up {
|
||||
running++
|
||||
}
|
||||
down := in.Status == "Error" || (!up && in.Config["boot.autostart"] == "true")
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".running", Name: in.Name, Kind: "state", Value: up, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".down", Name: in.Name + " down", Kind: "state", Value: down, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".state", Name: in.Name + " state", Kind: "state", Value: strings.ToLower(in.Status), T: t, Labels: l})
|
||||
if up {
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".cpu", Name: in.Name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(in.CPUPct*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l},
|
||||
sensors.Sensor{ID: id + ".mem", Name: in.Name + " memory", Kind: "bytes", Unit: "B", Value: float64(in.MemUsage), T: t, Labels: l})
|
||||
}
|
||||
}
|
||||
return append(res,
|
||||
sensors.Sensor{ID: "instances.total", Name: "Instances", Kind: "count", Value: total, T: t},
|
||||
sensors.Sensor{ID: "instances.running", Name: "Running instances", Kind: "count", Value: running, T: t})
|
||||
}
|
||||
|
||||
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||
st := p.s.updates.Status(ctx)
|
||||
if st.Manager == "" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
||||
package incus
|
||||
|
||||
// Nightly exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a
|
||||
// cron line like the ZFS schedules — `hamlab virt-export` in /etc/cron.d/hamlab-virt — so the
|
||||
// backup runs whether or not the console is up. The settings and the last run's record are
|
||||
// files beside the data, not state.json, because the cron job is a separate process.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hamlab/internal/store"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const CronFile = "/etc/cron.d/hamlab-virt"
|
||||
|
||||
type ExportConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Hour int `json:"hour"`
|
||||
Minute int `json:"minute"`
|
||||
Keep int `json:"keep"` // newest exports kept per instance
|
||||
InstanceOnly bool `json:"instance_only"` // leave the snapshots out of the tarball
|
||||
Instances []string `json:"instances,omitempty"` // empty: every instance
|
||||
Updated time.Time `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultExportConfig() ExportConfig {
|
||||
return ExportConfig{Hour: 2, Minute: 15, Keep: 3, InstanceOnly: true}
|
||||
}
|
||||
|
||||
func (c ExportConfig) Validate() error {
|
||||
if c.Hour < 0 || c.Hour > 23 || c.Minute < 0 || c.Minute > 59 {
|
||||
return errors.New("time: hour 0–23, minute 0–59")
|
||||
}
|
||||
if c.Keep < 1 || c.Keep > 100 {
|
||||
return errors.New("keep: between 1 and 100")
|
||||
}
|
||||
for _, n := range c.Instances {
|
||||
if !ValidName(n) {
|
||||
return errors.New("instance: " + n)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ExportConfig) CronExpr() string { return fmt.Sprintf("%d %d * * *", c.Minute, c.Hour) }
|
||||
|
||||
// NextRun is the next time the line fires, for the page.
|
||||
func (c ExportConfig) NextRun(now time.Time) time.Time {
|
||||
n := time.Date(now.Year(), now.Month(), now.Day(), c.Hour, c.Minute, 0, 0, now.Location())
|
||||
if !n.After(now) {
|
||||
n = n.Add(24 * time.Hour)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CronText is the whole file; rewritten on every change, never edited.
|
||||
func (c ExportConfig) CronText(user, exe, config string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Ham Lab instance exports — written by the Virtualization tab, rewritten whole on every change.\n")
|
||||
b.WriteString("# Edit it there, not here.\n")
|
||||
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||
fmt.Fprintf(&b, "# every instance to virt/exports, keep %d each\n", c.Keep)
|
||||
fmt.Fprintf(&b, "%s %s %s -config %s virt-export\n", c.CronExpr(), user, exe, config)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Manager) exportConfigPath() string { return filepath.Join(m.dir, "exports.json") }
|
||||
func (m *Manager) exportRunPath() string { return filepath.Join(m.dir, "export-runs.json") }
|
||||
|
||||
func (m *Manager) ExportConfig() ExportConfig {
|
||||
c := DefaultExportConfig()
|
||||
if b, err := os.ReadFile(m.exportConfigPath()); err == nil {
|
||||
_ = json.Unmarshal(b, &c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (m *Manager) SaveExportConfig(c ExportConfig) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Updated = time.Now()
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return store.WriteFileAtomic(m.exportConfigPath(), b, 0o644)
|
||||
}
|
||||
|
||||
type ExportRun struct {
|
||||
At time.Time `json:"at"`
|
||||
Exported []string `json:"exported"`
|
||||
Pruned []string `json:"pruned"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
}
|
||||
|
||||
func (m *Manager) LastExportRun() *ExportRun {
|
||||
b, err := os.ReadFile(m.exportRunPath())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var r ExportRun
|
||||
if json.Unmarshal(b, &r) != nil {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
// ExportFile is one tarball in the exports tree.
|
||||
type ExportFile struct {
|
||||
Instance string `json:"instance"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
func (m *Manager) ExportFiles() []ExportFile {
|
||||
out := []ExportFile{}
|
||||
dirs, _ := os.ReadDir(m.ExportsDir())
|
||||
for _, d := range dirs {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
files, _ := os.ReadDir(filepath.Join(m.ExportsDir(), d.Name()))
|
||||
for _, f := range files {
|
||||
if f.IsDir() || strings.HasSuffix(f.Name(), ".part") {
|
||||
continue
|
||||
}
|
||||
if info, err := f.Info(); err == nil {
|
||||
out = append(out, ExportFile{Instance: d.Name(), Name: f.Name(), Size: info.Size(), Modified: info.ModTime()})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Instance != out[j].Instance {
|
||||
return out[i].Instance < out[j].Instance
|
||||
}
|
||||
return out[i].Modified.After(out[j].Modified)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// RunExports exports the named instances (or every one when names is empty) and prunes each
|
||||
// instance's folder to the configured count. Output goes to w line by line for a task to show.
|
||||
func (m *Manager) RunExports(ctx context.Context, names []string, w func(string)) ExportRun {
|
||||
start := time.Now()
|
||||
c := m.ExportConfig()
|
||||
run := ExportRun{At: start, Exported: []string{}, Pruned: []string{}}
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
run.Errors = append(run.Errors, err.Error())
|
||||
return run
|
||||
}
|
||||
all, err := m.Instances(ctx)
|
||||
if err != nil {
|
||||
run.Errors = append(run.Errors, "listing instances: "+err.Error())
|
||||
return run
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, n := range names {
|
||||
want[n] = true
|
||||
}
|
||||
if len(names) == 0 && len(c.Instances) > 0 {
|
||||
for _, n := range c.Instances {
|
||||
want[n] = true
|
||||
}
|
||||
}
|
||||
for _, in := range all {
|
||||
if len(want) > 0 && !want[in.Name] {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(m.ExportsDir(), in.Name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
run.Errors = append(run.Errors, in.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
stamp := start.Format("2006-01-02_15.04")
|
||||
final := filepath.Join(dir, fmt.Sprintf("%s-%s.tar.gz", in.Name, stamp))
|
||||
part := final + ".part"
|
||||
args := []string{"export", in.Name, part}
|
||||
if c.InstanceOnly {
|
||||
args = append(args, "--instance-only")
|
||||
}
|
||||
w(fmt.Sprintf("exporting %s …", in.Name))
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Hour)
|
||||
out, err := exec.CommandContext(cctx, "incus", args...).CombinedOutput()
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = os.Remove(part)
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
run.Errors = append(run.Errors, in.Name+": "+msg)
|
||||
w(" failed: " + msg)
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(part, final); err != nil {
|
||||
run.Errors = append(run.Errors, in.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(final); err == nil {
|
||||
w(fmt.Sprintf(" %s (%.1f MiB)", filepath.Base(final), float64(st.Size())/(1<<20)))
|
||||
}
|
||||
run.Exported = append(run.Exported, filepath.Base(final))
|
||||
// prune: newest first, keep c.Keep
|
||||
var files []ExportFile
|
||||
for _, f := range m.ExportFiles() {
|
||||
if f.Instance == in.Name {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
for i := c.Keep; i < len(files); i++ {
|
||||
if err := os.Remove(filepath.Join(dir, files[i].Name)); err == nil {
|
||||
run.Pruned = append(run.Pruned, files[i].Name)
|
||||
w(" pruned " + files[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
run.Seconds = time.Since(start).Seconds()
|
||||
b, _ := json.MarshalIndent(run, "", " ")
|
||||
_ = store.WriteFileAtomic(m.exportRunPath(), b, 0o644)
|
||||
return run
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
package incus
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The install script is generated, so a quoting slip would only show on a live machine.
|
||||
// Build it for the shapes of machine we have and let sh check the syntax.
|
||||
func TestInstallScriptShapes(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("no sh")
|
||||
}
|
||||
m := New("/home/core/apps/hamlab", "core", "test")
|
||||
cases := []struct {
|
||||
name string
|
||||
st Status
|
||||
o InstallOpts
|
||||
want []string
|
||||
}{
|
||||
{"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30},
|
||||
InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16},
|
||||
[]string{"apt-get", "zpool create", "incus storage create local zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}},
|
||||
{"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30},
|
||||
InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true},
|
||||
[]string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create local btrfs source="}},
|
||||
{"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30},
|
||||
InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"},
|
||||
[]string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create local dir source="}},
|
||||
{"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30},
|
||||
InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"},
|
||||
[]string{"already installed"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
script, lay, err := m.InstallScript(c.st, c.o)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(script, w) {
|
||||
t.Errorf("%s: script lacks %q", c.name, w)
|
||||
}
|
||||
}
|
||||
if lay.Driver != c.o.Driver || lay.Subnet != c.o.Subnet {
|
||||
t.Errorf("%s: layout %+v", c.name, lay)
|
||||
}
|
||||
cmd := exec.Command("sh", "-n")
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: sh -n: %v\n%s", c.name, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallScriptRefusals(t *testing.T) {
|
||||
m := New("/x", "core", "test")
|
||||
st := Status{State: "installable", PM: "apt", Drivers: []string{"btrfs", "dir"}, DiskFree: 20 << 30, MemTotal: 8 << 30}
|
||||
for _, o := range []InstallOpts{
|
||||
{Driver: "zfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24"}, // not offered here
|
||||
{Driver: "btrfs", PoolSizeGiB: 50, Subnet: "172.16.100.1/24"}, // more than is free
|
||||
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.0/24"}, // the network address, not the bridge's
|
||||
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "10.0.0.1/8"}, // too wide
|
||||
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24", ARCMaxGiB: 64}, // more RAM than there is
|
||||
} {
|
||||
if _, _, err := m.InstallScript(st, o); err == nil {
|
||||
t.Errorf("accepted %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArgs(t *testing.T) {
|
||||
args, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true, CPU: "2", Memory: "2GiB", Disk: "20GiB", Network: "incusbr0", Start: true}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0"
|
||||
if a, err := CreateArgs(CreateSpec{Name: "x", Image: "images:alpine/3.22", Pool: "local"}, true); err != nil || strings.Join(a, " ") != "init images:alpine/3.22 x -d root,pool=local" {
|
||||
t.Fatalf("pool override: %v %v", a, err)
|
||||
}
|
||||
if got := strings.Join(args, " "); got != want {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if _, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true}, false); err == nil {
|
||||
t.Fatal("a VM without KVM must be refused")
|
||||
}
|
||||
if _, err := CreateArgs(CreateSpec{Name: "1bad", Image: "images:debian/13"}, true); err == nil {
|
||||
t.Fatal("a name starting with a digit must be refused")
|
||||
}
|
||||
if _, err := CreateArgs(CreateSpec{Name: "ok", Image: "images:debian/13", Memory: "2 gigs"}, true); err == nil {
|
||||
t.Fatal("a malformed size must be refused")
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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"
|
||||
"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"}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package incus
|
||||
|
||||
// The graphical console. Incus exposes a VM's SPICE display as a websocket: POST to the
|
||||
// instance's console endpoint with type vga and it answers an operation with two secrets,
|
||||
// one for the display stream and one for a control channel. Ham Lab dials both over Incus's
|
||||
// unix socket and hands the display stream to the browser, where spice-html5 draws it.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// SocketPath is where Incus's API listens; the daemon's account reaches it by being in
|
||||
// incus-admin, which Install arranges.
|
||||
func SocketPath() string {
|
||||
if p := os.Getenv("INCUS_SOCKET"); p != "" {
|
||||
return p
|
||||
}
|
||||
for _, p := range []string{"/var/lib/incus/unix.socket", "/run/incus/unix.socket"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return "/var/lib/incus/unix.socket"
|
||||
}
|
||||
|
||||
func (m *Manager) apiClient() *http.Client {
|
||||
sock := SocketPath()
|
||||
return &http.Client{Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", sock)
|
||||
},
|
||||
ForceAttemptHTTP2: false,
|
||||
}}
|
||||
}
|
||||
|
||||
// A SPICE client opens one connection per channel — main, display, inputs, cursor, playback
|
||||
// — and spice-html5 does the same with websockets, all to one URL. Incus allows exactly one
|
||||
// console operation per VM at a time, but takes any number of data websockets on it. So a VGA
|
||||
// session is one operation, held for as long as any channel is open, and every browser
|
||||
// websocket becomes one more data websocket on it.
|
||||
type vgaSession struct {
|
||||
op string
|
||||
secret string
|
||||
control *websocket.Conn
|
||||
cancel context.CancelFunc
|
||||
refs int
|
||||
}
|
||||
|
||||
// VGAChannel returns a fresh data websocket on the VM's console operation, opening the
|
||||
// operation first when none is held. release must be called when the channel is done.
|
||||
func (m *Manager) VGAChannel(ctx context.Context, name string) (data *websocket.Conn, release func(), err error) {
|
||||
if !ValidName(name) {
|
||||
return nil, nil, errors.New("bad name")
|
||||
}
|
||||
client := m.apiClient()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.vga == nil {
|
||||
m.vga = map[string]*vgaSession{}
|
||||
}
|
||||
dial := func(s *vgaSession) (*websocket.Conn, error) {
|
||||
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, s.secret)
|
||||
ws, _, err := websocket.Dial(c, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
||||
return ws, err
|
||||
}
|
||||
s := m.vga[name]
|
||||
if s != nil {
|
||||
if ws, err := dial(s); err == nil {
|
||||
s.refs++
|
||||
ws.SetReadLimit(64 << 20)
|
||||
return ws, m.releaser(name, s), nil
|
||||
}
|
||||
// the operation is gone (the VM restarted, or Incus did): start over
|
||||
s.cancel()
|
||||
delete(m.vga, name)
|
||||
}
|
||||
s, err = m.openVGA(ctx, client, name, false)
|
||||
if err != nil && strings.Contains(err.Error(), "already connected") {
|
||||
// a session a previous Ham Lab process held and never let go of; take it over
|
||||
s, err = m.openVGA(ctx, client, name, true)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ws, err := dial(s)
|
||||
if err != nil {
|
||||
s.cancel()
|
||||
return nil, nil, err
|
||||
}
|
||||
ws.SetReadLimit(64 << 20)
|
||||
s.refs = 1
|
||||
m.vga[name] = s
|
||||
return ws, m.releaser(name, s), nil
|
||||
}
|
||||
|
||||
func (m *Manager) releaser(name string, s *vgaSession) func() {
|
||||
return func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s.refs--
|
||||
if s.refs <= 0 && m.vga[name] == s {
|
||||
s.cancel()
|
||||
delete(m.vga, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// openVGA asks Incus for the console operation and holds its control websocket, which is
|
||||
// what keeps the operation alive.
|
||||
func (m *Manager) openVGA(ctx context.Context, client *http.Client, name string, force bool) (*vgaSession, error) {
|
||||
body, _ := json.Marshal(map[string]any{"type": "vga", "width": 0, "height": 0, "force": force})
|
||||
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(c, "POST", "http://incus/1.0/instances/"+name+"/console", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if isPermission(err) {
|
||||
return nil, errors.New("this account can't reach Incus's socket; Finish setup on the Virtualization tab puts Ham Lab in incus-admin")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var op struct {
|
||||
Error string `json:"error"`
|
||||
Metadata struct {
|
||||
ID string `json:"id"`
|
||||
Metadata struct {
|
||||
FDs map[string]string `json:"fds"`
|
||||
} `json:"metadata"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&op); err != nil {
|
||||
return nil, errors.New("unreadable answer from incus")
|
||||
}
|
||||
if op.Error != "" {
|
||||
return nil, errors.New(op.Error)
|
||||
}
|
||||
secret, controlSecret := op.Metadata.Metadata.FDs["0"], op.Metadata.Metadata.FDs["control"]
|
||||
if op.Metadata.ID == "" || secret == "" {
|
||||
return nil, errors.New("incus gave no console; is the VM running?")
|
||||
}
|
||||
s := &vgaSession{op: op.Metadata.ID, secret: secret}
|
||||
// the control websocket lives outside any one request: it is the session
|
||||
sctx, scancel := context.WithCancel(context.Background())
|
||||
s.cancel = scancel
|
||||
if controlSecret != "" {
|
||||
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, controlSecret)
|
||||
control, _, err := websocket.Dial(sctx, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
scancel()
|
||||
return nil, err
|
||||
}
|
||||
s.control = control
|
||||
go func() { // drain it; when it ends, so has the operation
|
||||
for {
|
||||
if _, _, err := control.Read(sctx); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
control.Close(websocket.StatusNormalClosure, "")
|
||||
}()
|
||||
go func() { <-sctx.Done(); control.Close(websocket.StatusNormalClosure, "") }()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func isPermission(err error) bool {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return os.IsPermission(pe)
|
||||
}
|
||||
var oe *net.OpError
|
||||
if errors.As(err, &oe) {
|
||||
return os.IsPermission(oe.Err) || isPermission(oe.Err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user