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" "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 os.WriteFile(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, "", " ") _ = os.WriteFile(m.exportRunPath(), b, 0o644) return run }