// Package restorespace is the production seam behind reconcile.RestoreSpace (R-672, agent v0.133.0): // how much a restore of an archive writes, how much a storage has free, and which storages a // restore-test may target. Every read that cannot answer returns an error — the preflight then // REFUSES (reconcile/restoretest_space.go rule 3); nothing here guesses. package restorespace import ( "context" "fmt" "os" "path" "regexp" "strconv" "strings" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" ) // API is the Proxmox subset the provider reads. type API interface { ListStorage(ctx context.Context) ([]proxmox.Storage, error) NodeStorage(ctx context.Context) ([]proxmox.Storage, error) StorageContent(ctx context.Context, store string) ([]proxmox.StorageContent, error) Permissions(ctx context.Context, aclPath string) (map[string]int, error) } // Provider implements reconcile.RestoreSpace. type Provider struct { API API // ThinMeta reads a thin pool's metadata-used fraction (storage.HostOps.ThinPoolMetadata). ThinMeta func(ctx context.Context, vg, pool string) (float64, bool) // ReadFile reads a vzdump log; nil → os.ReadFile. ReadFile func(name string) ([]byte, error) } var _ reconcile.RestoreSpace = (*Provider)(nil) // totalWrittenRe is vzdump's own count of the bytes tar wrote into the archive — the UNCOMPRESSED size, // i.e. what a restore writes back ("INFO: Total bytes written: 22607360000 (22GiB, 49MiB/s)"). var totalWrittenRe = regexp.MustCompile(`Total bytes written:\s*(\d+)`) // archiveExts are the vzdump archive suffixes; the log is the archive name without it + ".log". var archiveExts = []string{".tar.zst", ".tar.gz", ".tar.lzo", ".tgz", ".tar"} func (p *Provider) readFile(name string) ([]byte, error) { if p.ReadFile != nil { return p.ReadFile(name) } return os.ReadFile(name) } // RestoredBytes: a file-backed archive → its vzdump log's "Total bytes written"; a PBS archive → the // size Proxmox reports for the snapshot (its logical, uncompressed size). The archive FILE size is never // used: it is compressed (6.9 GB for a 22.6 GB restore, measured 2026-09-24). func (p *Provider) RestoredBytes(ctx context.Context, archive string) (int64, string, error) { id, vol, ok := strings.Cut(archive, ":") if !ok || id == "" || vol == "" { return 0, "", fmt.Errorf("not a storage volid: %q", archive) } st, err := p.storageConfig(ctx, id) if err != nil { return 0, "", err } switch st.Type { case "pbs": items, err := p.API.StorageContent(ctx, id) if err != nil { return 0, "", fmt.Errorf("list %s: %w", id, err) } for _, it := range items { if it.VolID == archive && it.Size > 0 { return it.Size, "pbs snapshot size", nil } } return 0, "", fmt.Errorf("archive %s not listed on %s with a size", archive, id) default: if st.Path == "" { return 0, "", fmt.Errorf("storage %s (%s) has no path to read a vzdump log from", id, st.Type) } base := path.Base(vol) // "backup/vzdump-lxc-…tar.zst" → "vzdump-lxc-…tar.zst" stem := "" for _, ext := range archiveExts { if strings.HasSuffix(base, ext) { stem = strings.TrimSuffix(base, ext) break } } if stem == "" { return 0, "", fmt.Errorf("unknown archive suffix: %s", base) } logPath := path.Join(st.Path, "dump", stem+".log") b, err := p.readFile(logPath) if err != nil { return 0, "", fmt.Errorf("read vzdump log %s: %w", logPath, err) } m := totalWrittenRe.FindSubmatch(b) if m == nil { return 0, "", fmt.Errorf("vzdump log %s carries no \"Total bytes written\"", logPath) } n, err := strconv.ParseInt(string(m[1]), 10, 64) if err != nil || n <= 0 { return 0, "", fmt.Errorf("vzdump log %s: bad byte count %q", logPath, m[1]) } return n, "vzdump log: total bytes written", nil } } func (p *Provider) storageConfig(ctx context.Context, id string) (proxmox.Storage, error) { all, err := p.API.ListStorage(ctx) if err != nil { return proxmox.Storage{}, fmt.Errorf("list storage config: %w", err) } for _, s := range all { if s.Storage == id { return s, nil } } return proxmox.Storage{}, fmt.Errorf("storage %s not configured", id) } // Free reads the node's live usage for `storage`; a thin pool adds its metadata fill. func (p *Provider) Free(ctx context.Context, storage string) (reconcile.StorageFree, error) { live, err := p.API.NodeStorage(ctx) if err != nil { return reconcile.StorageFree{}, fmt.Errorf("node storage: %w", err) } for _, s := range live { if s.Storage != storage { continue } if s.Active != 1 { return reconcile.StorageFree{}, fmt.Errorf("storage %s is not active", storage) } fr := reconcile.StorageFree{AvailBytes: s.Avail, UsedBytes: s.Used, Thin: s.Type == "lvmthin"} if fr.Thin { cfg, cerr := p.storageConfig(ctx, storage) if cerr == nil && p.ThinMeta != nil && cfg.VGName != "" && cfg.ThinPool != "" { fr.MetaUsedFraction, fr.MetaKnown = p.ThinMeta(ctx, cfg.VGName, cfg.ThinPool) } } return fr, nil } return reconcile.StorageFree{}, fmt.Errorf("storage %s not reported by the node", storage) } // Eligible: active, content includes `rootdir`, and the agent holds Datastore.AllocateSpace on // /storage/. The permission is read for the SPECIFIC privilege — the box-wide grant answers every // path with inherited privileges (proxmox.Client.Permissions). func (p *Provider) Eligible(ctx context.Context) ([]string, error) { live, err := p.API.NodeStorage(ctx) if err != nil { return nil, fmt.Errorf("node storage: %w", err) } var out []string for _, s := range live { if s.Active != 1 || !hasContent(s.Content, "rootdir") { continue } privs, perr := p.API.Permissions(ctx, "/storage/"+s.Storage) if perr != nil || privs["Datastore.AllocateSpace"] != 1 { continue } out = append(out, s.Storage) } return out, nil } func hasContent(list, want string) bool { for _, c := range strings.Split(list, ",") { if strings.TrimSpace(c) == want { return true } } return false }