diff --git a/PLAN.md b/PLAN.md index cdfe3f7..8aab125 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,6 +22,7 @@ stub tab or a roadmap item. | Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. | | Capability, not policy | **A tab offers what the selected machine can do, detected live — never what a rule says should run there.** Where the software is missing but installable, Ham Lab installs it; where it cannot be installed or the hardware lacks it, the tab says so in a sentence and offers what does work. Every action lands on the machine in the switcher. | Mitchell, 2026-10-09, after the Virtualization plan's first draft excluded the Mac mini and `hamtv` on the strength of `hamlet/CLAUDE.md`'s deployment rules: *"I want the option to do it anywhere I want, unless the hardware / software literally doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on any Linux system."* Those rules bind the agent's unasked deployments, not the product. | | Ham Lab's data lives in Ham Lab's folder | **Everything a tab manages sits under the machine's data directory (`config.DataDir()`), never at a convenient path elsewhere.** Existing material is imported by copying. The engine's runtime state (`/var/lib/docker`, `/var/lib/incus`) is the engine's; Ham Lab keeps what would rebuild it. | Mitchell, 2026-10-09, when the Virtualization plan pointed its ISO library at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it manages its own data."* The same reason `core`'s compose files moved under `apps/hamlab/stacks/` on 2026-10-05. `grid`'s and the Mac's stacks at `~/stacks//` were the standing exception; since 2026-10-10 the stacks code offers `/stacks` as the default home everywhere and such folders only as discovered extras. | +| No virtualization | **Ham Lab does not manage VMs or system containers** — no Incus, no QEMU, no passthrough, no VM consoles. It is a lightweight console; Docker is its one container engine. | Mitchell, 2026-10-15: *"Virtualization is out of scope for HamLab. This is supposed to be a lightweight application, and VMs and pass through and stuff is too much."* The tab built 2026-10-09 (Incus, phases 1–2, deployed as v0.1.0-196) was cut from `main` the same day and kept on the **`virtualization` branch** (its last commit is `main` as of `a2674be`, with `VIRTUALIZATION.md` and the vendored spice-html5). Don't rebuild it on `main`. | ## Layout @@ -676,7 +677,7 @@ something long in one, switch away and back, refresh — it's all still there. `render`, so there the daemon's own previews are CPU and the tab says so, naming the GPU and the owner, with an **Allow GPU access** button (confirms, then a task writes `hamlab.service.d/gpu.conf` adding `render`/`video` as supplementary groups and - restarts Ham Lab three seconds later, the shape the Incus install uses for + restarts Ham Lab three seconds later, the shape the Incus install used for `incus-admin`). Not pressed anywhere yet. Where ffmpeg is missing (the Mac mini, `gate`) the note offers **Install ffmpeg with dnf/apk** through the Updates machinery (`ffmpeg-free` on Fedora, `ffmpeg` elsewhere); the status re-probes when the task ends. @@ -986,14 +987,7 @@ what it has moved. §3 has the audit it came from. Deployed as v0.1.0-191 and checked on all five machines through the hub. -- **A Virtualization tab** — asked for 2026-09-23; **scoped, built and deployed 2026-10-09 — see - `VIRTUALIZATION.md`** for what shipped (phases 1 and 2) and what is left. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is - running, installable, or unavailable, and whether KVM is there, and acts on the machine in - the switcher — never a default host. Containers and VMs in one list, console/shell through - the terminal machinery, snapshots, exports, and the old Proxmox estate on `tank` as a later - import. That file has the probe of every host, the per-machine layout Ham Lab computes - (pool driver, a bridge subnet that avoids the LAN's /8 and Docker's pools, the Docker - `FORWARD` fix) and the phased build. +- ~~**A Virtualization tab**~~ — built 2026-10-09, **cut 2026-10-15**: out of scope (see Decisions). The code, its plan and its tests are on the `virtualization` branch. ## Open questions diff --git a/PORTABILITY.md b/PORTABILITY.md index 21ef249..2277323 100644 --- a/PORTABILITY.md +++ b/PORTABILITY.md @@ -5,6 +5,9 @@ every place the code, defaults, docs or UI assume Mitchell's hosts, network, har distro, init system, orchestrator history or posture. **Nothing here is fixed yet** — this is the list to work from. Each item: where, what it assumes, what the general form is. +Virtualization was cut from the app 2026-10-15 (kept on the `virtualization` branch); its +items — 1.8, 4.2, 5.1, 5.2, 6.14 and the Incus halves of 5.3, 5.7, 6.15, 10.3 — went with it. + Severity key: **A** breaks or misbehaves on an ordinary host · **B** works but is wrong by default or phones home to this lab · **C** cosmetic (placeholder text, comments, docs). @@ -21,7 +24,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments | 1.5 | **C** | `examples/external-demo/index.html:8` `base: 'http://10.0.0.234:8100'` | Demo config names the hub's LAN IP. | `http://:8100`. | | 1.6 | **C** | `SENSORS.md:149` Prometheus target `10.0.0.234:8100`; `:187` `&machine=grid`; `:196-208` Glance examples at `hamlab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (``, ``, ``). | | 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`hamlab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. | -| 1.8 | **C** | `VIRTUALIZATION.md:339-353` per-machine probe table; `:412, 425-436` `~/apps/hamlab`, `/tank/...`, boot NVMe free space | Plan is written against the five lab hosts. | Same as 1.7. | | 1.9 | **C** | `BUGS.md` throughout | Bug bashes narrate `core`, `grid`, `wyse`, `hamtv`, `gate`, the Mac. | History; fine to leave, but it is public. | | 1.10 | **C** | `cmd/hamlab/main.go:1` "Proxmox-style web console for a bare-metal Debian host"; `internal/hardware/hardware.go:5` "what Debian and its kin already have"; `internal/hardware/hardware.go:29` `// "GMKtec NucBox G3"`; `internal/metrics/metrics.go:1358` "did, on grid, 2026-10-02"; `internal/api/server.go:1382-1383` `["grid", …]`, `?machines=grid,wyse`; `internal/api/stacks.go:10` `/home/core/apps/dockhand/stacks/…`; `internal/api/dockersched.go:5` "unbound, nginx and pihole"; `internal/api/zfs.go:5` "grid already runs zfSnap"; `internal/storage/snapshots.go:6` "grid has run zfSnap v1.11.1"; `internal/api/guests.go:8` "Mitchell, 2026-09-27"; `internal/network/network.go:306` "the public address here is a DHCP lease"; `internal/metrics/gpu.go:17` "Asahi, today"; `internal/metrics/pciids.go:4-6` "the router's GPU"; `web/src/Top.tsx:522, 576`; `web/src/hashsel.ts:1-9`; `web/src/Embed.tsx:3` `&machine=grid` | Comments explain decisions by naming lab hosts. | Harmless, but rewrite the ones in package doc comments so a reader of the source doesn't need the lab to understand the code. | @@ -60,7 +62,6 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2 | # | Sev | Where | Assumes | General form | |---|---|---|---|---| | 4.1 | **A** | `internal/updates/updates.go:537-543` `Power()` | `systemctl reboot` / `systemctl poweroff`. On OpenWrt (`gate`) and OpenRC the Power dialog fails. | Try `systemctl`, else `reboot` / `poweroff` (busybox, OpenRC, sysvinit all have them). | -| 4.2 | **A** | `internal/incus/incus.go:1017, 1044-1058, 1069, 1075` | The Incus install script writes `/etc/systemd/system/*` drop-ins and units, adds `SupplementaryGroups` to `hamlab.service`, restarts with `systemd-run`. On OpenRC/procd the daemon never joins `incus-admin` → the tab stays in "repair" forever; the Docker FORWARD fix is never persisted. `HamlabUnit = "hamlab.service"` (`:35`) is surfaced in the API (`:280`). | Branch on the detected init: OpenRC `rc-update`/`conf.d`, procd init script edits, or fall back to `usermod` + a daemon restart via the installer's own supervisor knowledge. Docker rules via a boot hook that is not a unit. | | 4.3 | **A** | `internal/services/services.go:163-170` | Services tab is systemd-only ("says so cleanly"). `gate` (procd) and any OpenRC/runit/s6 box have no Services tab. | `rc-service`/`rc-status` (OpenRC), `/etc/init.d/ status` + `ubus call service list` (procd), `sv` (runit) backends. Noted as "not done" in `PLAN.md:487-489`. | | 4.4 | **A** | `internal/cron/cron.go:353-373, 401, 442, 532-534, 669-670` | Jobs tab reads `/etc/crontab`, `/etc/cron.d`, `/var/spool/cron{,/crontabs}` (Debian/cronie), `/usr/sbin/anacron`, last-runs from **journald**, timers via `systemctl`; writes only `/etc/crontab` and `/etc/cron.d`. Busybox crond (OpenWrt, Alpine) keeps crontabs in `/etc/crontabs/` and has no `cron.d` → the Jobs tab is empty on `gate` and schedules written by 4.5 never run. | Add `/etc/crontabs/*` as a source; last-runs from syslog/logread when there is no journal. | | 4.5 | **A** | `internal/storage/snapshots.go:402` `/etc/cron.d/hamlab-zfs`; `internal/api/dockersched.go:65` `/etc/cron.d/hamlab-docker` | ZFS snapshot and image-update schedules are cron.d files. No cron.d on busybox crond; no cron at all on a systemd-timer-only host (Fedora Server, CoreOS). | Detect: cron.d → cron.d; busybox → a line in `/etc/crontabs/`; none but systemd → a timer unit. The daemon-side runner already exists (`hamlab zfs-snapshot`, `docker-update`). | @@ -74,13 +75,11 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2 | # | Sev | Where | Assumes | General form | |---|---|---|---|---| -| 5.1 | **B** | `internal/incus/incus.go:213-250, 351-352` | Incus can be installed with apt, dnf, pacman, apk. **zypper is missing** (the Updates tab supports it), so an openSUSE host reads "No package manager Ham Lab knows how to drive". `opkg` is known only to say "not packaged for OpenWrt". | Add zypper; phrase the opkg case as "not packaged for this distribution". | -| 5.2 | **B** | `internal/incus/incus.go:973-978` | dnf branch adds `qemu-system-x86-core`/`edk2-ovmf` or `qemu-system-aarch64-core`/`edk2-aarch64`; other arches (riscv64, ppc64le, which `IncusArch()` maps) get no QEMU. pacman and apk branches assume their package names never change. | Table per PM × arch, or let `incus` pull its own deps where the distro does that (Debian does). | -| 5.3 | **B** | `internal/incus/incus.go:958, 970` `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold`; `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. | +| 5.3 | **B** | `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold` in `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. | | 5.4 | **B** | `scripts/pci-ids-gpu.sh:7, 41` default `/usr/share/misc/pci.ids` | Debian's path for `pci.ids`; Fedora/Arch keep it in `/usr/share/hwdata/`. Build-time only. | Try both. | | 5.5 | **B** | `internal/api/status_sensors.go:283`, `internal/api/docker.go:133` | Container memory/CPU read from three cgroup paths: cgroup v2 + systemd driver, cgroupfs, libpod. cgroup v1 hosts (older Debian/Ubuntu, some Alpine, OpenWrt) get no `container.*.mem/cpu`. | Add the v1 `memory/docker/` and `cpu,cpuacct` paths, or fall back to `docker stats --no-stream` once in a while. | | 5.6 | **B** | `internal/metrics/metrics.go:1064` `clkTck = 100` | USER_HZ assumed 100. True on every mainstream build; wrong on a kernel built otherwise. | `sysconf` via `getconf CLK_TCK` once at start. | -| 5.7 | **B** | `internal/incus/subnet.go:150-179` `ip -j`; `internal/space/manager.go:578-585` `timeout -s KILL` | iproute2 JSON output (busybox `ip` has no `-j`); GNU `timeout`. | Parse plain `ip route`/`ip addr`; busybox has `timeout` too but flag spelling differs — use the context deadline. | +| 5.7 | **B** | `internal/space/manager.go:578-585` `timeout -s KILL` | GNU `timeout`. | Busybox has `timeout` too but flag spelling differs — use the context deadline. | ## 6. Hardware and vendor assumptions @@ -99,8 +98,7 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2 | 6.11 | **B** | `internal/vpn/edit.go:5-7, 419-421` | Peers can be **created/edited only through OpenWrt UCI** "because that is where the server here is configured". A wg-quick server (the common case everywhere else) is read-only. | Write wg-quick `.conf` too (append a `[Peer]` block + `wg set`), guarded the same way. | | 6.12 | **B** | `internal/vpn/vpn.go:251-253` | Peer names come from wg-quick comments or UCI descriptions — "which between them cover every machine here". NetworkManager and systemd-networkd WireGuard configs are not read. | Add `.nmconnection` and `.netdev` readers. | | 6.13 | **B** | `internal/vpn/edit.go:515` `profile()` | Split-tunnel routes hard-code `10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12` on top of the tunnel's own prefix. | Offer the host's actual LAN prefixes (from `TakenPrefixes`-style route reading), with RFC 1918 as the fallback. | -| 6.14 | **B** | `internal/incus/subnet.go:1-9, 23-34, 55`; `web/src/Virtualization.tsx:133` | Bridge subnet candidates skip **all of 10/8** because "this LAN is 10.0.0.0/8"; order is 172.16.100–254, then 192.168.254–200, then 172.16.1–99; last-resort `172.16.100.0/24`. Works anywhere, but a lab on 172.16/12 gets 192.168.x first and a lab on 192.168/16 never gets its own range. | Read the host's routes (already done) and pick from all three RFC 1918 blocks in a neutral order. | -| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/incus/subnet.go`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, bridge/peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, no cert SAN and no bridge. | +| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, and no cert SAN. | ## 7. Privilege and deployment posture (the lab's NOPASSWD-everywhere model) @@ -134,7 +132,7 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2 |---|---|---|---|---| | 10.1 | **B** | `Makefile:60-61, 442-443`; `scripts/gitea-release.sh` | Publishing is Gitea/Forgejo-only (release API shape). `selfupdate` also understands a static directory, so publishing could too. | `make release` + any static host is already enough; add a GitHub/Forgejo-agnostic `release-dir` note. | | 10.2 | **C** | `scripts/gitea-release.sh:500-515` | Needs `python3` on the publishing host. | `jq`, or do it in Go (`hamlab publish`). | -| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. `ppc64le`/`s390x` are mapped by `IncusArch()` but never built. | Add or drop them from `IncusArch`. | +| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. | — | ## 11. Things that are already general (checked, no change needed) @@ -145,7 +143,6 @@ Listed so the sweep is known to be complete, not because they need work. - `internal/storage` block devices: `lsblk -J` with a `/sys/block` fallback, eMMC health, NVMe namespace folding, SMART only when `smartctl` exists. - `internal/metrics` CPU/mem/hwmon/thermal/net/disk/fs from `/proc` and `/sys`; remote-FS and pseudo-FS lists; filesystem statfs with a timeout. - `internal/hardware`: every reader may come back empty with a note; device-tree fallbacks for non-SMBIOS boards. -- `internal/incus` detection and the per-machine "ready / repair / installable / unavailable" model (modulo 4.2, 5.1, 5.2). - `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push. - `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design. - `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`. @@ -158,7 +155,7 @@ Listed so the sweep is known to be complete, not because they need work. ## Suggested order of work 1. **Posture and safety first (A):** 7.2 trusted proxies; 7.1 installer-written scoped sudoers; ~~3.1 drop the `:2375` probe (with 3.2 as its consequence)~~ done 2026-10-10. -2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.2 Incus install on non-systemd, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex. +2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex. 3. **Lab defaults that phone home or mislead (B):** 1.1–1.3 update/install URLs, 2.9 schedule defaults, 6.9/6.10 speed-test sources and an off switch, 8.1 memory limit, ~~3.3/9.2 stack home = DataDir~~ done 2026-10-10. -4. **Breadth (B):** 5.1 zypper for Incus, 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6. -5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `VIRTUALIZATION.md`, `SENSORS.md`, `README.md`. +4. **Breadth (B):** 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6. +5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `SENSORS.md`, `README.md`. diff --git a/VIRTUALIZATION.md b/VIRTUALIZATION.md deleted file mode 100644 index 7ba1799..0000000 --- a/VIRTUALIZATION.md +++ /dev/null @@ -1,358 +0,0 @@ -# Virtualization tab — plan (2026-10-09; phase 1 built the same day) - -**Status:** phases 1 and 2 are **deployed to all five machines as v0.1.0-196**. **No machine -has Incus installed as of 2026-10-11**: `grid`'s install of 2026-10-09 was removed at -Mitchell's request so he can install it himself from the tab and ask questions along the way -(`hamlet/docs/hosts.md` has what was removed and what the two days established). The tab -shows Install on `grid`, `core`, the Mac mini and `hamtv`, unavailable on `gate`. Both create -forms carry a **Disk location** picker with add/remove of locations; instance storage as -plain files on the machine's own disk is what he wants on `grid`. Everything in phase 2 was -verified live on `grid` before the removal: ISO fetch with checksum, New VM from ISO, the VGA -console in a real browser (headless Chromium; `binary` subprotocol, one console operation per -VM with a data websocket per SPICE channel), a virtiofs mount into a running VM, exports and -their cron line, `instance.*` sensors and the alert default. **Left:** the LAN bridge step -(his call), cloud-init presets beyond "Use my SSH keys", a backup line for `virt/exports/`. - -A tab for creating, watching, consoling into and deleting **LXC containers and KVM virtual -machines**, the way Proxmox does with `pct` and `qm` — **per machine, like every other -page.** This is the scoping PLAN.md asked for. Nothing here is built. Live state of the -hosts stays in `hamlet/docs/hosts.md`. - -## The governing rule: capability, not policy - -Stated by Mitchell 2026-10-09, after the first draft of this plan excluded hosts on the -strength of the homelab's deployment rules: - -> Anything that's done when a different machine is selected SHOULD GO ON THAT machine. I -> want the option to do it anywhere I want, unless the hardware / software literally -> doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on -> any Linux system. If certain parts / libraries aren't available on that machine, those -> tabs should just report that. - -So: - -- **Every action lands on the machine in the switcher.** There is no "VM host", no default - machine, no cross-machine placement. `grid` is where he will most likely click first; - the tab does not know or care. -- **The tab is gated by what the machine can do, detected live** — never by a rule about - what *should* run there. The `hamlet/CLAUDE.md` do-not-break rules (Mac mini for - single-core workloads, `hamtv` not spare hardware) bind the *agent's* unasked - deployments. They are not feature gates for the product, and the first draft was wrong - to treat them as such. -- **Ham Lab owns its data, and its data lives in its own folder.** Everything this tab - manages — instance storage, ISOs, image tarballs, exports — sits under Ham Lab's data - directory on *that* machine (`config.DataDir()`, `~/apps/hamlab` on `core`, `grid` and - the Mac), in one `virt/` tree. Never a path elsewhere on the machine, however convenient - the existing folder. Stated 2026-10-09 after the first Library draft pointed the ISO shelf - at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it - manages its own data."* This is the rule that moved `core`'s compose files under - `apps/hamlab/stacks/` on 2026-10-05; `grid`'s and the Mac's stacks, still at - `~/stacks//`, are the outstanding exception and are not touched by this plan. - The engine's own runtime state (`/var/lib/incus`, like `/var/lib/docker`) is the engine's, - not Ham Lab's data: Ham Lab keeps what it would need to rebuild it. -- **Ham Lab installs or reports.** It detects what is missing, offers to install it where it - knows how, and where it cannot or the hardware cannot do the job, says so in one sentence - and offers what *does* work (containers without KVM, for instance). - -## Decision: Incus 6.0 LTS as the engine - -| Option | Verdict | -|---|---| -| **Incus** (LXD fork, linuxcontainers.org) | **Chosen.** One daemon, one CLI and one JSON API for both containers and VMs. Packaged by Debian 13 (`incus` 6.0.4 LTS — `apt install incus` pulls `qemu-system-x86`, `swtpm`, `virtiofsd`, `incus-agent`, so VMs work out of the box), Fedora (6.23), Arch, Alpine, Ubuntu (Zabbly repo); not by OpenWrt. Storage drivers for ZFS, btrfs, LVM and plain directories, so it fits a machine with or without ZFS. Built-in image server (`images:debian/13`, `images:ubuntu/24.04`, Alpine, Fedora, Arch… as container *or* VM, per architecture), snapshot schedules with expiry, `incus export` backups, per-instance CPU/memory limits, cloud-init, text and VGA console over websocket. Everything is reachable as `incus … --format json` or `incus query`, which is exactly how Ham Lab already drives Docker: `exec.Command`, no new Go dependency, no cgo. | -| libvirt/KVM + `virsh`, plus LXC separately | Two daemons, two models, XML. libvirt's official Go binding needs cgo, which the static binary rules out (the pure-Go `go-libvirt` exists but then containers are a second project). Rejected. | -| Proxmox | An OS, not a library. Rejected. | - -## What each machine can do today - -Probed 2026-10-09 (`/dev/kvm`, cpu flags, kvm modules, RAM, disk, package availability). -These are **facts the tab will detect at runtime**, not a list of where instances are -allowed. The last column is what the tab would show the day it ships. - -| Machine | Arch | KVM | RAM (free) | Incus from the package manager | Pool driver available | Tab state on first open | -|---|---|---|---|---|---|---| -| `grid` | x86-64 | yes — VT-x, `kvm_intel` loaded, nested on | 62 GiB (~25; ARC holds 23 of the rest, max 40) | apt: `incus` 6.0.4 | **zfs** (2.3.9, `tank`) | **Install** — containers and VMs | -| `core` | x86-64 | yes — N100, `kvm_intel` loaded | 7.5 GiB (~4) | apt: `incus` 6.0.4 | btrfs or dir (ZFS was purged 2026-09-23) | **Install** — containers and VMs; the create form shows free memory so a VM's size is an informed choice | -| Mac mini | aarch64 | yes — Asahi KVM, 16K pages | 15 GiB (~8) | dnf: `incus` 6.23, `qemu-system-aarch64-core`, `edk2-aarch64` | btrfs or dir | **Install** — containers and VMs, **arm64 images only** (the picker filters by host arch). Whether a stock 4K-page guest kernel boots under the 16K host is untested; `muvm` does exactly that, so it is expected to. | -| `hamtv` | x86-64 | yes | 3.7 GiB (~2.9) | apt: `incus` 6.0.4 | btrfs or dir | **Install** — containers and small VMs | -| `gate` | x86-64 | **no** `/dev/kvm` | 8 GiB (~6) | **none** — OpenWrt's opkg has no Incus | — | **Unavailable**: "Incus isn't packaged for OpenWrt, and this kernel has no KVM." Nothing to install. | - -Nothing is installed anywhere today: no qemu, libvirt, lxc or incus on any host. - -### Detection the tab runs, in order - -1. `incus` on `PATH` and its socket answering → **ready**; read pools, networks, instances. -2. Else: a package manager Ham Lab knows (apt, dnf, pacman, apk) that has an `incus` - candidate → **installable**, with the Install dialog pre-filled (below). -3. Else → **unavailable**, with the reason in one sentence (no package; no root; OpenWrt). -4. Independently: `/dev/kvm` present and openable → VMs offered. Absent → **containers - only**, said once on the page, and the create form has no VM option. -5. Architecture from `uname -m`, so the image picker only shows images that can run here. -6. Pool driver: `zfs` if the module and tools are present, else `btrfs` (installing - `btrfs-progs` as part of Install), else `dir`. The dialog says which it picked and why. - -## What the tab does (v1) - -Mirrors the Docker tab's shape: a list, a detail page, actions that are followed tasks, -destructive ones confirmed by typing the name. - -- **Install** — one task that runs *on the selected machine*: install the package(s), - `incus admin init --preseed` with the layout from the dialog, create the pool and bridge, - add Ham Lab's user to `incus-admin` (socket access, the `docker`-group shape) and add it - to the unit's `SupplementaryGroups`, apply the Docker fix where Docker is present (below), - then restart Ham Lab. Idempotent, so it doubles as "repair". The dialog shows and lets him - edit: pool driver (detected), pool size (proposed from free space), bridge subnet - (computed), whether to apply the Docker rules. Where it all goes is not a choice: Ham - Lab's folder. -- **Instances** — containers and VMs in one list: type, state, IPv4, CPU and memory live, - disk, snapshot count, uptime. Click for detail. -- **Create** — container or VM (VM only where KVM is); image from the `images:` remote, - filtered to this architecture, searchable, with the common ones pinned (Debian 13, Ubuntu - 24.04, Alpine, Fedora); name, CPUs, memory (with this machine's free memory beside it), - root disk size; network (the NAT bridge, or a LAN bridge if this machine has one); - optional cloud-init user-data (SSH key, packages, a first command); profile. Create, or - create and start. Collisions (name taken) named before Create. -- **Lifecycle** — start, stop, restart, force stop, rename, delete (type the name; the - instance's snapshots go with it, and the dialog says so). -- **Console** — two buttons, both open as tabs in the Terminal page through the existing PTY - manager (`term.CreateCmd`, the `docker exec` path): **Console** (`incus console`, the - serial console — works before the network is up, shows boot output) and **Shell** - (`incus exec -- login shell`). Containers and, via `incus-agent`, VMs. -- **Snapshots** — take, restore (confirmed), delete; a **schedule** per instance with an - expiry, which is Incus's own `snapshots.schedule`/`snapshots.expiry`, so no cron file of - ours this time. -- **Resources** — edit CPU and memory limits (live for containers; VMs take CPU hotplug and - memory balloon live with the agent, else on restart), grow the root disk, add a bind mount - (a host path into a container; virtiofs into a VM). -- **Library** — ISOs for VMs and images (container templates and VM images). Its own - section below. -- **Storage and networks** — the pools with their fill, the bridges with their subnets. - Read-only in v1 beyond what Create and Install need. -- **Sensors** — `instance..cpu`, `.memory`, `.running` from Incus's state, like the - `container.*` sensors, so Alerts gets an "instance down" default and widgets can show one. - -## Where the data lives - -One tree per machine, under Ham Lab's data directory, created by Install: - -``` -~/apps/hamlab/virt/ - pool.img the instance pool's backing file (zfs or btrfs loop; a plain directory - pool/ when the driver is dir) — every instance's disks live in here - isos/ the ISO library: the files themselves - images/ the image library: template tarballs and VM disk images as files, the - way Proxmox keeps CT templates — Incus's image store is loaded from here - exports/ nightly `incus export` tarballs, with retention - virt.json what Ham Lab chose and when: driver, pool size, bridge subnet, ISO and - image inventory, export schedule -``` - -Consequences, stated once: - -- The tree is on whatever disk holds Ham Lab's data directory — the boot NVMe on all three. - Install proposes the pool size from that disk's free space. On `grid` that disk has - 790 GiB free; on `core`, 175 GiB. -- Bulk data a guest needs is **bind-mounted in from where it already is** (`/tank/…` into an - instance on `grid`), the same split Docker has: the instance is Ham Lab's, the media is - not. That is a device on the instance, recorded in its config, not a second home. -- On `core` the whole tree rides the existing nightly rsync to `grid` for free. On `grid` - and the Mac it is as backed up as the rest of their Ham Lab folders — which is to say the - exports are the backup, and the backup job will need to be told about them in phase 2. -- Existing material elsewhere — the 105 GiB of ISOs at `/tank/software/isos/`, the Proxmox - archives — is **imported into the tree by copying**, file by file, on request. The - library never indexes a folder it does not own. - -## Library: ISOs and images (asked for 2026-10-09) - -Proxmox has a storage view with **ISO Images** and **CT Templates**. Incus has the same two -things under other names, and the tab gives them one home, a **Library** sub-tab, per machine -like everything else. - -| Proxmox calls it | Incus calls it | What it is here | -|---|---|---| -| ISO Images | an ISO file attached to a VM as a `disk` device (added as a ROM device; VMs only), or a custom storage volume of type `iso` | **`virt/isos/` in Ham Lab's folder**, attached by path — no copy into the pool | -| CT Templates | **images** — the `images:` remote (linuxcontainers.org), the local image store, anything imported or published | **`virt/images/` in Ham Lab's folder** — tarballs and disk images as files, loaded into Incus from there; the remote is the catalogue to pull from | - -### ISOs - -- **The ISO library is `virt/isos/` in Ham Lab's folder**, on every machine, full stop. The - 105 GiB at `/tank/software/isos/` on `grid` (`linux/`, `windows/`, `truenas/`, - `virtio_drivers/`…) is a *source* to import from — pick the ones wanted, Ham Lab copies them - in — not the library itself. -- **Getting an ISO in:** upload from the browser (the Files tab's `PUT /api/files/upload` - with its progress bar, reused), **download from a URL** as a followed task with an optional - checksum (the usual way — an ISO starts as a link), or **import from a path** already on the - machine — which **copies** the file into `virt/isos/` and says so. Rename, delete (refused while a VM has it attached, naming the VM). -- **Using one:** attach to / detach from a VM as a ROM device by path, with `boot.priority` - set so it boots first; **New VM from ISO** makes an empty VM (`incus init --empty --vm`) - with the ISO attached, starts it and opens the console. After install, Detach is one click - and boot order falls back to the disk. -- **Honest limit:** an ISO install is an interactive graphical installer, so it needs the - **VGA console in the browser** (`spice-html5` over Incus's VGA console websocket). - Installers that run on a serial console (Debian with `console=ttyS0`, Alpine, Arch) work - through the text console before that exists. Windows needs the virtio drivers ISO attached - as a second ROM device as well — Incus's documented path is `distrobuilder repack-windows` - to bake them in; the drivers are already in `virtio_drivers/` on `tank`. So **the ISO shelf - and the VGA console ship together, in phase 2**, moved up from phase 3. -- ISO *volumes* (`incus storage volume import --type=iso`) are the - alternative: read-only, attachable to several VMs at once, but they copy the file into the - pool. Not used here — a file in `virt/isos/` is already Ham Lab's, and a volume would copy it - a second time into the pool. Offered as an option only if a need shows up. - -### Images (container templates and VM images) - -- **The catalogue:** browse `images:` filtered to this machine's architecture, by type - (container / VM), with the common ones pinned. **Download** copies one into the local - store (`incus image copy images:debian/13 local: --alias debian-13`) so a later Create is - instant and works without the internet. Images used by Create are cached by Incus anyway; - this is just the explicit version, Proxmox's "Templates → Download". -- **The local store:** every image here with alias, type, arch, size, origin (remote, - published, imported), when it was last used, and **which instances were made from it** - (Incus records the base image on each instance). Alias, delete (refused while an - instance's base — Incus knows), "New instance from this image". -- **Import:** a unified tarball or metadata + rootfs pair (a container template from - anywhere, including the phase-3 Proxmox conversions), or a **qcow2 / raw disk with - metadata Ham Lab writes** for a VM image — Home Assistant OS, OPNsense, a vendor cloud - image — by upload, URL, or a path on the machine. Same three ways in as ISOs. -- **Publish:** a stopped instance becomes an image with an alias — Proxmox's "convert to - template", the one thing the Images section of v1 always had. -- **Where they live:** as files in `virt/images/` — the downloaded or imported tarball, the - published image exported to one (`incus image export`) — so the library is complete without - Incus and can be reloaded into a fresh install. Incus's own image store under - `/var/lib/incus/images/` is a cache derived from it, and the page says so. - -## Not in v1, with reasons - -| Thing | Why not yet | -|---|---| -| **VGA console in the browser** | Incus hands the VM's SPICE socket over a websocket; drawing it needs `spice-html5` in the UI. Doable (the Incus UI does it); text console + shell cover every Linux guest until then. **Phase 2, with the ISO shelf** — an ISO install is useless without it. | -| **Windows VMs** | Incus runs them (ISO repacked with virtio drivers by `distrobuilder`), but without a VGA console in the browser they are unusable from the tab. After the above. | -| **GPU passthrough to a VM** | Removes the card from the host and from whatever uses it there (Jellyfin on `grid`). Offered only with that warning, later. Sharing a GPU into a *container* (`gpu` device) leaves the host its card and is how `r720-jellyfin` used to run. Phase 3. | -| **Clustering / live migration** | Incus clusters, but these machines differ in size and one is arm64, with no shared storage. Not planned. | -| **A fleet-wide instance list** | One machine per view. The switcher is the machine. | -| **A second engine (bare LXC on OpenWrt, libvirt-only hosts)** | Would double the backend for one router that has no KVM. The tab reports Incus unavailable there and stops. | - -## Layout decisions Ham Lab makes per machine (overridable in the Install dialog) - -1. **Storage — revised 2026-10-11.** The create forms carry a **Disk location** picker: a - list of the machine's Incus storage pools, each described by where its bytes land, with - "+ add a location" (any folder on the machine becomes a plain-files pool). On `grid` the - one location is `local`: plain files under `virt/pool/` on the root NVMe, each VM's disk - its own sparse file sized on its own, each container a directory, nothing pre-sized. - *The first design* (a ZFS pool inside one sparse file, handed to Incus as the pool's - source, for instant snapshots and clones) was built, ran for two days, and was taken out - at Mitchell's instruction: one pre-sized file holding every disk is an opaque middle - layer with a number on it that reads as a reservation, and "zfs · hamlab-virt" on the page - read as the NAS pool. The Install dialog still offers zfs/btrfs-in-a-file for a machine - that wants instant snapshots; the labels now say exactly what each is. Bulk data is still - **bind-mounted in**, not stored in the pool. -2. **Bridge subnet, computed.** Incus's `auto` picks a random `10.x.y.0/24`, and **this - LAN is `10.0.0.0/8`** — every host has a /8 on its NIC, so any 10.x bridge collides. - Ham Lab reads the machine's routes and picks the first free /24 from `192.168.0.0/16`, - then `172.16.0.0/12`, that is neither on-link nor routed nor inside Docker's configured - pool (`172.28.0.0/14` on `grid`; `172.19`–`172.29` and `192.168.80`–`143` live on - `core`). Instances get `name.incus` DNS from Incus's dnsmasq. -3. **LAN addresses are a later, explicit step.** A `br0` on the machine's uplink lets an - instance take a lease from `gate` and a Pi-hole name, but it rewrites the host's network - config and drops it off the network for seconds — never part of Install, always its own - confirmed action (with the NanoKVM standing by on `grid`). macvlan was considered and - rejected: no host change, but host and guests cannot talk, so nothing on the host could - reach an instance. -4. **Docker and Incus on one host.** Docker sets the `FORWARD` policy to `DROP`, which - silently kills the Incus bridge's traffic. Fix is two `DOCKER-USER` accept rules for the - bridge, persisted — applied by Install wherever Docker is detected (`grid`, `core`, the - Mac, `hamtv` all run it). -5. **Memory.** A VM's memory is a hard reservation; the create form shows the machine's - free memory and warns past it, and never refuses. On a ZFS host Install offers to cap - the ARC (on `grid`, 40 GiB → 16 GiB) so guest RAM is deterministic; his choice. -6. **Privilege.** Ham Lab's user joins `incus-admin`; no sudo in the hot path. Install - itself goes through `priv` like the other installers. -7. **Backups.** Nightly `incus export --instance-only` of every instance to `virt/exports/` - with a retention, as a cron line like the ZFS schedules (`hamlab incus-export`), from - phase 2. On `core` that folder is inside the nightly rsync already; on `grid` and the Mac - the backup job gets a line for it in phase 2. - -## The old Proxmox estate (importable, phase 3) - -`/tank/backups/proxmox/` holds the retired `r720` node: `/etc/pve` (12 LXC confs, 2 VM -confs), 118 GiB of `vzdump` archives, and three raw VM disks. Latest of each: - -| ID | Name | Latest archive | Size | -|---|---|---|---| -| 100 | r720-docker | 2026-01-11 | 20.7 GB | -| 101 | r720-cockpit | 2026-01-11 | 439 MB | -| 102 | r720-wordpress-alexblaskovich | 2026-01-11 | 1.2 GB | -| 103 | r720-spoonterm | 2025-06-14 | 494 MB | -| 104 | r720-ollama | 2026-01-11 | 11.3 GB | -| 105 | r720-transmission | 2026-01-11 | 322 MB | -| 106 | r720-apache | 2026-01-11 | 347 MB | -| 107 | r720-nut | 2025-03-29 | 205 MB | -| 108 | r720-filebrowser | 2026-01-11 | 262 MB | -| 109 | r720-bookstack | 2025-04-27 | 516 MB | -| 110 | r720-jellyfin | 2026-01-11 | 2.2 GB | -| 111 | r720-wireguard | 2025-04-13 | 510 MB | -| 112 | r720-eggwiki | 2026-01-11 | 634 MB | -| VMs | steam (102), powerpanel (105/107/112) | `.vma.zst` 2024–2025; raw disks `vm-105`, `vm-112`, `base-104` | 19 GB, 1.9 GB ×6, 97 GiB | - -- **LXC:** feasible and scriptable. Unpack the rootfs (`tar --zstd -xpf … --numeric-owner`), - read `etc/vzdump/pct.conf` for arch/ostype, drop it, write `metadata.yaml`, pack, `incus - image import`, launch. Unprivileged uid shifting is Incus's job after that. The result is - an image in the Library, so a restored container is launched from there like any other - template. An import action in the tab (pick an archive on this machine) is plausible once - the script works by hand. x86-64 archives only run on x86-64 machines; the picker says so. -- **VMs:** the raw disks import directly (`incus-migrate`, or an empty VM with the disk - imported into its root volume). The `.vma.zst` archives need Proxmox's `vma` extractor (or - the Python port) to become raw disks first. Most of these are the same `powerpanel` box - backed up six times, and `tank-space-audit.md` already has them down for deletion — decide - which, if any, are wanted before anything is restored. - -## Build plan - -| Phase | What | Effort | -|---|---|---| -| **1 — detection, Install, the tab** | `internal/incus` (detection, CLI wrapper, JSON types, `incus monitor` for live state, the preseed builder and subnet picker), `internal/api/virt.go` (routes below), `Virtualization.tsx` (the three page states; list, detail, create dialog, snapshot panel), rail entry `virt`, terminal integration, sensors, and the Library's **Images** shelf (browse and download from `images:`, local store, import, publish). **Tested by clicking Install on `grid` first, then `core`** — two different pool drivers, both with Docker present. Then the Mac for the arm64 path. `gate` for the unavailable state. | 2–3 sessions | -| **2 — operations (done 2026-10-09, except the LAN bridge)** | The Library's **ISO** shelf (directory, upload/URL/path, attach, New VM from ISO) **with the VGA console** (`spice-html5`), snapshot schedules UI, nightly exports with retention and the per-machine export directory, limits editor, bind mounts, the LAN-bridge step, instance alert default, cloud-init presets. | 1–2 sessions | -| **3 — the rest** | Proxmox import (archives become Library images), GPU device for containers, Windows via `distrobuilder repack-windows`, GPU passthrough to a VM with its warning. | as wanted | - -### API sketch - -``` -GET /api/virt {state: ready|installable|unavailable, reason, kvm, arch, - pool_drivers[], pools[], networks[], instances[], free_memory} -POST /api/virt/install {driver, size, path, subnet, docker_rules, arc_max?} task -GET /api/virt/images?remote=images&q= local cache + remote search, filtered to this arch -DELETE /api/virt/images/{fingerprint} -POST /api/virt/instances {name, type, image, cpu, memory, disk, network, cloudinit, profile, start} task -GET /api/virt/instances/{name} config, state, devices, snapshots -PATCH /api/virt/instances/{name} limits, description, devices -POST /api/virt/instances/{name}/{start|stop|restart|kill|delete|rename|publish|export} -POST /api/virt/instances/{name}/console → terminal {id} (incus console) -POST /api/virt/instances/{name}/exec → terminal {id} (incus exec -- sh -lc …) -POST /api/virt/instances/{name}/snapshots {name}; POST …/{snap}/restore; DELETE …/{snap} -PUT /api/virt/instances/{name}/schedule {cron, expiry} -POST /api/virt/import {path} task (export tarball, or phase-3 Proxmox archive) - -GET /api/virt/isos virt/isos/, scanned: name, size, attached-to -POST /api/virt/isos/import {path} task: copy a file on this machine into virt/isos/ -POST /api/virt/isos/fetch {url, sha256?} task; uploads reuse PUT /api/files/upload -DELETE /api/virt/isos/{name} refused while attached -POST /api/virt/instances/{name}/iso {iso, boot_first} attach; DELETE …/iso detach -POST /api/virt/instances/from-iso {name, iso, cpu, memory, disk, network} task → console -GET /api/virt/images/remote?q=&type= the `images:` catalogue for this arch -POST /api/virt/images/download {alias} task (incus image copy images:… local:) -POST /api/virt/images/import {path | url, alias, type} task: copy into virt/images/, then load into Incus -GET /api/virt/instances/{name}/vga websocket: Incus VGA console → spice-html5 (phase 2) -``` - -Everything is per machine and reaches a node through the existing `/api/nodes/{name}/…` -proxy with no hub-side code; consoles attach over the existing `/api/terminals/{id}/attach`. -Tasks stream through `/api/tasks/{id}/stream` as Docker's do. - -## Sources - -- Incus on Debian: https://wiki.debian.org/Incus -- Incus and Docker on one host (the `FORWARD` drop): https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/ -- Bridge options and `ipv4.address: auto`: https://linuxcontainers.org/incus/docs/main/reference/network_bridge/ -- Consoles (text and VGA): https://linuxcontainers.org/incus/docs/main/howto/instances_console/ -- ZFS storage driver: https://linuxcontainers.org/incus/docs/main/reference/storage_zfs/ -- macvlan's host-isolation: https://linuxcontainers.org/incus/docs/main/reference/network_macvlan/ -- Image format for the Proxmox import: https://linuxcontainers.org/incus/docs/main/reference/image_format/ diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index d526b03..567154c 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -30,7 +30,6 @@ import ( "hamlab/internal/config" "hamlab/internal/cron" "hamlab/internal/files" - "hamlab/internal/incus" "hamlab/internal/media" "hamlab/internal/metrics" "hamlab/internal/nodes" @@ -64,7 +63,6 @@ func main() { passwd [user] set a user's password (makes the account if it is new; reads stdin when piped) users list the accounts zfs-snapshot run one ZFS snapshot schedule (what its cron line calls) - virt-export [name…] export Incus instances to the library (what its cron line calls) docker-update run one image-update schedule (what its cron line calls) user-rm remove an account and sign it out everywhere check verify the config and state load, then exit @@ -152,10 +150,6 @@ func main() { if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil { log.Fatal(err) } - case "virt-export": // the nightly cron line from the Virtualization tab; optional instance names - if err := virtExport(cfg, flag.Args()[1:]); err != nil { - log.Fatal(err) - } case "users": migrateUsers(cfg) if len(cfg.Users) == 0 { @@ -306,21 +300,6 @@ func zfsSnapshot(cfg *config.Config, id string) error { return nil } -// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham -// Lab runs as (which is in incus-admin), and records the run beside the data. -func virtExport(cfg *config.Config, names []string) error { - who := os.Getenv("USER") - if u, err := user.Current(); err == nil && u.Username != "" { - who = u.Username - } - run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) }) - fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds) - if len(run.Errors) > 0 { - return errors.New(strings.Join(run.Errors, "; ")) - } - return nil -} - func migrateUsers(cfg *config.Config) { name := os.Getenv("USER") if u, err := user.Current(); err == nil && u.Username != "" { diff --git a/internal/api/alerts.go b/internal/api/alerts.go index 0430185..f5418ce 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -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 diff --git a/internal/api/server.go b/internal/api/server.go index 5420e4b..6176060 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)) diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go index b499701..d236539 100644 --- a/internal/api/status_sensors.go +++ b/internal/api/status_sensors.go @@ -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 == "" { diff --git a/internal/api/virt.go b/internal/api/virt.go deleted file mode 100644 index 18d5535..0000000 --- a/internal/api/virt.go +++ /dev/null @@ -1,1167 +0,0 @@ -package api - -// The Virtualization tab: LXC containers and KVM virtual machines through Incus, per -// machine. Three page states — ready, repairable, installable, or unavailable with the -// reason — and every action lands on the machine that was asked. Long work (install, create, -// image downloads, publish) runs as a followed task; consoles are terminal sessions like -// any other, so they reattach and work through the hub's proxy. - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "os/exec" - "path" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/coder/websocket" - - "hamlab/internal/incus" - "hamlab/internal/priv" - "hamlab/internal/sensors" -) - -func readBody(r *http.Request, v any, max int64) error { - return json.NewDecoder(io.LimitReader(r.Body, max)).Decode(v) -} - -// virtOverview is the page: the machine's state, and when Incus answers, everything on it. -func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) { - st := s.virt.Detect(r.Context()) - out := map[string]any{"status": st} - if st.State == "ready" { - ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) - defer cancel() - inst, err := s.virt.Instances(ctx) - if err != nil { - out["error"] = err.Error() - } - out["instances"] = inst - imgs, _ := s.virt.Images(ctx) - out["images"] = imgs - out["pools"] = s.virt.Pools(ctx) - out["default_pool"] = s.virt.DefaultPool(ctx) - out["networks"] = s.virt.Networks(ctx) - } - isos := s.virt.Library(s.virt.ISOsDir()) - if inst, ok := out["instances"].([]incus.Instance); ok { - users := s.isoUsers(inst) - for i := range isos { - isos[i].AttachedTo = users[isos[i].Name] - } - } - out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": isos} - jsonOK(w, 200, out) -} - -func (s *Server) virtInstall(w http.ResponseWriter, r *http.Request) { - var o incus.InstallOpts - if err := readBody(r, &o, 16<<10); err != nil { - jsonErr(w, 400, "bad request") - return - } - st := s.virt.Detect(r.Context()) - if st.State != "installable" && st.State != "repair" { - jsonErr(w, 409, "nothing to install here: "+st.State) - return - } - if err := s.virt.EnsureDirs(); err != nil { - jsonErr(w, 500, err.Error()) - return - } - script, lay, err := s.virt.InstallScript(st, o) - if err != nil { - jsonErr(w, 400, err.Error()) - return - } - if err := s.virt.SaveLayout(lay); err != nil { - jsonErr(w, 500, err.Error()) - return - } - // kept beside the layout, so what was run is on record - _ = os.WriteFile(filepath.Join(s.virt.Dir(), "install.sh"), []byte(script), 0o700) - t, err := s.tasks.Start("virt:install", "install Incus", s.virt.ShellCmd(r.Context(), script)) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: install started as task %s — %s pool, %s (from %s)", t.ID, lay.Driver, lay.Subnet, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtReady(w http.ResponseWriter, r *http.Request) bool { - if st := s.virt.Detect(r.Context()); st.State != "ready" { - jsonErr(w, 409, "Incus isn't ready on this machine") - return false - } - return true -} - -// ---- images ---- - -func (s *Server) virtRemoteImages(w http.ResponseWriter, r *http.Request) { - if !s.virtReady(w, r) { - return - } - list, err := s.virt.Remote(r.Context(), r.URL.Query().Get("refresh") == "1") - if err != nil { - jsonErr(w, 502, err.Error()) - return - } - if t := r.URL.Query().Get("type"); t == "container" || t == "virtual-machine" { - var f []incus.Image - for _, im := range list { - if im.Type == t { - f = append(f, im) - } - } - list = f - } - if list == nil { - list = []incus.Image{} - } - jsonOK(w, 200, map[string]any{"images": list}) -} - -func (s *Server) virtImageDownload(w http.ResponseWriter, r *http.Request) { - var body struct { - Alias string `json:"alias"` - } - _ = readBody(r, &body, 4096) - alias := strings.TrimSpace(body.Alias) - if !incus.ValidImage(alias) || strings.Contains(alias, ":") { - jsonErr(w, 400, "alias: like debian/13 or ubuntu/24.04/cloud") - return - } - if !s.virtReady(w, r) { - return - } - local := strings.ReplaceAll(alias, "/", "-") - // copied into the local store under a flat alias, then exported to the library so the - // file is Ham Lab's whether or not Incus keeps its cache - script := `set -e; incus image copy "images:$1" local: --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"` - t, err := s.tasks.Start("virt:image", "download "+alias, s.virt.ShellCmd(r.Context(), script, alias, local, s.virt.ImagesDir())) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: image %s download as task %s (from %s)", alias, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtImageImport(w http.ResponseWriter, r *http.Request) { - var body struct { - Path string `json:"path"` - Alias string `json:"alias"` - Type string `json:"type"` // container | virtual-machine - } - _ = readBody(r, &body, 8192) - alias := strings.TrimSpace(body.Alias) - if !incus.ValidImage(alias) || strings.Contains(alias, ":") { - jsonErr(w, 400, "alias: letters, digits, dots, dashes") - return - } - p := filepath.Clean(body.Path) - st, err := os.Stat(p) - if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() { - jsonErr(w, 400, "path: a file on this machine") - return - } - if !s.virtReady(w, r) { - return - } - dest := filepath.Join(s.virt.ImagesDir(), filepath.Base(p)) - var script string - if body.Type == "virtual-machine" { - // a bare disk image needs the metadata Incus wants beside it; a small tarball Ham Lab writes - script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; META="$3/$4.metadata.tar.gz"; T=$(mktemp -d); printf 'architecture: %s\ncreation_date: %s\nproperties:\n description: "%s"\n os: custom\n' "$5" "$(date +%s)" "$4" > "$T/metadata.yaml"; tar -C "$T" -czf "$META" metadata.yaml; rm -rf "$T"; incus image import "$META" "$2" --alias "$4"; echo "imported $4 from $2"` - } else { - script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; incus image import "$2" --alias "$4"; echo "imported $4 from $2"` - } - t, err := s.tasks.Start("virt:image", "import "+alias, s.virt.ShellCmd(r.Context(), script, p, dest, s.virt.ImagesDir(), alias, incus.IncusArch())) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: image import %s from %s as task %s (from %s)", alias, p, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtImagePublish(w http.ResponseWriter, r *http.Request) { - var body struct { - Instance string `json:"instance"` - Alias string `json:"alias"` - } - _ = readBody(r, &body, 4096) - alias := strings.TrimSpace(body.Alias) - if !incus.ValidName(body.Instance) || !incus.ValidImage(alias) || strings.Contains(alias, ":") { - jsonErr(w, 400, "an instance and an alias") - return - } - if !s.virtReady(w, r) { - return - } - script := `set -e; incus publish "$1" --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"` - t, err := s.tasks.Start("virt:image", "publish "+body.Instance+" as "+alias, s.virt.ShellCmd(r.Context(), script, body.Instance, alias, s.virt.ImagesDir())) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: publish %s as %s, task %s (from %s)", body.Instance, alias, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtImageDelete(w http.ResponseWriter, r *http.Request) { - var body struct { - Ref string `json:"ref"` - } - _ = readBody(r, &body, 4096) - if !incus.ValidImage(body.Ref) { - jsonErr(w, 400, "bad image") - return - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - if out, err := s.virt.Cmd(ctx, "image", "delete", body.Ref).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: image %s deleted (from %s)", body.Ref, clientIP(r)) - w.WriteHeader(204) -} - -// ---- instances ---- - -func (s *Server) virtCreate(w http.ResponseWriter, r *http.Request) { - var sp incus.CreateSpec - if err := readBody(r, &sp, 512<<10); err != nil { - jsonErr(w, 400, "bad request") - return - } - st := s.virt.Detect(r.Context()) - if st.State != "ready" { - jsonErr(w, 409, "Incus isn't ready on this machine") - return - } - args, err := incus.CreateArgs(sp, st.KVM) - if err != nil { - jsonErr(w, 400, err.Error()) - return - } - if inst, err := s.virt.Instances(r.Context()); err == nil { - for _, in := range inst { - if in.Name == sp.Name { - jsonErr(w, 409, "there is already an instance called "+sp.Name) - return - } - } - } - kind := "container" - if sp.VM { - kind = "VM" - } - t, err := s.tasks.Start("virt:create", fmt.Sprintf("create %s %s", kind, sp.Name), s.virt.Cmd(r.Context(), args...)) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: create %s %s from %s as task %s (from %s)", kind, sp.Name, sp.Image, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtInstance(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if !incus.ValidName(name) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - list, err := s.virt.Instances(r.Context()) - if err != nil { - jsonErr(w, 502, err.Error()) - return - } - for _, in := range list { - if in.Name == name { - jsonOK(w, 200, in) - return - } - } - jsonErr(w, 404, "no such instance") -} - -// virtAction is start / stop / restart / kill / delete, and the two consoles. -func (s *Server) virtAction(w http.ResponseWriter, r *http.Request) { - name, action := r.PathValue("name"), r.PathValue("action") - if !incus.ValidName(name) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - var args []string - switch action { - case "start": - args = []string{"start", name} - case "stop": - args = []string{"stop", name} - case "restart": - args = []string{"restart", name} - case "kill": - args = []string{"stop", "--force", name} - case "delete": - var body struct { - Confirm string `json:"confirm"` - Force bool `json:"force"` - } - _ = readBody(r, &body, 4096) - if body.Confirm != name { - jsonErr(w, 400, "type the instance's name to delete it") - return - } - args = []string{"delete", name} - if body.Force { - args = []string{"delete", "--force", name} - } - case "console", "exec": - s.virtTerminal(w, r, name, action) - return - default: - jsonErr(w, 400, "unknown action") - return - } - ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: %s %s (from %s)", action, name, clientIP(r)) - w.WriteHeader(204) -} - -// virtTerminal opens the serial console or a shell as a terminal session. -func (s *Server) virtTerminal(w http.ResponseWriter, r *http.Request, name, kind string) { - var argv []string - title := "" - if kind == "console" { - argv = s.virt.Argv(r.Context(), "console", name) - title = "console " + name - } else { - argv = s.virt.Argv(r.Context(), "exec", name, "--", "sh", "-c", "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi") - title = "sh " + name - } - sess, err := s.mgr.CreateCmd("", title, argv) - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - log.Printf("virt: %s of %s as terminal %s (from %s)", kind, name, sess.ID, clientIP(r)) - jsonOK(w, 200, map[string]string{"id": sess.ID, "name": title}) -} - -func (s *Server) virtRename(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body struct { - Name string `json:"name"` - } - _ = readBody(r, &body, 4096) - if !incus.ValidName(name) || !incus.ValidName(body.Name) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, "rename", name, body.Name).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: renamed %s to %s (from %s)", name, body.Name, clientIP(r)) - w.WriteHeader(204) -} - -// virtConfig sets limits, the snapshot schedule, or a description — the config keys the page -// edits, each validated for its shape. An empty value unsets the key. -func (s *Server) virtConfig(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body map[string]string - if err := readBody(r, &body, 16<<10); err != nil || !incus.ValidName(name) { - jsonErr(w, 400, "bad request") - return - } - var set, unset []string - desc, hasDesc := body["description"] - delete(body, "description") - if hasDesc && (len(desc) > 200 || strings.ContainsAny(desc, "\n\r")) { - jsonErr(w, 400, "bad value for description") - return - } - for k, v := range body { - v = strings.TrimSpace(v) - ok := false - switch k { - case "limits.cpu": - ok = incus.ValidCPU(v) - case "limits.memory": - ok = incus.ValidSize(v) - case "snapshots.schedule": - ok = incus.ValidCron(v) - case "snapshots.expiry": - ok = incus.ValidExpiry(v) - case "boot.autostart": - ok = v == "" || v == "true" || v == "false" - } - if !ok { - jsonErr(w, 400, "bad value for "+k) - return - } - if v == "" { - unset = append(unset, k) - } else { - set = append(set, k+"="+v) - } - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) - defer cancel() - if len(set) > 0 { - if out, err := s.virt.Cmd(ctx, append([]string{"config", "set", name}, set...)...).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - } - for _, k := range unset { - _, _ = s.virt.Cmd(ctx, "config", "unset", name, k).CombinedOutput() // unsetting what isn't set is fine - } - if hasDesc { - // the description is a field of the instance, not a config key, so it goes in through the API - patch, _ := json.Marshal(map[string]string{"description": strings.TrimSpace(desc)}) - if out, err := s.virt.Cmd(ctx, "query", "-X", "PATCH", "-d", string(patch), "/1.0/instances/"+name).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - } - log.Printf("virt: config %s set %v unset %v (from %s)", name, set, unset, clientIP(r)) - w.WriteHeader(204) -} - -func (s *Server) virtSnapshotCreate(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body struct { - Name string `json:"name"` - } - _ = readBody(r, &body, 4096) - snap := strings.TrimSpace(body.Name) - if snap == "" { - snap = time.Now().Format("2006-01-02_15.04.05") - } - if !incus.ValidName(name) || !incus.ValidSnap(snap) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, "snapshot", "create", name, snap).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: snapshot %s/%s (from %s)", name, snap, clientIP(r)) - jsonOK(w, 200, map[string]string{"name": snap}) -} - -func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) { - name, snap, action := r.PathValue("name"), r.PathValue("snap"), r.PathValue("action") - if !incus.ValidName(name) || !incus.ValidSnap(snap) { - jsonErr(w, 400, "bad name") - return - } - var args []string - switch action { - case "restore": - args = []string{"snapshot", "restore", name, snap} - case "delete": - args = []string{"snapshot", "delete", name, snap} - default: - jsonErr(w, 400, "unknown action") - return - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: snapshot %s %s/%s (from %s)", action, name, snap, clientIP(r)) - w.WriteHeader(204) -} - -// ---- the graphical console ---- - -// virtVGA bridges the browser to the VM's SPICE display: the browser's websocket on one side, -// Incus's display websocket (dialled over its unix socket) on the other, frames copied both -// ways. spice-html5 in the page speaks SPICE; Ham Lab only carries it. -func (s *Server) virtVGA(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if !incus.ValidName(name) { - jsonErr(w, 400, "bad name") - return - } - // spice-html5 asks for the "binary" subprotocol and gives up unless the server agrees to it - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled, Subprotocols: []string{"binary"}}) - if err != nil { - return - } - conn.SetReadLimit(64 << 20) - ctx, cancel := context.WithCancel(r.Context()) - defer cancel() - display, release, err := s.virt.VGAChannel(ctx, name) - if err != nil { - log.Printf("virt: vga %s refused: %v (from %s)", name, err, clientIP(r)) - conn.Close(websocket.StatusInternalError, truncate(err.Error(), 120)) - return - } - defer release() - defer display.Close(websocket.StatusNormalClosure, "") - go func() { - defer cancel() - for { - _, data, err := display.Read(ctx) - if err != nil { - return - } - if err := conn.Write(ctx, websocket.MessageBinary, data); err != nil { - return - } - } - }() - for { - _, data, err := conn.Read(ctx) - if err != nil { - break - } - if err := display.Write(ctx, websocket.MessageBinary, data); err != nil { - break - } - } - cancel() - conn.Close(websocket.StatusNormalClosure, "") -} - -func truncate(s string, n int) string { - if len(s) > n { - return s[:n] - } - return s -} - -// ---- ISOs ---- - -func (s *Server) isoPath(name string) (string, bool) { - if !incus.ValidISOName(name) { - return "", false - } - return filepath.Join(s.virt.ISOsDir(), name), true -} - -// isoUsers says which instances have each ISO file attached, by its file name. -func (s *Server) isoUsers(list []incus.Instance) map[string][]string { - out := map[string][]string{} - dir := s.virt.ISOsDir() + "/" - for _, in := range list { - for _, d := range in.Devices { - if d["type"] == "disk" && strings.HasPrefix(d["source"], dir) { - n := strings.TrimPrefix(d["source"], dir) - out[n] = append(out[n], in.Name) - } - } - } - return out -} - -// userShell runs a small script as the daemon's own account — file work in Ham Lab's folder -// needs no root — with positional parameters. -func userShell(script string, params ...string) *exec.Cmd { - return exec.Command("sh", append([]string{"-c", script, "hamlab"}, params...)...) -} - -var urlRe = regexp.MustCompile(`^https?://[^\s"'<>]+$`) - -func (s *Server) virtISOFetch(w http.ResponseWriter, r *http.Request) { - var body struct { - URL string `json:"url"` - SHA256 string `json:"sha256"` - Name string `json:"name"` - } - _ = readBody(r, &body, 8192) - u := strings.TrimSpace(body.URL) - if !urlRe.MatchString(u) { - jsonErr(w, 400, "url: http or https") - return - } - name := strings.TrimSpace(body.Name) - if name == "" { - if pu, err := url.Parse(u); err == nil { - name = path.Base(pu.Path) - } - } - dest, ok := s.isoPath(name) - if !ok { - jsonErr(w, 400, "name: a file name ending in .iso") - return - } - sum := strings.ToLower(strings.TrimSpace(body.SHA256)) - if sum != "" && !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sum) { - jsonErr(w, 400, "sha256: 64 hex digits") - return - } - if _, err := os.Stat(dest); err == nil { - jsonErr(w, 409, name+" is already in the library") - return - } - if err := s.virt.EnsureDirs(); err != nil { - jsonErr(w, 500, err.Error()) - return - } - script := `set -e; echo "fetching $1"; echo " to $2"; curl -fL --retry 3 -sS -o "$2.part" "$1"; if [ -n "$3" ]; then echo "$3 $2.part" | sha256sum -c - || { rm -f "$2.part"; exit 1; }; fi; mv "$2.part" "$2"; ls -l "$2" | awk '{print " " $5 " bytes"}'` - t, err := s.tasks.Start("virt:iso", "fetch "+name, userShell(script, u, dest, sum)) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: iso fetch %s as task %s (from %s)", u, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtISOImport(w http.ResponseWriter, r *http.Request) { - var body struct { - Path string `json:"path"` - } - _ = readBody(r, &body, 8192) - p := filepath.Clean(body.Path) - st, err := os.Stat(p) - if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() { - jsonErr(w, 400, "path: a file on this machine") - return - } - dest, ok := s.isoPath(filepath.Base(p)) - if !ok { - jsonErr(w, 400, "the file has to be a .iso or .img") - return - } - if _, err := os.Stat(dest); err == nil { - jsonErr(w, 409, filepath.Base(p)+" is already in the library") - return - } - if err := s.virt.EnsureDirs(); err != nil { - jsonErr(w, 500, err.Error()) - return - } - script := `set -e; echo "copying $1"; echo " to $2"; cp --reflink=auto "$1" "$2.part" && mv "$2.part" "$2"; ls -l "$2" | awk '{print " " $5 " bytes"}'` - cmd := userShell(script, p, dest) - if f, err := os.Open(p); err != nil && priv.Available() { // readable only by root: copy as root and hand it over - argv := priv.Wrap("sh", "-c", script+`; chown "$3" "$2"`, "hamlab", p, dest, s.user) - cmd = exec.Command(argv[0], argv[1:]...) - } else if err == nil { - f.Close() - } - t, err := s.tasks.Start("virt:iso", "import "+filepath.Base(p), cmd) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: iso import %s as task %s (from %s)", p, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtISODelete(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - } - _ = readBody(r, &body, 4096) - p, ok := s.isoPath(body.Name) - if !ok { - jsonErr(w, 400, "bad name") - return - } - if list, err := s.virt.Instances(r.Context()); err == nil { - if users := s.isoUsers(list)[body.Name]; len(users) > 0 { - jsonErr(w, 409, "attached to "+strings.Join(users, ", ")+" — detach it first") - return - } - } - if err := os.Remove(p); err != nil { - jsonErr(w, 404, "no such ISO") - return - } - log.Printf("virt: iso %s deleted (from %s)", body.Name, clientIP(r)) - w.WriteHeader(204) -} - -func (s *Server) virtISORename(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - To string `json:"to"` - } - _ = readBody(r, &body, 4096) - from, ok1 := s.isoPath(body.Name) - to, ok2 := s.isoPath(body.To) - if !ok1 || !ok2 { - jsonErr(w, 400, "bad name") - return - } - if list, err := s.virt.Instances(r.Context()); err == nil { - if users := s.isoUsers(list)[body.Name]; len(users) > 0 { - jsonErr(w, 409, "attached to "+strings.Join(users, ", ")+" — detach it first") - return - } - } - if _, err := os.Stat(to); err == nil { - jsonErr(w, 409, body.To+" already exists") - return - } - if err := os.Rename(from, to); err != nil { - jsonErr(w, 404, "no such ISO") - return - } - w.WriteHeader(204) -} - -func (s *Server) instanceByName(ctx context.Context, name string) (*incus.Instance, error) { - list, err := s.virt.Instances(ctx) - if err != nil { - return nil, err - } - for i := range list { - if list[i].Name == name { - return &list[i], nil - } - } - return nil, fmt.Errorf("no instance called %s", name) -} - -// virtISOAttach puts an ISO in a VM's drive — live or stopped, Incus takes either — as a ROM -// device, first in the boot order when asked. -func (s *Server) virtISOAttach(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body struct { - ISO string `json:"iso"` - BootFirst bool `json:"boot_first"` - } - _ = readBody(r, &body, 4096) - p, ok := s.isoPath(body.ISO) - if !incus.ValidName(name) || !ok { - jsonErr(w, 400, "bad name") - return - } - if _, err := os.Stat(p); err != nil { - jsonErr(w, 404, "no such ISO in the library") - return - } - if !s.virtReady(w, r) { - return - } - in, err := s.instanceByName(r.Context(), name) - if err != nil { - jsonErr(w, 404, err.Error()) - return - } - if in.Type != "virtual-machine" { - jsonErr(w, 409, "only a virtual machine takes an ISO") - return - } - dev := "" - for i := 0; i < 10; i++ { - if _, taken := in.Devices[fmt.Sprintf("iso%d", i)]; !taken { - dev = fmt.Sprintf("iso%d", i) - break - } - } - if dev == "" { - jsonErr(w, 409, "ten ISOs are already attached") - return - } - args := []string{"config", "device", "add", name, dev, "disk", "source=" + p} - if body.BootFirst { - args = append(args, "boot.priority=10") - } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: %s attached to %s as %s (from %s)", body.ISO, name, dev, clientIP(r)) - jsonOK(w, 200, map[string]string{"device": dev}) -} - -func (s *Server) virtISODetach(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body struct { - Device string `json:"device"` - } - _ = readBody(r, &body, 4096) - if !incus.ValidName(name) || !incus.ValidDevice(body.Device) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - in, err := s.instanceByName(r.Context(), name) - if err != nil { - jsonErr(w, 404, err.Error()) - return - } - d, ok := in.Devices[body.Device] - if !ok || d["type"] != "disk" || !strings.HasPrefix(d["source"], s.virt.ISOsDir()+"/") { - jsonErr(w, 409, body.Device+" isn't an ISO from the library") - return - } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, "config", "device", "remove", name, body.Device).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: %s detached from %s (from %s)", body.Device, name, clientIP(r)) - w.WriteHeader(204) -} - -func (s *Server) virtFromISO(w http.ResponseWriter, r *http.Request) { - var sp incus.FromISOSpec - if err := readBody(r, &sp, 16<<10); err != nil { - jsonErr(w, 400, "bad request") - return - } - st := s.virt.Detect(r.Context()) - if st.State != "ready" { - jsonErr(w, 409, "Incus isn't ready on this machine") - return - } - script, params, err := incus.FromISOScript(sp, st.KVM) - if err != nil { - jsonErr(w, 400, err.Error()) - return - } - p, _ := s.isoPath(sp.ISO) - if _, err := os.Stat(p); err != nil { - jsonErr(w, 404, "no such ISO in the library") - return - } - params[1] = p - if _, err := s.instanceByName(r.Context(), sp.Name); err == nil { - jsonErr(w, 409, "there is already an instance called "+sp.Name) - return - } - t, err := s.tasks.Start("virt:create", "create VM "+sp.Name+" from "+sp.ISO, s.virt.ShellCmd(r.Context(), script, params...)) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: VM %s from %s as task %s (from %s)", sp.Name, sp.ISO, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -// ---- mounts: disk devices other than root ---- - -func (s *Server) virtMountAdd(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body struct { - Device string `json:"device"` - Source string `json:"source"` - Path string `json:"path"` - ReadOnly bool `json:"readonly"` - Shift bool `json:"shift"` - } - _ = readBody(r, &body, 8192) - src, dst := filepath.Clean(body.Source), filepath.Clean(body.Path) - dev := strings.TrimSpace(body.Device) - if dev == "" { - dev = "mnt-" + sensors.Seg(filepath.Base(src)) - } - switch { - case !incus.ValidName(name): - jsonErr(w, 400, "bad name") - return - case !incus.ValidDevice(dev): - jsonErr(w, 400, "device: letters, digits, dashes") - return - case !filepath.IsAbs(src) || !filepath.IsAbs(dst) || dst == "/": - jsonErr(w, 400, "source and path: absolute paths; path not /") - return - } - if _, err := os.Stat(src); err != nil && os.IsNotExist(err) { - jsonErr(w, 404, src+" doesn't exist on this machine") - return - } - if !s.virtReady(w, r) { - return - } - in, err := s.instanceByName(r.Context(), name) - if err != nil { - jsonErr(w, 404, err.Error()) - return - } - if _, taken := in.Devices[dev]; taken { - jsonErr(w, 409, "there is already a device called "+dev) - return - } - args := []string{"config", "device", "add", name, dev, "disk", "source=" + src, "path=" + dst} - if body.ReadOnly { - args = append(args, "readonly=true") - } - if body.Shift && in.Type == "container" { - args = append(args, "shift=true") - } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: %s: mounted %s at %s as %s (from %s)", name, src, dst, dev, clientIP(r)) - jsonOK(w, 200, map[string]string{"device": dev}) -} - -func (s *Server) virtMountRemove(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var body struct { - Device string `json:"device"` - } - _ = readBody(r, &body, 4096) - if !incus.ValidName(name) || !incus.ValidDevice(body.Device) || body.Device == "root" { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - in, err := s.instanceByName(r.Context(), name) - if err != nil { - jsonErr(w, 404, err.Error()) - return - } - if d, ok := in.Devices[body.Device]; !ok || d["type"] != "disk" { - jsonErr(w, 409, body.Device+" isn't a disk device of "+name) - return - } - ctx, cancel := context.WithTimeout(r.Context(), time.Minute) - defer cancel() - if out, err := s.virt.Cmd(ctx, "config", "device", "remove", name, body.Device).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: %s: device %s removed (from %s)", name, body.Device, clientIP(r)) - w.WriteHeader(204) -} - -// ---- exports ---- - -func (s *Server) virtExports(w http.ResponseWriter, r *http.Request) { - c := s.virt.ExportConfig() - out := map[string]any{"config": c, "cron": c.CronExpr(), "last": s.virt.LastExportRun(), "files": s.virt.ExportFiles(), "dir": s.virt.ExportsDir(), "cron_file": incus.CronFile} - if c.Enabled { - out["next"] = c.NextRun(time.Now()) - } - jsonOK(w, 200, out) -} - -func (s *Server) virtExportsPut(w http.ResponseWriter, r *http.Request) { - var c incus.ExportConfig - if err := readBody(r, &c, 16<<10); err != nil { - jsonErr(w, 400, "bad request") - return - } - if err := s.virt.SaveExportConfig(c); err != nil { - jsonErr(w, 400, err.Error()) - return - } - exe, err := os.Executable() - if err == nil { - exe, _ = filepath.EvalSymlinks(exe) - } - if c.Enabled { - err = s.storage.WriteRootFile(r.Context(), incus.CronFile, c.CronText(s.user, exe, s.cfg.Path())) - } else { - err = s.storage.RemoveRootFile(r.Context(), incus.CronFile) - } - if err != nil { - jsonErr(w, 500, "schedule saved, but the cron file could not be written: "+err.Error()) - return - } - log.Printf("virt: exports %v at %02d:%02d keep %d (from %s)", c.Enabled, c.Hour, c.Minute, c.Keep, clientIP(r)) - w.WriteHeader(204) -} - -func (s *Server) virtExportsRun(w http.ResponseWriter, r *http.Request) { - var body struct { - Instance string `json:"instance"` - } - _ = readBody(r, &body, 4096) - if body.Instance != "" && !incus.ValidName(body.Instance) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - exe, err := os.Executable() - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - args := []string{"-config", s.cfg.Path(), "virt-export"} - title := "export every instance" - if body.Instance != "" { - args = append(args, body.Instance) - title = "export " + body.Instance - } - t, err := s.tasks.Start("virt:export", title, exec.Command(exe, args...)) - if err != nil { - jsonErr(w, 409, err.Error()) - return - } - log.Printf("virt: %s as task %s (from %s)", title, t.ID, clientIP(r)) - jsonOK(w, 202, t.Snapshot(false)) -} - -func (s *Server) virtExportDelete(w http.ResponseWriter, r *http.Request) { - var body struct { - Instance string `json:"instance"` - Name string `json:"name"` - } - _ = readBody(r, &body, 4096) - if !incus.ValidName(body.Instance) || body.Name != filepath.Base(body.Name) || !strings.HasSuffix(body.Name, ".tar.gz") { - jsonErr(w, 400, "bad name") - return - } - if err := os.Remove(filepath.Join(s.virt.ExportsDir(), body.Instance, body.Name)); err != nil { - jsonErr(w, 404, "no such export") - return - } - log.Printf("virt: export %s/%s deleted (from %s)", body.Instance, body.Name, clientIP(r)) - w.WriteHeader(204) -} - -// virtSSHKeys is the daemon account's public keys, for a cloud-init that lets him straight in. -func (s *Server) virtSSHKeys(w http.ResponseWriter, _ *http.Request) { - home, _ := os.UserHomeDir() - seen := map[string]bool{} - keys := []string{} - add := func(line string) { - f := strings.Fields(line) - if len(f) < 2 || !strings.HasPrefix(f[0], "ssh-") && !strings.HasPrefix(f[0], "ecdsa-") && !strings.HasPrefix(f[0], "sk-") { - return - } - k := f[0] + " " + f[1] - if seen[k] { - return - } - seen[k] = true - keys = append(keys, strings.TrimSpace(line)) - } - if b, err := os.ReadFile(filepath.Join(home, ".ssh", "authorized_keys")); err == nil { - for _, l := range strings.Split(string(b), "\n") { - add(l) - } - } - pubs, _ := filepath.Glob(filepath.Join(home, ".ssh", "*.pub")) - for _, p := range pubs { - if b, err := os.ReadFile(p); err == nil { - add(string(b)) - } - } - jsonOK(w, 200, map[string]any{"keys": keys, "user": s.user}) -} - -// ---- locations: storage pools, added as plain-files pools at a path of his choosing ---- - -func (s *Server) virtPoolCreate(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - Path string `json:"path"` - } - _ = readBody(r, &body, 8192) - p := filepath.Clean(body.Path) - name := strings.TrimSpace(body.Name) - if name == "" { - name = sensors.Seg(filepath.Base(p)) - } - if !incus.ValidName(name) { - jsonErr(w, 400, "name: letters, digits and dashes, starting with a letter") - return - } - if !filepath.IsAbs(p) || p == "/" { - jsonErr(w, 400, "path: an absolute path to a folder on this machine") - return - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) - defer cancel() - if st, err := os.Stat(p); err == nil && !st.IsDir() { - jsonErr(w, 400, p+" is a file, not a folder") - return - } else if err != nil { - mk := priv.Wrap("mkdir", "-p", p) // Incus wants the folder there; it fills it as root - if out, err := exec.CommandContext(ctx, mk[0], mk[1:]...).CombinedOutput(); err != nil { - jsonErr(w, 500, "could not make "+p+": "+strings.TrimSpace(string(out))) - return - } - } - if out, err := s.virt.Cmd(ctx, "storage", "create", name, "dir", "source="+p).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: location %s added at %s (from %s)", name, p, clientIP(r)) - jsonOK(w, 200, map[string]string{"name": name, "path": p}) -} - -func (s *Server) virtPoolDelete(w http.ResponseWriter, r *http.Request) { - var body struct { - Name string `json:"name"` - } - _ = readBody(r, &body, 4096) - if !incus.ValidName(body.Name) { - jsonErr(w, 400, "bad name") - return - } - if !s.virtReady(w, r) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) - defer cancel() - // Incus refuses a pool anything still uses; that refusal is the answer - if out, err := s.virt.Cmd(ctx, "storage", "delete", body.Name).CombinedOutput(); err != nil { - jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: ")) - return - } - log.Printf("virt: location %s removed (from %s)", body.Name, clientIP(r)) - w.WriteHeader(204) -} diff --git a/internal/incus/export.go b/internal/incus/export.go deleted file mode 100644 index d1e300f..0000000 --- a/internal/incus/export.go +++ /dev/null @@ -1,232 +0,0 @@ -package incus - -// Nightly exports: every instance as a tarball in virt/exports//, 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 -} diff --git a/internal/incus/incus.go b/internal/incus/incus.go deleted file mode 100644 index 7050058..0000000 --- a/internal/incus/incus.go +++ /dev/null @@ -1,1212 +0,0 @@ -// 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 // /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 </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 -} diff --git a/internal/incus/install_test.go b/internal/incus/install_test.go deleted file mode 100644 index f12c98e..0000000 --- a/internal/incus/install_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/incus/subnet.go b/internal/incus/subnet.go deleted file mode 100644 index 03812bd..0000000 --- a/internal/incus/subnet.go +++ /dev/null @@ -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"} -} diff --git a/internal/incus/subnet_test.go b/internal/incus/subnet_test.go deleted file mode 100644 index 1b62375..0000000 --- a/internal/incus/subnet_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/incus/vga.go b/internal/incus/vga.go deleted file mode 100644 index 760d1fd..0000000 --- a/internal/incus/vga.go +++ /dev/null @@ -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 -} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index a60605b..82915d5 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -15,7 +15,6 @@ import Hardware from './Hardware' import Network from './Network' import VPN from './VPN' import ZFS from './ZFS' -import Virtualization from './Virtualization' import KVM from './KVM' import Files from './Files' import Top from './Top' @@ -82,7 +81,6 @@ const GROUPS: Group[] = [ { id: 'usage', label: 'Usage', icon: '▧' }, { id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'docker', label: 'Docker', icon: DockerIcon }, - { id: 'virt', label: 'Virtualization', icon: '▣' }, { id: 'kvm', label: 'KVM', icon: '⌨' }, { id: 'hardware', label: 'Hardware', icon: '⬡' }, { id: 'updates', label: 'Updates', icon: '⇪' }, @@ -93,8 +91,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' -const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] +type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' +const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and @@ -331,7 +329,6 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'machines' && { switchMachine(n); setView('procs') }} />} {view === 'widgets' && } {view === 'docker' && } - {view === 'virt' && } {view === 'alerts' && m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'procs' && } {view === 'services' && } @@ -369,7 +366,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'virt' ? 'Virtualization' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/VgaConsole.tsx b/web/src/VgaConsole.tsx deleted file mode 100644 index ec906e6..0000000 --- a/web/src/VgaConsole.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// The graphical console of a VM: spice-html5 (vendored, loaded on demand) drawing the SPICE -// stream that Ham Lab bridges from Incus into a canvas. Used for installers from an ISO, and -// anything else with a screen rather than a serial line. -import { useEffect, useRef, useState } from 'react' -import { wsURLPath } from './api' -import { Btn, useEscape } from './ui' -// No spice.css: upstream's stylesheet is for its own demo page and sets body, *, button and -// canvas for the whole document. Imported here it restyled every button in Ham Lab. The -// console is styled by the .vga rules in styles.css. - -type Spice = typeof import('./vendor/spice-html5/src/main.js') - -export default function VgaConsole({ name, onClose }: { name: string; onClose: () => void }) { - const [state, setState] = useState<'loading' | 'connecting' | 'connected' | 'error'>('loading') - const [err, setErr] = useState('') - const [fit, setFit] = useState(true) - const [size, setSize] = useState('') - const conn = useRef | null>(null) - const mod = useRef(null) - const area = useRef(null) - const screen = useRef(null) - useEscape(onClose) - - useEffect(() => { - let dead = false - import('./vendor/spice-html5/src/main.js').then((m) => { - if (dead) return - mod.current = m - setState('connecting') - try { - conn.current = new m.SpiceMainConn({ - uri: wsURLPath(`/api/virt/instances/${encodeURIComponent(name)}/vga`), - screen_id: 'spice-screen', message_id: 'spice-message', - onerror: (e) => { setState('error'); setErr(e instanceof Error ? e.message : (e as Event & { reason?: string })?.reason || 'the console closed') }, - onsuccess: () => setState('connected'), - onagent: () => { try { m.handle_resize() } catch { /* no agent in the guest yet */ } }, - }) - } catch (e) { setState('error'); setErr(String(e)) } - }).catch((e) => { setState('error'); setErr('could not load the SPICE viewer: ' + String(e)) }) - return () => { dead = true; try { conn.current?.stop() } catch { /* already gone */ } conn.current = null } - }, [name]) - - // The guest draws at its own resolution; scale the canvas to the pane unless asked not to. - useEffect(() => { - const a = area.current, s = screen.current - if (!a || !s) return - const apply = () => { - const c = s.querySelector('canvas') - if (!c) return - setSize(`${c.width}×${c.height}`) - c.tabIndex = 1 - if (!fit) { s.style.transform = ''; s.style.width = ''; s.style.height = ''; return } - const k = Math.min(1, (a.clientWidth - 8) / c.width, (a.clientHeight - 8) / c.height) - s.style.transformOrigin = 'top left' - s.style.transform = `scale(${k})` - s.style.width = `${c.width}px`; s.style.height = `${c.height}px` - } - apply() - const ro = new ResizeObserver(apply); ro.observe(a) - const mo = new MutationObserver(apply); mo.observe(s, { childList: true, attributes: true, subtree: true, attributeFilter: ['width', 'height'] }) - return () => { ro.disconnect(); mo.disconnect() } - }, [fit, state]) - - const cad = () => { if (conn.current && mod.current) mod.current.sendCtrlAltDel(conn.current) } - const focus = () => screen.current?.querySelector('canvas')?.focus() - return ( -
-
e.stopPropagation()}> -
- - {name} - {state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'} - - Ctrl+Alt+Del - - -
-
-
-
-
-
Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.
-
-
- ) -} diff --git a/web/src/Virtualization.tsx b/web/src/Virtualization.tsx deleted file mode 100644 index 430d53b..0000000 --- a/web/src/Virtualization.tsx +++ /dev/null @@ -1,715 +0,0 @@ -// Virtualization: LXC containers and KVM virtual machines through Incus, per machine. The -// page has four states — ready, repair, installable, unavailable — decided by what the -// selected machine can do, never by a rule about what should run where. Everything the -// tab makes lives under Ham Lab's own folder on that machine. -import { FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react' -import { api, ExportsView, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api' -import VgaConsole from './VgaConsole' -import TaskOutput from './TaskOutput' -import { useHashSel } from './hashsel' -import { useRefreshMs } from './prefs' -import { fmtBytes } from './fmt' -import { askText, askYes, Btn, CloseBtn, Loading, toast, useEscape } from './ui' - -type Tab = 'instances' | 'images' | 'library' -const GiB = 1 << 30 -const dot = (i: VirtInstance) => (i.status === 'Running' ? 'ok' : i.status === 'Stopped' ? '' : i.status === 'Error' ? 'bad' : 'warn') -const kind = (i: VirtInstance) => (i.type === 'virtual-machine' ? 'VM' : 'LXC') -const when = (iso?: string) => { if (!iso) return '—'; const d = new Date(iso); return isNaN(d.getTime()) || d.getFullYear() < 1980 ? '—' : d.toLocaleString() } -const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'failed') -const imgLabel = (im: VirtImage) => im.aliases[0] ?? im.fingerprint.slice(0, 12) - -export default function Virtualization() { - const [ov, setOv] = useState(null) - const [err, setErr] = useState('') - const [tab, setTab] = useState('instances') - const [sel, setSel] = useHashSel('virt') - const [making, setMaking] = useState(null) - const [fromIso, setFromIso] = useState(null) - const [task, setTask] = useState<{ id: string; title: string } | null>(null) - const [filter, setFilter] = useState('') - const rate = useRefreshMs() - const load = useCallback(async () => { try { setOv(await api.virt()); setErr('') } catch (e) { setErr(errMsg(e)) } }, []) - useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) - const startTask = (t: TaskInfo) => setTask({ id: t.id, title: t.title }) - - if (!ov) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Virtualization tab. Update it from Machines." : err}
: }
- const st = ov.status - if (st.state !== 'ready') return - const instances = ov.instances ?? [] - const images = ov.images ?? [] - const cur = instances.find((i) => i.name === sel) - const running = instances.filter((i) => i.status === 'Running').length - const f = filter.trim().toLowerCase() - const shown = instances.filter((i) => !f || `${i.name} ${i.image} ${i.type} ${i.status}`.toLowerCase().includes(f)) - const libCount = ov.library.images.length + ov.library.isos.length - return ( -
-
-
-
- {(['instances', 'images', 'library'] as Tab[]).map((t) => ( - - ))} -
- {tab === 'instances' && ( -
- - - {st.kvm ? 'containers and VMs' : 'containers only — no KVM on this machine'} -
- )} - {tab === 'instances' && instances.length > 3 && setFilter(e.target.value)} placeholder="filter instances" />} - {ov.error &&
{ov.error}
} - {task && { load(); if (t.exit_code === 0 && !t.error) toast(`${task.title}: done`); else toast(`${task.title}: failed`, 'err') }} />} - {task &&
} -
- {tab === 'instances' && ( -
- {shown.map((i) => ( -
setSel(i.name)}> - -
{i.name} {kind(i)}
{i.image || '—'}{i.ipv4.length ? ` · ${i.ipv4.join(' ')}` : ''}
-
{i.status === 'Running' ? <>{fmtBytes(i.mem_usage)}
{i.cpu_pct.toFixed(0)}% : i.status.toLowerCase()}
-
- ))} - {instances.length === 0 &&
Nothing here yet. “+ New instance” makes a container{st.kvm ? ' or a VM' : ''} from the catalogue.
} -
- )} - {tab === 'images' && { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />} - {tab === 'library' && setFromIso(iso)} />} -
- {tab === 'instances' && (cur ? setSel('')} onChanged={load} onTask={startTask} /> : )} - {making && setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />} - {fromIso !== null && setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />} -
- ) -} - -// ---- the three states before "ready" ---- - -function Gate({ st, onChanged }: { st: VirtStatus; onChanged: () => void }) { - const [open, setOpen] = useState(false) - const [task, setTask] = useState('') - const [waiting, setWaiting] = useState(false) - // the install ends by restarting Ham Lab, so poll until it answers again - useEffect(() => { - if (!waiting) return - const t = setInterval(async () => { try { const o = await api.virt(); if (o.status.state === 'ready') { setWaiting(false); onChanged() } } catch { /* restarting */ } }, 3000) - return () => clearInterval(t) - }, [waiting, onChanged]) - const headline = st.state === 'installable' - ? `Incus isn't installed on this machine. Ham Lab can install it with ${st.pm}: ${st.kvm ? 'containers and virtual machines' : 'containers only, since this kernel has no KVM'}.` - : st.reason - return ( -
-

