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>
233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
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
|
||
}
|