9bdb4dae8f
gates / gates (push) Successful in 13s
Space preflight before anything is created (uncompressed size from the vzdump log / PBS snapshot, x1.2 + 5 GiB, thin metadata, off the tested guest's pool when another storage is eligible, unknown refuses, reported as a non-pass result). Failed scratch teardown and the stale-lock sweep retried every 10 min (the sweep under the heavy-op gate). A thin pool crossing 90% requests an immediate host report. Six red-proofs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
177 lines
5.9 KiB
Go
177 lines
5.9 KiB
Go
// 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/<id>. 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
|
|
}
|