Files
hamlab/internal/incus/export.go
Mitchell-JohnsonandClaude Fable 5.1 285278abdb A sweep of the whole console: status codes, a data race, atomic writes, dead code
From the race detector, staticcheck, a garbage-in battery over every safe route and a
headless pass through every tab on two machines. Fixed:
- docker inspect/health of a container that isn't there answered 500; now 404, and a
  daemon that is down 503 (dockerErr, with a test).
- /api/virt/instances/{name} on a machine without Incus answered 502 "incus not found";
  it now says Incus isn't ready, like its siblings.
- Deleting a ZFS snapshot with a bad name answered 500; a bad name is 400, a snapshot
  that doesn't exist 404.
- The task list read a task's Running flag without the task's lock while the waiter
  goroutine cleared it: a data race, now read under the lock.
- The Virtualization export config and run record were written in place; a crash
  mid-write would have left a half file. Atomic now, like the config and state files.
- An unused function in kvm.go, one in incus/subnet.go, an unused field in files/move.go
  and a capitalised error string, from staticcheck.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:52:18 -07:00

233 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package incus
// Nightly exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a
// cron line like the ZFS schedules — `hamlab virt-export` in /etc/cron.d/hamlab-virt — so the
// backup runs whether or not the console is up. The settings and the last run's record are
// files beside the data, not state.json, because the cron job is a separate process.
import (
"context"
"encoding/json"
"errors"
"fmt"
"hamlab/internal/store"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
const CronFile = "/etc/cron.d/hamlab-virt"
type ExportConfig struct {
Enabled bool `json:"enabled"`
Hour int `json:"hour"`
Minute int `json:"minute"`
Keep int `json:"keep"` // newest exports kept per instance
InstanceOnly bool `json:"instance_only"` // leave the snapshots out of the tarball
Instances []string `json:"instances,omitempty"` // empty: every instance
Updated time.Time `json:"updated,omitempty"`
}
func DefaultExportConfig() ExportConfig {
return ExportConfig{Hour: 2, Minute: 15, Keep: 3, InstanceOnly: true}
}
func (c ExportConfig) Validate() error {
if c.Hour < 0 || c.Hour > 23 || c.Minute < 0 || c.Minute > 59 {
return errors.New("time: hour 0–23, minute 0–59")
}
if c.Keep < 1 || c.Keep > 100 {
return errors.New("keep: between 1 and 100")
}
for _, n := range c.Instances {
if !ValidName(n) {
return errors.New("instance: " + n)
}
}
return nil
}
func (c ExportConfig) CronExpr() string { return fmt.Sprintf("%d %d * * *", c.Minute, c.Hour) }
// NextRun is the next time the line fires, for the page.
func (c ExportConfig) NextRun(now time.Time) time.Time {
n := time.Date(now.Year(), now.Month(), now.Day(), c.Hour, c.Minute, 0, 0, now.Location())
if !n.After(now) {
n = n.Add(24 * time.Hour)
}
return n
}
// CronText is the whole file; rewritten on every change, never edited.
func (c ExportConfig) CronText(user, exe, config string) string {
var b strings.Builder
b.WriteString("# Ham Lab instance exports — written by the Virtualization tab, rewritten whole on every change.\n")
b.WriteString("# Edit it there, not here.\n")
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
fmt.Fprintf(&b, "# every instance to virt/exports, keep %d each\n", c.Keep)
fmt.Fprintf(&b, "%s %s %s -config %s virt-export\n", c.CronExpr(), user, exe, config)
return b.String()
}
func (m *Manager) exportConfigPath() string { return filepath.Join(m.dir, "exports.json") }
func (m *Manager) exportRunPath() string { return filepath.Join(m.dir, "export-runs.json") }
func (m *Manager) ExportConfig() ExportConfig {
c := DefaultExportConfig()
if b, err := os.ReadFile(m.exportConfigPath()); err == nil {
_ = json.Unmarshal(b, &c)
}
return c
}
func (m *Manager) SaveExportConfig(c ExportConfig) error {
if err := c.Validate(); err != nil {
return err
}
if err := m.EnsureDirs(); err != nil {
return err
}
c.Updated = time.Now()
b, _ := json.MarshalIndent(c, "", " ")
return store.WriteFileAtomic(m.exportConfigPath(), b, 0o644)
}
type ExportRun struct {
At time.Time `json:"at"`
Exported []string `json:"exported"`
Pruned []string `json:"pruned"`
Errors []string `json:"errors,omitempty"`
Seconds float64 `json:"seconds"`
}
func (m *Manager) LastExportRun() *ExportRun {
b, err := os.ReadFile(m.exportRunPath())
if err != nil {
return nil
}
var r ExportRun
if json.Unmarshal(b, &r) != nil {
return nil
}
return &r
}
// ExportFile is one tarball in the exports tree.
type ExportFile struct {
Instance string `json:"instance"`
Name string `json:"name"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func (m *Manager) ExportFiles() []ExportFile {
out := []ExportFile{}
dirs, _ := os.ReadDir(m.ExportsDir())
for _, d := range dirs {
if !d.IsDir() {
continue
}
files, _ := os.ReadDir(filepath.Join(m.ExportsDir(), d.Name()))
for _, f := range files {
if f.IsDir() || strings.HasSuffix(f.Name(), ".part") {
continue
}
if info, err := f.Info(); err == nil {
out = append(out, ExportFile{Instance: d.Name(), Name: f.Name(), Size: info.Size(), Modified: info.ModTime()})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Instance != out[j].Instance {
return out[i].Instance < out[j].Instance
}
return out[i].Modified.After(out[j].Modified)
})
return out
}
// RunExports exports the named instances (or every one when names is empty) and prunes each
// instance's folder to the configured count. Output goes to w line by line for a task to show.
func (m *Manager) RunExports(ctx context.Context, names []string, w func(string)) ExportRun {
start := time.Now()
c := m.ExportConfig()
run := ExportRun{At: start, Exported: []string{}, Pruned: []string{}}
if err := m.EnsureDirs(); err != nil {
run.Errors = append(run.Errors, err.Error())
return run
}
all, err := m.Instances(ctx)
if err != nil {
run.Errors = append(run.Errors, "listing instances: "+err.Error())
return run
}
want := map[string]bool{}
for _, n := range names {
want[n] = true
}
if len(names) == 0 && len(c.Instances) > 0 {
for _, n := range c.Instances {
want[n] = true
}
}
for _, in := range all {
if len(want) > 0 && !want[in.Name] {
continue
}
dir := filepath.Join(m.ExportsDir(), in.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
run.Errors = append(run.Errors, in.Name+": "+err.Error())
continue
}
stamp := start.Format("2006-01-02_15.04")
final := filepath.Join(dir, fmt.Sprintf("%s-%s.tar.gz", in.Name, stamp))
part := final + ".part"
args := []string{"export", in.Name, part}
if c.InstanceOnly {
args = append(args, "--instance-only")
}
w(fmt.Sprintf("exporting %s …", in.Name))
cctx, cancel := context.WithTimeout(ctx, 2*time.Hour)
out, err := exec.CommandContext(cctx, "incus", args...).CombinedOutput()
cancel()
if err != nil {
_ = os.Remove(part)
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
run.Errors = append(run.Errors, in.Name+": "+msg)
w(" failed: " + msg)
continue
}
if err := os.Rename(part, final); err != nil {
run.Errors = append(run.Errors, in.Name+": "+err.Error())
continue
}
if st, err := os.Stat(final); err == nil {
w(fmt.Sprintf(" %s (%.1f MiB)", filepath.Base(final), float64(st.Size())/(1<<20)))
}
run.Exported = append(run.Exported, filepath.Base(final))
// prune: newest first, keep c.Keep
var files []ExportFile
for _, f := range m.ExportFiles() {
if f.Instance == in.Name {
files = append(files, f)
}
}
for i := c.Keep; i < len(files); i++ {
if err := os.Remove(filepath.Join(dir, files[i].Name)); err == nil {
run.Pruned = append(run.Pruned, files[i].Name)
w(" pruned " + files[i].Name)
}
}
}
run.Seconds = time.Since(start).Seconds()
b, _ := json.MarshalIndent(run, "", " ")
_ = store.WriteFileAtomic(m.exportRunPath(), b, 0o644)
return run
}