Virtualization phase 2: ISOs, New VM from ISO, the VGA console in the browser, nightly exports, mounts, instance sensors

- Library: an ISO shelf in virt/isos: upload (the Files uploader), fetch by URL with an
  optional sha256, import a file from the machine by copying it in, rename, delete (refused
  while attached), attach to a VM as a CD-ROM live or stopped with boot-first, eject, and
  New VM from ISO: an empty VM with the ISO as first boot device, Secure Boot on or off.
- The VGA console: Ham Lab asks Incus for the VM's SPICE console over its unix socket and
  bridges the display websocket to the browser, where spice-html5 (vendored from upstream
  b372aab, LGPL, loaded on demand) draws it; Ctrl+Alt+Del, fit-to-window.
- Exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a cron
  line in /etc/cron.d/hamlab-virt calling hamlab virt-export, settings and the last run
  as files beside the data; Export now per instance or for all.
- Disks tab per instance: the root disk, attached ISOs with Eject, bind mounts in and out
  (shift for containers, virtiofs for VMs).
- instance.* sensors (running, down, state, cpu, mem) and the VM-or-container-down alert
  default, offered once to installs seeded before it existed.
- cloud-init: Use my SSH keys writes a cloud-config from the account's public keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-09 22:21:57 -07:00
co-authored by Claude Fable 5.1
parent 66498a58c5
commit 43ebacdd92
46 changed files with 14493 additions and 25 deletions
+116
View File
@@ -0,0 +1,116 @@
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"
"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,
}}
}
// VGA opens the display and control websockets for a running VM. The caller closes both.
func (m *Manager) VGA(ctx context.Context, name string) (display, control *websocket.Conn, err error) {
if !ValidName(name) {
return nil, nil, errors.New("bad name")
}
client := m.apiClient()
body, _ := json.Marshal(map[string]any{"type": "vga", "width": 0, "height": 0})
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 os.IsPermission(errors.Unwrap(err)) || isPermission(err) {
return nil, nil, errors.New("Ham Lab's account can't reach Incus's socket; Finish setup on the Virtualization tab puts it in incus-admin")
}
return nil, nil, err
}
defer resp.Body.Close()
var op struct {
Error string `json:"error"`
Operation string `json:"operation"`
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, nil, errors.New("unreadable answer from incus")
}
if op.Error != "" {
return nil, nil, errors.New(op.Error)
}
data, control0 := op.Metadata.Metadata.FDs["0"], op.Metadata.Metadata.FDs["control"]
if op.Metadata.ID == "" || data == "" {
return nil, nil, errors.New("incus gave no console; is the VM running?")
}
dial := func(secret string) (*websocket.Conn, error) {
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", op.Metadata.ID, secret)
ws, _, err := websocket.Dial(ctx, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
return ws, err
}
if control0 != "" {
if control, err = dial(control0); err != nil {
return nil, nil, err
}
}
if display, err = dial(data); err != nil {
if control != nil {
control.Close(websocket.StatusInternalError, "")
}
return nil, nil, err
}
display.SetReadLimit(64 << 20) // a SPICE frame can carry a whole screen
return display, control, 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
}