Virtualization

-
-
{headline}
-
-
KVM{st.kvm ? 'yes — virtual machines possible' : 'no — containers only'}
-
Architecture{st.arch}
- {st.pm &&
Packages{st.pm}
} -
Pool drivers{st.drivers.join(', ')}
-
Memory{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}
-
Disk{fmtBytes(st.disk_free)} free where Ham Lab's data lives
-
Docker{st.docker ? 'present — its FORWARD policy needs two rules, Install adds them' : 'not here'}
-
Data{st.dir}
- {st.layout &&
Last attempt{when(st.layout.attempted)} · {st.layout.driver} pool · {st.layout.subnet}
} -
- {(st.state === 'installable' || st.state === 'repair') && !task && ( -
setOpen(true)}>{st.state === 'repair' ? 'Finish setup' : 'Install Incus'}
- )} - {task && { if (t.exit_code === 0 && !t.error) setWaiting(true); else { toast('install failed — the output says where', 'err'); setTask('') } }} />} - {waiting &&
Ham Lab is restarting to pick up its new group. The page reconnects by itself.
} -
- {open && setOpen(false)} onStarted={(id) => { setOpen(false); setTask(id) }} />} -
- ) -} - -function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: () => void; onStarted: (id: string) => void }) { - const [driver, setDriver] = useState(st.layout?.driver ?? st.drivers[0] ?? 'dir') - const [size, setSize] = useState(String(st.layout?.pool_size_gib || st.pool_size_gib || 20)) - const [subnet, setSubnet] = useState(st.layout?.subnet || st.subnet || '172.16.100.1/24') - const [docker, setDocker] = useState(st.docker) - const [arc, setArc] = useState(st.layout?.arc_max_gib ? String(st.layout.arc_max_gib) : '') - const [err, setErr] = useState('') - const [busy, setBusy] = useState(false) - useEscape(onClose) - const submit = async (e: FormEvent) => { - e.preventDefault(); setErr(''); setBusy(true) - try { const t = await api.virtInstall({ driver, pool_size_gib: Number(size) || 0, subnet: subnet.trim(), docker_rules: docker, arc_max_gib: Number(arc) || 0 }); onStarted(t.id) } - catch (e) { setErr(errMsg(e)); setBusy(false) } - } - return ( -
-
e.stopPropagation()} onSubmit={submit}> -

{st.state === 'repair' ? 'Finish setting up Incus' : 'Install Incus'}

-
Everything goes under {st.dir}: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.
- - {driver !== 'dir' && ( - - )} - - {st.docker && ( - - )} - {st.zfs_host && ( - - )} -
Ham Lab's account ({st.user}) joins the incus-admin group and Ham Lab restarts at the end.
- {err &&
{err}
} -
- - - -
-
-
- ) -} - -// ---- the right-hand pane when nothing is selected: what this machine's Incus is ---- - -// A location is a plain-files storage pool at a folder of his choosing. -async function addLocation(onDone: (name: string) => void) { - const path = await askText('Folder on this machine for instance disks', '/', 'Next') - if (!path) return - const base = path.split('/').filter(Boolean).pop() ?? 'disks' - const name = await askText(`A name for ${path}`, base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, ''), 'Add') - if (!name) return - try { const r = await api.virtPoolCreate(name, path); toast(`${r.name} added at ${r.path}`); onDone(r.name) } catch (e) { toast(errMsg(e), 'err') } -} - -function Summary({ ov, onChanged }: { ov: VirtOverview; onChanged: () => void }) { - const st = ov.status - return ( -
-
Incus {st.version}{st.arch}{!st.kvm && no KVM}
-
-
Data{st.dir}
-
Memory{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}
- {st.layout &&
Installed{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}
} - {!st.layout &&
Installedoutside Ham Lab — pools and networks below are as found
} -
-

