backup

package
v0.14.3-dev Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package backup is the outpost-side, app-opaque folder watcher that produces backup candidates on a cron schedule. It does NOT snapshot app internals — the cooperating app (classgo's nightly ZIP under ~/.classgo/data/backups/, kg's exported graph, …) is responsible for putting backup artifacts into a folder we watch. On each fire the worker scans every configured folder, picks the newest regular file by mtime, computes its sha256, and (if new) records a Candidate entry in the local JSONL ledger.

Phase 2 (this commit) only records candidates and serves the admin UI's "what's pending backup right now" view. Phase 3 will read the pending candidates and ship them to peer outposts via cloudbox (age-encrypted, chunked, sha256-verified). The Candidate record is the contract between the two phases.

Index

Constants

View Source
const JobName = "backup-folders"

JobName is the registered scheduler name for the backup job. Stable constant so manual ledger queries and the admin UI agree on what row corresponds to a scheduled fire.

Variables

View Source
var ErrNoFiles = errors.New("backup: directory has no eligible files")

ErrNoFiles is returned by PickLatest when the directory exists but holds no eligible regular files. Distinct from "directory missing" (os.ErrNotExist) so the admin UI can render "no backups yet" vs "you typed a bad path."

Functions

func DefaultIdentityPath

func DefaultIdentityPath() string

DefaultIdentityPath returns "<cacheDir>/outpost/age.key" or empty string if UserCacheDir is unavailable.

func DefaultLedgerPath

func DefaultLedgerPath() string

DefaultLedgerPath returns "<cacheDir>/outpost/backup.log" or an empty string if UserCacheDir is unavailable. Used by main.go to build the default Manager.

func LoadOrCreateIdentity

func LoadOrCreateIdentity(path string) (*age.X25519Identity, *age.X25519Recipient, error)

LoadOrCreateIdentity returns the outpost-local age X25519 identity + matching recipient. On first call (no key file at path) a new identity is generated and persisted (mode 0600, parent directory mode 0700). The private key never leaves this host — only the recipient ("age1...") is published, and cloudbox / peer outposts see opaque ciphertext.

Documented consequence: if this file is lost, every artifact encrypted with the matching recipient becomes unrecoverable. The v2 mitigation (operator-supplied passphrase escrow) is tracked in the umbrella plan.

Empty path is an error — we never silently fall back to a temporary identity because that would yield artifacts no one can decrypt after the daemon restarts.

func PickLatest

func PickLatest(dir string) (string, fs.FileInfo, error)

PickLatest returns the absolute path of the newest regular file directly inside dir (no recursion). Eligibility filter:

  • regular files only (no directories, symlinks, sockets, fifos)
  • hidden files (basename starting with ".") are skipped — they are typically lock files, partial writes from the cooperating app, or editor backups
  • zero-byte files are skipped — most likely "I am writing to you right now" placeholders; we want the previous complete file

The tie-break for files with identical mtimes is lexicographic (filename ascending) so the result is deterministic across reruns — matters for the dedup check (same file picked twice in a row must produce the same Candidate.SHA256).

func RecipientFingerprint

func RecipientFingerprint(r *age.X25519Recipient) string

RecipientFingerprint returns a short stable identifier for a recipient public key (first 16 hex chars of sha256). Used as the KeyID column on BackupArtifact so a future key rotation can reason about which artifacts were sealed with which key without joining back to a policy row.

Implementation note: we hash the canonical age1 string rather than the raw 32 bytes so the fingerprint is computable from anything that can render an age recipient — no need to round-trip through the binary curve point.

Types

type Candidate

type Candidate struct {
	At      time.Time `json:"at"`
	Folder  string    `json:"folder"`
	Path    string    `json:"path"`              // absolute path of the picked file
	SHA256  string    `json:"sha256"`            // hex-encoded content hash
	Size    int64     `json:"size"`              // bytes
	Mtime   time.Time `json:"mtime"`             // file modtime, UTC
	Skipped bool      `json:"skipped,omitempty"` // true when SHA matched the previous candidate (no-op fire)
	Error   string    `json:"error,omitempty"`

	// Push status — populated when the manager has a Pusher wired
	// (cloudbox URL + access token present). Empty Pushed means the
	// candidate didn't go through a push attempt (worker only).
	Pushed       bool   `json:"pushed,omitempty"`
	ArtifactID   string `json:"artifact_id,omitempty"`
	CipherSHA256 string `json:"cipher_sha256,omitempty"`
	PushError    string `json:"push_error,omitempty"`
}

