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:
co-authored by
Claude Fable 5.1
parent
66498a58c5
commit
43ebacdd92
+90
-16
@@ -57,7 +57,7 @@ type Manager struct {
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
cpuPrev map[string]cpuMark
|
||||
cpuPrev map[string]map[string]cpuMark // caller → instance → last reading, so two readers do not spoil each other's deltas
|
||||
remote struct {
|
||||
at time.Time
|
||||
list []Image
|
||||
@@ -75,7 +75,7 @@ type cpuMark struct {
|
||||
}
|
||||
|
||||
func New(dataDir, user, version string) *Manager {
|
||||
return &Manager{dir: filepath.Join(dataDir, "virt"), user: user, version: version, cpuPrev: map[string]cpuMark{}}
|
||||
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 }
|
||||
@@ -512,6 +512,12 @@ func secretKey(k string) bool {
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -525,6 +531,11 @@ func (m *Manager) Instances(ctx context.Context) ([]Instance, error) {
|
||||
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 {
|
||||
@@ -577,18 +588,18 @@ func (m *Manager) Instances(ctx context.Context) ([]Instance, error) {
|
||||
}
|
||||
}
|
||||
if r.Status == "Running" {
|
||||
if prev, ok := m.cpuPrev[r.Name]; ok && now.After(prev.at) && r.State.CPU.Usage >= prev.ns {
|
||||
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
|
||||
}
|
||||
m.cpuPrev[r.Name] = cpuMark{ns: r.State.CPU.Usage, at: now}
|
||||
prevs[r.Name] = cpuMark{ns: r.State.CPU.Usage, at: now}
|
||||
seen[r.Name] = true
|
||||
}
|
||||
}
|
||||
list = append(list, in)
|
||||
}
|
||||
for n := range m.cpuPrev {
|
||||
for n := range prevs {
|
||||
if !seen[n] {
|
||||
delete(m.cpuPrev, n)
|
||||
delete(prevs, n)
|
||||
}
|
||||
}
|
||||
if list == nil {
|
||||
@@ -797,11 +808,16 @@ func (m *Manager) Remote(ctx context.Context, refresh bool) ([]Image, error) {
|
||||
|
||||
// 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"`
|
||||
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 {
|
||||
@@ -822,16 +838,20 @@ func (m *Manager) Library(dir string) []LibraryFile {
|
||||
// ---- 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])*$|^$`)
|
||||
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) }
|
||||
@@ -1075,3 +1095,57 @@ func (m *Manager) InstallScript(st Status, o InstallOpts) (string, Layout, error
|
||||
w("if systemctl is-active --quiet %s; then systemd-run --quiet --on-active=3 --unit=hamlab-virt-restart systemctl restart %s; fi", HamlabUnit, HamlabUnit)
|
||||
return b.String(), lay, nil
|
||||
}
|
||||
|
||||
// FromISOSpec is the New VM from ISO form: an empty VM with the ISO as its first boot device.
|
||||
type FromISOSpec struct {
|
||||
Name string `json:"name"`
|
||||
ISO string `json:"iso"` // a file name in virt/isos
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Disk string `json:"disk"`
|
||||
Network string `json:"network"`
|
||||
SecureBoot bool `json:"secureboot"`
|
||||
Start bool `json:"start"`
|
||||
}
|
||||
|
||||
// FromISOScript makes the VM in one shell script run through ShellCmd; the parameters carry
|
||||
// the values so nothing is spliced into the text.
|
||||
func FromISOScript(sp FromISOSpec, kvm bool) (script string, params []string, err error) {
|
||||
switch {
|
||||
case !kvm:
|
||||
return "", nil, errors.New("this machine has no KVM")
|
||||
case !ValidName(sp.Name):
|
||||
return "", nil, errors.New("name: letters, digits and dashes, starting with a letter, up to 63")
|
||||
case !ValidISOName(sp.ISO):
|
||||
return "", nil, errors.New("iso: pick one from the library")
|
||||
case !ValidCPU(sp.CPU) || !ValidSize(sp.Memory) || !ValidSize(sp.Disk) || !ValidNetwork(sp.Network):
|
||||
return "", nil, errors.New("cpu, memory, disk: a count and two sizes, like 2, 2GiB, 20GiB")
|
||||
}
|
||||
cpu, mem, disk := sp.CPU, sp.Memory, sp.Disk
|
||||
if cpu == "" {
|
||||
cpu = "2"
|
||||
}
|
||||
if mem == "" {
|
||||
mem = "2GiB"
|
||||
}
|
||||
if disk == "" {
|
||||
disk = "20GiB"
|
||||
}
|
||||
sb := "true"
|
||||
if !sp.SecureBoot {
|
||||
sb = "false"
|
||||
}
|
||||
start := "0"
|
||||
if sp.Start {
|
||||
start = "1"
|
||||
}
|
||||
// $1 name $2 iso path $3 cpu $4 memory $5 disk $6 network $7 secureboot $8 start
|
||||
script = `set -e
|
||||
NET=""; [ -n "$6" ] && NET="-n $6"
|
||||
incus init --empty --vm "$1" -c limits.cpu="$3" -c limits.memory="$4" -c security.secureboot="$7" -d root,size="$5" $NET
|
||||
incus config device add "$1" iso0 disk source="$2" boot.priority=10
|
||||
echo "made $1 with $2 as its first boot device"
|
||||
[ "$8" = 1 ] && incus start "$1" && echo "started; open the VGA console to run the installer"
|
||||
exit 0`
|
||||
return script, []string{sp.Name, sp.ISO, cpu, mem, disk, sp.Network, sb, start}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user