Disk locations · where an instance\u2019s disk can go

- - {(ov.pools ?? []).map((p) => - )}
LocationWhereUsedFreeDisks
{p.name}{p.name === ov.default_pool && default}{p.where}
{p.driver} · {p.source}
{p.total ? fmtBytes(p.used) : '—'}{p.total ? fmtBytes(p.total - p.used) : '—'}{p.used_by}{p.used_by === 0 && p.name !== ov.default_pool && { try { await api.virtPoolDelete(p.name); toast('removed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }}>Remove}
-
addLocation(onChanged)}>+ Add a location…a folder on this machine; VM disks become files in it, containers folders
-

Networks

- - {(ov.networks ?? []).filter((n) => n.managed).map((n) => )}
NetworkTypeAddressNATUsers
{n.name}{n.type}{n.address ?? '—'}{n.nat ? 'yes' : 'no'}{n.used_by}
-
Pick an instance on the left, or make one. Consoles open as tabs on the Terminal page.
-
- ) -} - -// ---- one instance ---- - -function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: VirtStatus; onBack: () => void; onChanged: () => void; onTask: (t: TaskInfo) => void }) { - const [tab, setTab] = useState<'overview' | 'snapshots' | 'mounts' | 'config'>('overview') - const [vga, setVga] = useState(false) - const running = i.status === 'Running' - const isVM = i.type === 'virtual-machine' - const stopped = i.status === 'Stopped' - const act = (a: 'start' | 'stop' | 'restart' | 'kill') => async () => { try { await api.virtAction(i.name, a); toast(`${i.name}: ${a} ok`); onChanged() } catch (e) { toast(`${i.name}: ${errMsg(e)}`, 'err') } } - const term = (k: 'console' | 'exec') => async () => { - try { const t = await api.virtTerminal(i.name, k); toast(`${k === 'exec' ? 'shell' : 'console'} open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) } - catch (e) { toast(errMsg(e), 'err') } - } - const rename = async () => { const to = await askText(`Rename ${i.name} to`, i.name, 'Rename'); if (!to || to === i.name) return; try { await api.virtRename(i.name, to); toast('renamed'); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } } - const del = async () => { - const typed = await askText(`Delete ${i.name}${running ? ', which is running' : ''}? Its disk and ${i.snapshots.length} snapshot${i.snapshots.length === 1 ? '' : 's'} go with it. Type its name to confirm.`, '', 'Delete') - if (typed === null) return - if (typed !== i.name) { toast('name did not match', 'err'); return } - try { await api.virtDelete(i.name, running); toast(`${i.name} deleted`); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } - } - const publish = async () => { - const alias = await askText(`Publish ${i.name} as an image — an alias for it`, `${i.name}-image`, 'Publish') - if (!alias) return - try { onTask(await api.virtImagePublish(i.name, alias)) } catch (e) { toast(errMsg(e), 'err') } - } - const snap = async () => { - const name = await askText(`Snapshot ${i.name} — a name, or blank for the date`, '', 'Snapshot') - if (name === null) return - try { const r = await api.virtSnapshot(i.name, name); toast(`snapshot ${r.name} taken`); onChanged() } catch (e) { toast(errMsg(e), 'err') } - } - return ( -
- -
{i.name}{kind(i)}{i.status.toLowerCase()}
-
-
Image{i.image || (Object.keys(i.devices).some((d) => d.startsWith('iso')) ? 'none — installed from an ISO' : 'none')}{i.arch ? · {i.arch} : ''}
- {i.description &&
About{i.description}
} - {running &&
Using{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes > 0 ? ` · ${i.processes} processes` : ''}
} -
Limits{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}
-
Disk{i.disk_total > 0 ? `${fmtBytes(i.disk_usage)} of ${fmtBytes(i.disk_total)}` : i.disk_usage > 0 ? fmtBytes(i.disk_usage) : 'no limit'}{i.devices.root?.pool ? · on {i.devices.root.pool} : ''}
-
Network{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}
-
Created{when(i.created)}
-
-
- {!running && Start} - {running && Shell} - Console - {isVM && running && setVga(true)} title="the VM's screen, keyboard and mouse — what an installer needs">VGA} - {running && Restart} - {running && Stop} - {running && Force stop} - Snapshot - { try { onTask(await api.virtExportsRun(i.name)) } catch (e) { toast(errMsg(e), 'err') } }} title="a tarball of it in the library's exports folder">Export - {stopped && Rename} - {stopped && Publish} - Delete… -
-
- - - - -
- {tab === 'mounts' && } - {vga && setVga(false)} />} - {tab === 'overview' && } - {tab === 'snapshots' && } - {tab === 'config' && ( - <> -
{Object.entries(i.config).sort().map(([k, v]) => {k}{v})}
-

Devices

-
{Object.entries(i.devices).sort().map(([d, cfg]) => {d}{Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join(' ')})}
-
Profiles: {i.profiles.join(', ') || '—'}
- - )} -
- ) -} - -function Limits({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) { - const [cpu, setCpu] = useState(i.cpu ?? '') - const [mem, setMem] = useState(i.memory ?? '') - const [auto, setAuto] = useState(i.config['boot.autostart'] ?? '') - const [desc, setDesc] = useState(i.description ?? '') - useEffect(() => { setCpu(i.cpu ?? ''); setMem(i.memory ?? ''); setAuto(i.config['boot.autostart'] ?? ''); setDesc(i.description ?? '') }, [i.name, i.cpu, i.memory, i.description, i.config]) - const changed = cpu !== (i.cpu ?? '') || mem !== (i.memory ?? '') || auto !== (i.config['boot.autostart'] ?? '') || desc !== (i.description ?? '') - const save = async () => { try { await api.virtConfig(i.name, { 'limits.cpu': cpu, 'limits.memory': mem, 'boot.autostart': auto, description: desc }); toast('saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } } - return ( -
-
- - -
-
- - -
-
{i.type === 'container' ? 'Containers take new limits live.' : 'A VM takes CPU and memory changes live where its agent is running, otherwise on its next start.'} {fmtBytes(st.mem_avail)} is free on this machine right now.
-
Save
-
- ) -} - -function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void }) { - const [sched, setSched] = useState(i.schedule ?? '') - const [exp, setExp] = useState(i.expiry ?? '') - useEffect(() => { setSched(i.schedule ?? ''); setExp(i.expiry ?? '') }, [i.name, i.schedule, i.expiry]) - const saveSched = async () => { try { await api.virtConfig(i.name, { 'snapshots.schedule': sched.trim(), 'snapshots.expiry': exp.trim() }); toast('schedule saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } } - const on = async (s: string, a: 'restore' | 'delete') => { - const ok = await askYes(a === 'restore' ? `Restore ${i.name} to snapshot ${s}? Everything since it is lost${i.status === 'Running' ? '; it is stopped first' : ''}.` : `Delete snapshot ${s} of ${i.name}?`, a === 'restore' ? 'Restore' : 'Delete') - if (!ok) return - try { await api.virtSnapshotAction(i.name, s, a); toast(`${a} ${s}: ok`); onChanged() } catch (e) { toast(errMsg(e), 'err') } - } - return ( - <> - {i.snapshots.length === 0 &&
No snapshots. “Snapshot” above takes one now; a schedule below keeps taking them.
} - {i.snapshots.length > 0 && ( - - {[...i.snapshots].sort((a, b) => b.created.localeCompare(a.created)).map((s) => ( - - - ))}
SnapshotTakenExpiresSize
{s.name}{when(s.created)}{s.expires ? when(s.expires) : 'never'}{s.size ? fmtBytes(s.size) : '—'} on(s.name, 'restore')}>Restore on(s.name, 'delete')}>Delete
- )} -
-
- - -
-
Incus takes and expires these itself, so they carry on whether or not this console is up.
-
Save schedule
-
- - ) -} - -// ---- making one ---- - -// Where an instance's disk goes. One entry is still a choice shown; a new one can be added here. -function PoolPick({ ov, value, onChange }: { ov: VirtOverview; value: string; onChange: (v: string) => void }) { - const [extra, setExtra] = useState<{ name: string; where: string }[]>([]) - const pools = [...(ov.pools ?? []).map((p) => ({ name: p.name, where: p.where + (p.total ? ` · ${fmtBytes(p.total - p.used)} free` : '') })), ...extra.filter((e) => !(ov.pools ?? []).some((p) => p.name === e.name))] - return ( - - ) -} - -function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) { - const [vm, setVm] = useState(!!preset.vm && st.kvm) - const [image, setImage] = useState(preset.image ?? '') - const [name, setName] = useState('') - const [cpu, setCpu] = useState('') - const [mem, setMem] = useState('') - const [disk, setDisk] = useState('') - const [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '') - const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge') - const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '') - const [cloud, setCloud] = useState('') - const [showCloud, setShowCloud] = useState(false) - const [desc, setDesc] = useState('') - const [start, setStart] = useState(true) - const [q, setQ] = useState('') - const [remote, setRemote] = useState(null) - const [remoteErr, setRemoteErr] = useState('') - const [err, setErr] = useState('') - const [busy, setBusy] = useState(false) - const type = vm ? 'virtual-machine' : 'container' - useEscape(onClose) - useEffect(() => { setRemote(null); setRemoteErr(''); api.virtRemote(type).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type]) - const local = (ov.images ?? []).filter((im) => im.type === type) - const picks = useMemo(() => { - const f = q.trim().toLowerCase() - const match = (im: VirtImage) => !f || `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release}`.toLowerCase().includes(f) - const seen = new Set() - const out: { ref: string; label: string; sub: string; here: boolean }[] = [] - for (const im of local) { if (!match(im)) continue; const ref = imgLabel(im); if (seen.has(ref)) continue; seen.add(ref); out.push({ ref, label: ref, sub: `${im.description} · on this machine · ${fmtBytes(im.size)}`, here: true }) } - for (const im of remote ?? []) { - const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0] - if (!a || !match(im)) continue - const ref = `images:${a}` - if (seen.has(ref)) continue - seen.add(ref); out.push({ ref, label: a, sub: `${im.description}${im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · ${fmtBytes(im.size)}`, here: false }) - } - return out.slice(0, 60) - }, [q, local, remote]) - const submit = async (e: FormEvent) => { - e.preventDefault(); setErr(''); setBusy(true) - try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), pool, network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) } - catch (e) { setErr(errMsg(e)); setBusy(false) } - } - return ( -
-
e.stopPropagation()} onSubmit={submit}> -

New instance

-
- - -
- -
- {remote === null && !remoteErr &&
Reading the catalogue…
} - {remoteErr &&
catalogue: {remoteErr}
} - {picks.map((p) => ( -
setImage(p.ref)}> -
{p.label}{p.here && here}
{p.sub}
-
- ))} - {remote && picks.length === 0 &&
Nothing in the catalogue matches.
} -
-
- - -
-
- - - -
- -
{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}
- - - {showCloud &&
{ try { const k = await api.virtSSHKeys(); if (!k.keys.length) { toast('no public keys found for ' + k.user, 'info'); return } setCloud(`#cloud-config\nusers:\n - name: ${k.user}\n groups: [sudo]\n shell: /bin/bash\n sudo: ALL=(ALL) NOPASSWD:ALL\n ssh_authorized_keys:\n${k.keys.map((x) => ' - ' + x).join('\n')}\npackage_update: true\npackages: [curl, vim]\n`) } catch (e) { toast(errMsg(e), 'err') } }} title="a cloud-config that makes your account inside, with the public keys this machine's Ham Lab account holds">Use my SSH keys
} - {showCloud &&