Candidate is one (folder, file, content-hash) triple a worker fire produced. Written as a single JSONL line to the ledger. The content-hash is what enables resume + dedup across fires: a folder that hasn't grown a new file since the last fire emits a fresh ledger line with the same SHA256 and the pusher will recognize it as already-shipped.

type Ledger

type Ledger struct {
	// contains filtered or unexported fields
}

Ledger is the JSONL writer + bounded tail-reader for backup Candidates. Shape mirrors internal/agent/upgrade/ledger.go and internal/scheduler/ledger.go so a single tail-renderer can read any of them later.

func NewLedger

func NewLedger(path string) *Ledger

func (*Ledger) Append

func (l *Ledger) Append(c Candidate) error

Append writes one Candidate as a JSON line. Empty path silently no-ops (tests / disabled config).

func (*Ledger) LastByFolder

func (l *Ledger) LastByFolder(folder string) (Candidate, error)

LastByFolder returns the most recent non-skipped candidate for folder, or zero if the folder has never produced one. Used by the worker's dedup check (skip a fire when the latest picked file's sha matches the last shipped one) and by the admin UI to render "last backup picked from this folder."

func (*Ledger) Path

func (l *Ledger) Path() string

func (*Ledger) Tail

func (l *Ledger) Tail(n int) ([]Candidate, error)

Tail returns up to the last `n` candidates, newest last. Missing file is empty + nil (a host that has never fired has no history).

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager glues admincore's saved BackupConfig to the in-process scheduler. The lifecycle is:

  • main.go constructs one Manager with a process-lifetime scheduler reference and the path resolver for the default ledger (cache dir).
  • At boot, Apply is called with the persisted FileConfig.Backup so a scheduled job is registered before any HTTP serves.
  • When the admin UI saves a new BackupConfig via admincore. SetBackup, admincore calls Manager.Apply with the new config — LIVE mutation, no restart needed.

Manager owns the Worker (one per process) so RunOnce dedup is process-wide. Concurrent Apply + RunOnce is safe because both go through the worker's own inFlight mutex.

When a Pusher is attached, the manager pushes every fresh (non-skipped, non-errored) candidate to cloudbox after the worker records it. Push outcomes are stamped onto the Candidate's Pushed/PushError/ArtifactID/CipherSHA256 fields and re-appended to the ledger so the admin UI's history view reflects the push status alongside the discovery status.

func NewManager

func NewManager(sched *scheduler.Scheduler, defaultLedger string) *Manager

NewManager constructs a Manager. scheduler must be non-nil; defaultLedger is the on-disk path the manager uses when the saved BackupConfig.LedgerPath is empty (typically <UserCacheDir>/outpost/backup.log).

func (*Manager) Apply

func (m *Manager) Apply(cfg *conf.BackupConfig) error

Apply reconciles the scheduler against cfg: registers (or re-registers) the cron entry when cfg.Enabled && cfg.Schedule, or removes it otherwise. Updates the worker + ledger to point at the (possibly new) ledger path. Idempotent — safe to call on every save even when nothing changed.

func (*Manager) AttachPusher

func (m *Manager) AttachPusher(p *Pusher)

AttachPusher injects (or clears) the cloudbox pusher. Called at startup once main.go knows the cloudbox base + access token, and again whenever pairing changes. Pass nil to disable push.

func (*Manager) History

func (m *Manager) History(n int) ([]Candidate, error)

History returns the last `n` ledger entries (newest last). Empty when the manager has no ledger configured yet. Used by the admin UI's "Recent backups" panel and by future MCP/CLI surfaces.

func (*Manager) RunNow

func (m *Manager) RunNow(ctx context.Context) ([]Candidate, error)

