check / check (push) Waiting to run
A snapshot ID is hostname_name_timestamp, and purge took the name to be everything between the first and the last underscore. With a hostname such as my_host the name home came out as host_home, so `snapshot purge --keep-latest --snapshot home` found nothing to delete and `snapshot create --prune` purged nothing without a message. The name is now read by removing the hostname stored with the snapshot, in the short form the ID uses, so both may contain underscores. This was chosen over rejecting underscores in `hostname` when the config loads, which would also stop restores on such a host. The purge consistency test stored a hostname that did not match its snapshot IDs; it now matches, as it always does in production. Model: opus-5-5
201 lines
6.2 KiB
Go
201 lines
6.2 KiB
Go
package vaultik
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// percentScale converts a 0..1 ratio into a percentage.
|
|
const percentScale = 100
|
|
|
|
// progressLogEvery is how many processed items pass between progress
|
|
// log lines in long-running loops.
|
|
const progressLogEvery = 100
|
|
|
|
// ubytes renders a byte count with humanize.Bytes, clamping negative
|
|
// values to zero so the int64→uint64 conversion cannot overflow.
|
|
func ubytes(n int64) string {
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
|
|
return humanize.Bytes(uint64(n))
|
|
}
|
|
|
|
// Sentinel errors for snapshot ID and duration parsing.
|
|
var (
|
|
errMalformedSnapshotID = errors.New(
|
|
"invalid snapshot ID format: expected hostname_snapshotname_timestamp")
|
|
errInvalidDuration = errors.New("invalid duration")
|
|
errUnknownTimeUnit = errors.New("unknown time unit")
|
|
errNegativeDuration = errors.New("negative durations are not supported")
|
|
)
|
|
|
|
// Time-unit lengths used by parseDuration.
|
|
const (
|
|
day = 24 * time.Hour
|
|
week = 7 * day
|
|
month = 30 * day
|
|
year = 365 * day
|
|
)
|
|
|
|
// A snapshot ID split on "_" has at least a hostname and a trailing
|
|
// timestamp.
|
|
const minSnapshotIDParts = 2
|
|
|
|
// SnapshotInfo contains information about a snapshot.
|
|
//
|
|
// LocallyTracked says which of the two sources this row came from, and
|
|
// therefore which fields are meaningful:
|
|
//
|
|
// - true: the snapshot is in the local index. ID is its human
|
|
// snapshot ID and UncompressedSize/NewChunkSize are populated.
|
|
// - false: the snapshot was found only on the destination store. ID
|
|
// is empty, because the human ID cannot be recovered from remote
|
|
// storage without the age secret key (see RemoteKey), and
|
|
// UncompressedSize/NewChunkSize are zero because they are computed
|
|
// from local index rows that do not exist.
|
|
//
|
|
// RemoteKey is always populated: for a locally tracked snapshot it is
|
|
// the key the snapshot would occupy on the destination store, and for a
|
|
// remote-only snapshot it is the only identifier available.
|
|
//
|
|
// RemotePresent reports whether the snapshot's metadata was seen on the
|
|
// destination store. It is nil when the destination could not be
|
|
// listed, so "absent" and "unknown" stay distinguishable.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established output format
|
|
type SnapshotInfo struct {
|
|
ID types.SnapshotID `json:"id"`
|
|
RemoteKey string `json:"remote_key"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
CompressedSize int64 `json:"compressed_size"`
|
|
UncompressedSize int64 `json:"uncompressed_size,omitempty"`
|
|
NewChunkSize int64 `json:"new_chunk_size,omitempty"`
|
|
LocallyTracked bool `json:"locally_tracked"`
|
|
RemotePresent *bool `json:"remote_present"`
|
|
}
|
|
|
|
// formatBytes formats bytes in a human-readable format
|
|
func formatBytes(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// parseSnapshotTimestamp extracts the timestamp from a snapshot ID
|
|
// Format: hostname_snapshotname_2026-01-12T14:41:15Z
|
|
func parseSnapshotTimestamp(snapshotID string) (time.Time, error) {
|
|
parts := strings.Split(snapshotID, "_")
|
|
if len(parts) < minSnapshotIDParts {
|
|
return time.Time{}, errMalformedSnapshotID
|
|
}
|
|
|
|
// Last part is the RFC3339 timestamp
|
|
timestampStr := parts[len(parts)-1]
|
|
|
|
timestamp, err := time.Parse(time.RFC3339, timestampStr)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid timestamp: %w", err)
|
|
}
|
|
|
|
return timestamp.UTC(), nil
|
|
}
|
|
|
|
// parseSnapshotName extracts the snapshot name from a snapshot ID of the
|
|
// form hostname_name_timestamp, given the hostname stored with that
|
|
// snapshot. The hostname and the name may both contain underscores, so the
|
|
// name is what is left after removing the short hostname and its "_" from
|
|
// the front and the last "_" and the timestamp from the end. Returns "" for
|
|
// an ID with no name (hostname_timestamp), and for an ID that does not start
|
|
// with that hostname, which CreateSnapshotWithName never writes.
|
|
func parseSnapshotName(snapshotID, hostname string) string {
|
|
prefix := snapshot.ShortHostname(hostname) + "_"
|
|
|
|
rest, ok := strings.CutPrefix(snapshotID, prefix)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
end := strings.LastIndex(rest, "_")
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
|
|
return rest[:end]
|
|
}
|
|
|
|
// parseDuration parses a duration string with support for human-friendly units:
|
|
// d/day/days, w/week/weeks, mo/month/months, y/year/years, plus standard Go
|
|
// duration units. Following Go, m is minutes and mo is months. A bare number,
|
|
// an unknown unit, and a negative value are all rejected. Outside the Go
|
|
// units the input must be whole numbers each followed directly by a unit
|
|
// (2w3d), so 1.0y and 30 days are errors; decimals work only in Go units
|
|
// (1.5h).
|
|
func parseDuration(s string) (time.Duration, error) {
|
|
if strings.HasPrefix(strings.TrimSpace(s), "-") {
|
|
return 0, errNegativeDuration
|
|
}
|
|
|
|
// A bare number has no unit, but time.ParseDuration accepts 0, which
|
|
// would put the cutoff at now and select every snapshot.
|
|
_, err := strconv.ParseFloat(s, 64)
|
|
if err == nil {
|
|
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
|
|
}
|
|
|
|
d, err := time.ParseDuration(s)
|
|
if err == nil {
|
|
return d, nil
|
|
}
|
|
|
|
if !regexp.MustCompile(`^(\d+[a-zA-Z]+)+$`).MatchString(s) {
|
|
return 0, fmt.Errorf("%w: %q", errInvalidDuration, s)
|
|
}
|
|
|
|
re := regexp.MustCompile(`(\d+)([a-zA-Z]+)`)
|
|
matches := re.FindAllStringSubmatch(s, -1)
|
|
|
|
var total time.Duration
|
|
|
|
for _, match := range matches {
|
|
n, err := strconv.Atoi(match[1])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid number %q: %w", match[1], err)
|
|
}
|
|
|
|
unit := strings.ToLower(match[2])
|
|
switch unit {
|
|
case "d", "day", "days":
|
|
total += time.Duration(n) * day
|
|
case "w", "week", "weeks":
|
|
total += time.Duration(n) * week
|
|
case "mo", "month", "months":
|
|
total += time.Duration(n) * month
|
|
case "y", "year", "years":
|
|
total += time.Duration(n) * year
|
|
default:
|
|
return 0, fmt.Errorf("%w %q", errUnknownTimeUnit, unit)
|
|
}
|
|
}
|
|
|
|
return total, nil
|
|
}
|