RunNow triggers a manual fire against the currently-applied folders, regardless of Enabled. Returns the candidates produced (one per folder) so the admin UI can render the result inline. Returns an error when no config has been applied yet.

type PushConfig

type PushConfig struct {
	CloudboxBase string
	AccessToken  string
	AgentName    string

	// IdentityPath is where the persistent age identity lives. Empty
	// = use backup.DefaultIdentityPath(). The identity is generated
	// on first push if missing.
	IdentityPath string

	// HTTPClient is optional — tests can inject a custom transport.
	// Default = a fresh http.Client with a 60s timeout, generous
	// enough for a 500 MiB upload over a slow link.
	HTTPClient *http.Client
}

PushConfig is what main.go threads into the manager to enable cloudbox push. Empty CloudboxBase OR AccessToken disables push silently (the worker still runs locally — useful for offline testing or for outposts not yet paired).

type PushResult

type PushResult struct {
	ArtifactID   string
	CipherSHA256 string
	KeyID        string
}

PushResult is what Push returns on success — the cloudbox-assigned artifact id, the recipient fingerprint used, and the sha256 of the uploaded ciphertext (distinct from Candidate.SHA256 which is the plaintext sha).

type Pusher

type Pusher struct {
	// contains filtered or unexported fields
}

Pusher encrypts a candidate's file with age and POSTs it to cloudbox's /api/v1/backup/artifact. Returns the cloudbox-assigned artifact id + the ciphertext sha256 on success.

Cloudbox-side route lives in cloudbox/hub/internal/handlers/ v1_backup.go:V1BackupCreateArtifact and stores the blob under <cloudbox cfg.Base>/blobstore/backup/<owner>/<artifact-id>.bin.

func NewPusher

func NewPusher(cfg PushConfig) *Pusher

NewPusher constructs a Pusher. cfg.CloudboxBase + cfg.AccessToken are required at push time but may be empty here — Configured() reports the truth.

func (*Pusher) Configured

func (p *Pusher) Configured() bool

Configured reports whether a push attempt has any chance of succeeding (cloudbox base + access token + agent name present). Callers gate on this to avoid wasting work + ledger noise on unpaired hosts.

func (*Pusher) Push

func (p *Pusher) Push(ctx context.Context, c Candidate, app string) (PushResult, error)

Push encrypts c.Path with the resolved age recipient and POSTs the resulting blob to cloudbox. Returns a PushResult on success. The caller is expected to stamp the result fields onto its Candidate before writing to the ledger.

Errors fall into three buckets:

  • Configuration: unpaired host, missing key — surface to operator.
  • Encrypt: filesystem / age failure — likely transient.
  • Network/Server: cloudbox unreachable or 4xx/5xx — retry-eligible.

All are returned as plain Go errors; the manager logs + records them on the Candidate's PushError field.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker walks the configured folders on a schedule (or on demand) and writes one Candidate per (folder, fire) to the ledger. The scheduler glue lives in manager.go; Worker.RunOnce is the unit of work either path invokes.

Concurrency: RunOnce is serialized through inFlight to prevent a manual "Run now" overlapping a scheduled fire. Overlap would produce two candidates with the same SHA but the second would be flagged Skipped — harmless but noisy in the ledger.

func NewWorker

func NewWorker(ledger *Ledger) *Worker

NewWorker constructs a Worker writing to the given ledger. A nil ledger disables persistence (the worker still computes hashes and returns Candidates to callers — useful for tests).

func (*Worker) LastFireAt

func (w *Worker) LastFireAt() time.Time

LastFireAt returns the UTC timestamp of the most recent RunOnce start, or zero if the worker has never fired this process-lifetime. Read by the admin UI's status banner.

func (*Worker) RunOnce

func (w *Worker) RunOnce(ctx context.Context, folders []string) ([]Candidate, error)

RunOnce iterates folders, picks the newest file from each, computes sha256, and appends one Candidate per folder to the ledger. Returns the candidates it produced (in folders order) so a manual-fire caller can render the result inline without a second ledger Tail.

Errors from individual folders (missing dir, permission, picker failure) are recorded as Candidate.Error and do NOT abort the remaining folders — one bad folder shouldn't block the rest.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL