Leandro von Werra
Bucket backup: snapshot over the API, scrub secrets, and never trust a default (#38)
48ce7d1 unverified
Raw
History Blame Contribute Delete
31.7 kB
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { DATA_DIR } from './config.js';
import { visibility } from './visibility.js';
import { shareNamespace } from './share.js';
// Bucket backup: every 1h/3h/24h, launch one HF Job that copies this Space's
// bucket to private Hub storage. Design: docs/bucket-backup.md
//
// The whole feature is one loop: if it is switched on and enough time has
// passed, launch a Job. Nothing else. The Job builds one versioned snapshot:
//
// 1. list the bucket over the API — 8 s for 108,960 files, against 14m51s to
// stat the same tree through a FUSE mount (§3.11).
// 2. decide the scope in code, then copy just those paths server-side by Xet
// hash into a private, EPHEMERAL staging bucket. Metadata only, no bytes.
// Being frozen is what makes a run self-consistent: the live bucket is
// written while we work.
// 3. download the snapshot to the Job's local disk, scrub secrets, verify none
// remain, commit to a private dataset repo, delete the staging bucket.
//
// There is no persistent mirror. It cost a second full copy of the bucket, never
// deleted anything (so it archived every credential the Space ever held), and the
// restore it offered — "the Space exactly as it was, latest only" — is what the
// bucket itself already is.
//
// The copying happens on the Hub, not here: we launch the Job and forget it. So
// a backup costs this Space one API call, never reads /data (whose cold walk runs
// to minutes), and cannot wedge the event loop that pumps the terminals.
//
// Requires HF_TOKEN in the environment — there is no way to launch a Job or write
// to the Hub without one, so with no token the feature reports itself
// unavailable rather than failing every hour in the logs.
const STATE_FILE = path.join(DATA_DIR, 'backup-state.json');
const SPACE_ID = process.env.SPACE_ID || '';
const spaceName = () => SPACE_ID.split('/')[1] || 'agent-manager';
export const EVERY_MS = {
'1h': 3_600_000,
'3h': 10_800_000,
'24h': 86_400_000,
};
export const INTERVALS = ['never', ...Object.keys(EVERY_MS)];
export const intervalMs = (every) => EVERY_MS[every] || 0;
// How often we ask "is a backup due?". Cheap: reads a file, compares two numbers.
const TICK_MS = 300_000; // 5 min
// A Job that hangs must die well inside the shortest interval so runs cannot
// stack up behind each other.
const JOB_TIMEOUT = '3000s'; // 50 min
const JOB_IMAGE = 'python:3.12';
// Pinned, not left to the default: the operator pays for this. cpu-basic is the
// cheapest tier ($0.0002/min) and the work is API calls and file hashing, so
// nothing here benefits from more machine. Pinning also means a change to the
// Hub's default flavour cannot silently make hourly backups more expensive.
export const JOB_FLAVOR = 'cpu-basic';
export const hasToken = () => !!(process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN);
// A bucket/dataset id arrives from PUT /api/config and ends up in a Job's
// argument list. Anything outside this charset is refused rather than escaped —
// there is no legitimate repo called `; rm -rf /`.
const ID_RE = /^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*$/;
export const validRepoId = (s) => typeof s === 'string' && s.length <= 96 && ID_RE.test(s);
// Folders the operator does not want in the history: the slow, regenerable kind
// (`node_modules`, `.venv`, `env`) that turn one backup into thousands of file
// hashes. Measured on 805 files: 7s without, 1s with them excluded — and a real
// bucket's mount is slower than a local disk, so the saving is larger there.
//
// Tokens are folder names, or raw globs for anything finer. No whitespace and no
// shell metacharacters: these are joined into one env var and split apart again
// inside the Job, so the charset is what makes that split exact.
const EXCLUDE_RE = /^[A-Za-z0-9._*?/-]+$/;
export const MAX_EXCLUDES = 40;
/**
* What a fresh install skips until the operator says otherwise.
*
* One criterion, and it is not size: **a command can put it back**. Everything
* here is owned by a package manager, a toolchain installer, a cache or a temp
* dir. Nothing is excluded for being large — a 292 MB session transcript is the
* most history-shaped thing on the bucket, and an earlier draft of this list
* dropped it to a per-file size cap, which was wrong.
*
* Measured against this Space's own bucket (110,590 files / 11.82 GB):
* excluding only the names below drops 86,615 files and 8.05 GB, leaving 23,975
* files and 3.78 GB. The heavy hitters were `node_modules` (25,184 files),
* `.cache` (11,319), `.tmp` (10,151), `.lake/packages` (9,606, 0.65 GB),
* `.elan/toolchains` (13,904, 2.75 GB) and `.npm/_cacache` (1.03 GB).
*
* Deliberately NOT here:
* - `.git` — appears more often than anything else and holds the work you
* would most want back: unpushed commits and staged changes live nowhere
* else. Cheap to keep, and a partially-copied `.git` is a corrupt repo.
* - git worktrees as a class. A clone looks reproducible from its remote, but
* uncommitted work in it is not.
* - `dist` / `build` / `target` — genuinely ambiguous names for a source
* folder. A default that silently drops work is worse than one that copies
* some junk. Add them per-install.
*/
export const DEFAULT_EXCLUDE = Object.freeze([
// dependency trees
'node_modules', '.venv', 'venv', 'site-packages', '.lake/packages', '.pnpm-store',
// caches, by manager
'.cache', '.npm/_cacache', '.npm/_npx', '.yarn/cache', '.cargo/registry', 'pkg/mod',
'__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints',
'.gradle/caches', '.m2/repository', '.deno', '.bun/install/cache', '.turbo', '.next',
// toolchains an installer re-fetches
'.elan/toolchains', '.rustup/toolchains', '.nvm/versions', '.pyenv/versions',
// scratch
'.tmp', '.elan/tmp', '.lake/build',
]);
/**
* The stored value, or the default when the operator has never set one.
*
* An absent key and an empty list are NOT the same: `undefined` means "never
* asked", which takes the default, while `[]` is a list the operator emptied on
* purpose and must stay empty — otherwise clearing the field in Settings would
* silently refill itself on the next read.
*/
export function excludeFromConfig(saved) {
if (saved === undefined || saved === null) return [...DEFAULT_EXCLUDE];
return normalizeExclude(saved);
}
export function normalizeExclude(list) {
if (!Array.isArray(list)) return [];
const out = [];
for (const raw of list) {
if (typeof raw !== 'string') continue;
// Leading and trailing slashes are noise: `/env/` and `env` mean the same.
const t = raw.trim().replace(/^\/+/, '').replace(/\/+$/, '');
if (!t || t.length > 64 || !EXCLUDE_RE.test(t)) continue;
if (!out.includes(t)) out.push(t);
}
return out.slice(0, MAX_EXCLUDES);
}
function run(args, { timeout = 120_000 } = {}) {
return new Promise((resolve, reject) => {
execFile('hf', args, { timeout, env: process.env, maxBuffer: 8 << 20 }, (err, stdout, stderr) => {
if (err) { err.stderr = String(stderr || ''); return reject(err); }
resolve(String(stdout || ''));
});
});
}
export function loadState() {
try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return {}; }
}
function saveState(patch) {
const next = { ...loadState(), ...patch };
try { fs.writeFileSync(STATE_FILE, JSON.stringify(next, null, 2)); } catch {}
return next;
}
// The bucket mounted at /data, as discovered by visibility.js from
// GET /api/spaces/{id} → runtime.volumes[]. AM_BACKUP_SOURCE overrides it for
// local runs, where there is no Space and so no volume to discover.
export function sourceBucket() {
if (process.env.AM_BACKUP_SOURCE) return process.env.AM_BACKUP_SOURCE;
const v = visibility();
return (v.buckets && v.buckets[0]) || null;
}
export async function defaultsFor() {
const ns = await shareNamespace().catch(() => '');
if (!ns) return { dataset: '', staging: '' };
return {
dataset: `${ns}/${spaceName()}-backup`,
// Ephemeral: created private at the start of a run, deleted at the end of it.
// A distinct name from the dataset so a half-finished run can never be
// confused with the history, and so an operator's existing mirror bucket is
// never written to by the new pipeline.
staging: `${ns}/${spaceName()}-snapshot`,
};
}
const HF = 'https://huggingface.co';
// Every run is launched with `--name am-backup-<space>`, which the Hub stores as
// a `name=` label. So one static URL lists this Space's backup runs — past,
// present and failed — and the row never has to track a job id to link to them.
export const jobName = () => `am-backup-${spaceName()}`.slice(0, 40);
export const jobsUrl = () =>
`${HF}/settings/jobs?label=${encodeURIComponent(`name=${jobName()}`)}`;
// The script the Job runs. No interpolation: every value arrives as an env var
// (`-e`), so a config string can never become shell syntax.
export const JOB_SCRIPT = `set -euo pipefail
# Plain package, no [cli] extra: 1.26.0 dropped it and ships the CLI in the base
# install. hf_xet speeds up the one leg that actually moves bytes.
pip install -q "huggingface_hub[hf_xet]"
python - <<'EOPY'
import os, re, sys, time, collections
from huggingface_hub import HfApi
api = HfApi()
SRC = os.environ["AM_SOURCE"]
DATASET = os.environ["AM_DATASET"]
STAGING = os.environ["AM_STAGING"]
# Folder-name tokens, space-joined. Matched here rather than handed to an
# uploader: an uploader enumerates the folder first and filters second, which
# pays exactly the walk this pipeline exists to avoid.
EXCLUDE = [t for t in os.environ.get("AM_EXCLUDE", "").split(" ") if t]
WORK = "/work"
def say(m): print(m, flush=True)
# ---------------------------------------------------------------- privacy gate
# A backup carries every saved login on the bucket (docs/bucket-backup.md §4), so
# a public destination is a stop, not a warning. Re-checked HERE on every run,
# because a repo can be flipped public long after it was created.
#
# Never inferred from a default: create_bucket() with no private= yields a PUBLIC
# bucket, and hf upload into a repo that does not exist creates it PUBLIC. Both
# verified against the Hub (§3.12). So every destination is created explicitly
# private and then read back before a single byte is written.
def must_be_private(kind, rid):
info = api.bucket_info(rid) if kind == "bucket" else api.dataset_info(rid)
if getattr(info, "private", None) is not True:
sys.exit("refusing to back up: " + kind + " " + rid + " is not private")
must_be_private("bucket", SRC)
api.create_repo(DATASET, repo_type="dataset", private=True, exist_ok=True)
must_be_private("dataset", DATASET)
api.create_bucket(STAGING, private=True, exist_ok=True)
must_be_private("bucket", STAGING)
say("destinations verified private: " + DATASET + ", " + STAGING)
def cleanup():
# Left behind, staging is a second full copy of the bucket, so failing to
# remove it is loud rather than silent.
try:
api.delete_bucket(STAGING)
say("staging bucket deleted: " + STAGING)
except Exception as e:
say("WARNING could not delete staging bucket " + STAGING + ": " + str(e))
try:
# ------------------------------------------------------------------ 1. list
# Over the API, never a mount. The mount costs ~8 ms per file: a stat-only
# walk of this bucket took 14m51s for 108,960 files; the same tree lists in
# 8 s here. That gap is the whole reason for this rewrite.
t = time.time()
entries = [f for f in api.list_bucket_tree(SRC, recursive=True) if hasattr(f, "size")]
say("listed " + str(len(entries)) + " files in " + format(time.time() - t, ".1f") + "s")
# ------------------------------------------------------------- 2. the scope
def excluded(path):
q = "/" + path + "/"
return any(("/" + t.strip("/") + "/") in q for t in EXCLUDE)
# Files that exist to hold credentials, matched on the exact final path
# segment rather than as a substring: a substring test for "/credentials"
# sails straight past "/.credentials.json", which is how one got into a probe
# commit before the scrub existed.
CRED_NAMES = {
".credentials.json", ".claude.json", "credentials.json", "auth.json",
"hosts.yml", "token", "stored_tokens", ".netrc", ".env", ".env.local",
"id_rsa", "id_ed25519", "credentials",
}
CRED_DIRS = ("/shell_snapshots/",)
def is_credential(path):
if any(d in "/" + path for d in CRED_DIRS):
return True
return path.rsplit("/", 1)[-1] in CRED_NAMES
keep = []
n_excl = n_cred = n_nohash = 0
for f in entries:
if excluded(f.path):
n_excl += 1
elif is_credential(f.path):
n_cred += 1
elif not f.xet_hash:
n_nohash += 1
else:
keep.append(f)
kb = sum((f.size or 0) for f in keep)
say("scope: keeping " + str(len(keep)) + " files (" + format(kb / 1e9, ".2f") + " GB); skipped "
+ str(n_excl) + " reproducible, " + str(n_cred) + " credential-bearing, "
+ str(n_nohash) + " without a hash")
if not keep:
sys.exit("refusing to commit: the scope matched no files")
# -------------------------------------------------- 3. frozen snapshot copy
# Server-side, by xet hash: metadata only, no bytes, ~880 files/s measured.
# The snapshot is also what makes a run self-consistent. The live bucket is
# written while we work, and reading it directly used to die with "not a file
# on the local file system" when the Space rotated a file away mid-run.
t = time.time()
for i in range(0, len(keep), 2000):
api.batch_bucket_files(
STAGING,
copy=[("bucket", SRC, f.xet_hash, f.path) for f in keep[i:i + 2000]],
)
say("snapshot: " + str(len(keep)) + " paths copied server-side in " + format(time.time() - t, ".0f") + "s")
# ------------------------------------------------------------ 4. sync local
# Local disk, so the walk before the commit is free: 0.14 s for 39,873 files
# against 14m51s for the same work on the mount.
os.makedirs(WORK, exist_ok=True)
t = time.time()
api.sync_bucket("hf://buckets/" + STAGING, WORK)
got = sum(len(fs) for _, _, fs in os.walk(WORK))
say("downloaded " + str(got) + " files in " + format(time.time() - t, ".0f") + "s")
# ---------------------------------------------------------------- 5. scrub
# Tight patterns on purpose: a looser sk-[A-Za-z0-9_-]{24,} matched
# "sk-abstraction-and-chart-selection" in ordinary prose, and a scrub that
# rewrites real content is worse than one that over-reports.
BEF = "(?<![A-Za-z0-9_-])"
AFT = "(?![A-Za-z0-9_-])"
SECRETS = [
("hf", re.compile((BEF + "hf_[A-Za-z0-9]{34}" + AFT).encode())),
("openai", re.compile((BEF + "sk-(?:proj-)?[A-Za-z0-9]{20,}" + AFT).encode())),
("anthropic", re.compile((BEF + "sk-ant-[A-Za-z0-9_-]{24,}" + AFT).encode())),
("github", re.compile((BEF + "gh[pousr]_[A-Za-z0-9]{36}" + AFT).encode())),
("aws", re.compile((BEF + "AKIA[0-9A-Z]{16}" + AFT).encode())),
("google", re.compile((BEF + "AIza[0-9A-Za-z_-]{35}" + AFT).encode())),
]
NUL = bytes([0])
def textfiles():
for root, _, fs in os.walk(WORK):
for fn in fs:
fp = os.path.join(root, fn)
try:
data = open(fp, "rb").read()
except OSError:
continue
if NUL in data[:8192]:
continue
yield fp, data
hits = collections.Counter()
touched = 0
t = time.time()
for fp, data in textfiles():
orig = data
for name, rx in SECRETS:
data, k = rx.subn(b"[REDACTED-SECRET]", data)
if k:
hits[name] += k
if data != orig:
open(fp, "wb").write(data)
touched += 1
say("scrub: redacted " + str(sum(hits.values())) + " secrets in " + str(touched)
+ " files " + str(dict(hits)) + " in " + format(time.time() - t, ".0f") + "s")
# --------------------------------------------------------------- 6. verify
# Mandatory, not a nicety. The Hub's scanner is TruffleHog and it verifies a
# find by authenticating with it, which INVALIDATES a live token. A commit
# that leaks one does not merely fail: it breaks the operator's credentials.
left = [os.path.relpath(fp, WORK) for fp, d in textfiles()
if any(rx.search(d) for _, rx in SECRETS)]
if left:
sys.exit("refusing to commit: secrets still present in " + ", ".join(left[:5])
+ ((" (+" + str(len(left) - 5) + " more)") if len(left) > 5 else ""))
say("verified: no secret patterns remain")
# --------------------------------------------------------------- 7. commit
# delete_patterns makes the newest commit match the scope, while every
# earlier commit keeps since-removed files recoverable.
t = time.time()
api.upload_folder(
folder_path=WORK, repo_id=DATASET, repo_type="dataset", delete_patterns="*",
commit_message="Bucket snapshot " + time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()),
)
say("committed to " + DATASET + " in " + format(time.time() - t, ".0f") + "s")
finally:
cleanup()
EOPY
`;
/**
* The Job id out of `hf jobs run --detach` output, whatever shape it comes in.
*
* This is deliberately format-agnostic. The image installs huggingface_hub
* unpinned, so the CLI here is not the CLI a dev machine has, and a version that
* printed the id differently silently left us with `jobId: null` — which killed
* overlap protection and made the status row report a stage it had invented.
* Accepts `id=<id>`, a bare id on its own line, or one embedded in a job URL.
*/
export function parseJobId(out) {
const text = String(out || '');
const looksLikeId = (s) => /^[a-f0-9]{16,32}$/i.test(s || '');
const tagged = text.match(/\bid=([A-Za-z0-9]+)/);
if (tagged && looksLikeId(tagged[1])) return tagged[1];
const fromUrl = text.match(/\/jobs\/[^\s/]+\/([A-Za-z0-9]+)/);
if (fromUrl && looksLikeId(fromUrl[1])) return fromUrl[1];
for (const line of text.split('\n').map((l) => l.trim()).reverse()) {
const last = line.split(/\s+/).pop();
if (looksLikeId(last)) return last;
}
return null;
}
// Job arguments, as an array (never a shell string). Exported for the tests:
// asserting on this is how we know a config value cannot reach a shell.
export function jobArgs({ source, dataset, staging, exclude = [] }) {
return [
'jobs', 'run', '--detach',
'--name', jobName(),
'--secrets', 'HF_TOKEN',
'-e', `AM_SOURCE=${source}`,
'-e', `AM_DATASET=${dataset}`,
'-e', `AM_STAGING=${staging}`,
// Folder-name tokens, space-joined. They are validated to contain no
// whitespace, so the Job splits them back apart exactly. Tokens, not globs:
// the Job matches them against the API listing itself, so the two-pattern
// glob dance `hf upload --exclude` needed is gone.
'-e', `AM_EXCLUDE=${normalizeExclude(exclude).join(' ')}`,
// No `-v`: nothing is mounted. The pipeline reads the bucket through the API
// and downloads to local disk, which is ~6,000x faster to walk (§3.11).
'--flavor', JOB_FLAVOR,
'--timeout', JOB_TIMEOUT,
JOB_IMAGE, 'bash', '-c', JOB_SCRIPT,
];
}
/**
* Why an on-demand backup cannot run, or null if it can. Deliberately does NOT
* consider the interval: "back up now" is exactly what you want before a risky
* change, without committing to a schedule.
*/
export function runNowBlockedBy() {
if (!hasToken()) return 'needs a write-scoped HF_TOKEN secret on the Space';
if (!sourceBucket()) return 'no bucket is mounted on this Space';
return null;
}
/**
* Why the scheduled backup is not running, or null if it is live. Same reasons
* as on-demand plus the interval, so the timer and the settings row can never
* disagree about it.
*/
export function unavailableReason(cfg) {
return runNowBlockedBy() || ((cfg?.backup?.every || 'never') === 'never' ? 'switched off' : null);
}
/** Resolve the destinations, falling back to <namespace>/<space>-backup. */
async function targets(cfg) {
const d = await defaultsFor();
const dataset = (cfg?.backup?.dataset || d.dataset || '').trim();
const staging = (cfg?.backup?.staging || d.staging || '').trim();
return { dataset, staging, defaults: d, exclude: excludeFromConfig(cfg?.backup?.exclude) };
}
/** Launch one backup Job now. Returns { job } — the Hub does the rest. */
export async function runBackupNow(cfg) {
const blocked = runNowBlockedBy();
if (blocked) throw new Error(blocked);
// Two runs at once would have two Jobs uploading to the same dataset, which
// race. The timer skips for this reason too; on demand it is worth saying out
// loud rather than silently doing nothing.
if (await isRunning()) throw new Error('a backup is already running');
const source = sourceBucket();
const { dataset, staging, exclude } = await targets(cfg);
for (const [label, id] of [['source', source], ['dataset', dataset], ['staging', staging]]) {
if (!validRepoId(id)) throw new Error(`${label} "${id}" is not a valid repo id`);
}
// Nothing is pre-created here. Both destinations are created explicitly
// private INSIDE the Job and then read back before anything is written —
// creating them from two places is how you end up trusting a default.
const out = await run(jobArgs({ source, dataset, staging, exclude }), { timeout: 180_000 });
const job = parseJobId(out);
// A launch we cannot identify still happened — record it, but say so, because
// without an id there is nothing to poll and no overlap to detect.
if (!job) console.warn('[backup] launched a Job but could not parse its id from:', out.slice(0, 200));
saveState({ jobId: job, startedAt: Date.now(), source, dataset, staging, error: null, outcomeFor: null });
return { job };
}
/** Stage and the platform's own message for a Job — "Job timeout" lives in the
* message, and it is the difference between "it broke" and "it never finished". */
async function jobStatus(jobId) {
if (!jobId) return { stage: null, message: null };
try {
const j = JSON.parse(await run(['jobs', 'inspect', jobId, '--json'], { timeout: 30_000 }));
const s = Array.isArray(j) ? j[0] : j;
return { stage: (s && s.status && s.status.stage) || null, message: (s && s.status && s.status.message) || null };
} catch { return { stage: null, message: null }; }
}
/** Terminal state of the last launched Job, or null while it is still going. */
async function jobStage(jobId) {
return (await jobStatus(jobId)).stage;
}
/**
* Why a run failed, in one line, from its own logs.
*
* The platform message says "Job timeout"; the logs say the bucket has a
* `.cache/` path the Hub will not commit. The second is the one that tells an
* operator what to do, so both are kept and this is the one shown first.
*/
async function failureReason(jobId) {
try {
const out = await run(['jobs', 'logs', jobId], { timeout: 90_000 });
const lines = out.split('\n').map((l) => l.trim())
.filter((l) => l && !/^(WARNING|Hint:|\[notice\])/.test(l));
const telling = [...lines].reverse()
.find((l) => /error|refus|denied|invalid|cannot|failed|timeout/i.test(l));
return (telling || lines[lines.length - 1] || '').slice(0, 300) || null;
} catch { return null; }
}
/**
* Record how the last run ended, once, so the answer survives in state instead
* of needing a Hub call to discover.
*
* This is the whole point of the feature: a backup that fails quietly is worse
* than no backup at all, because the operator believes they are covered. Until
* now nothing wrote a failure down — the settings row only learned of one if
* somebody happened to open it while the evidence was still fresh.
*/
async function recordOutcome() {
const st = loadState();
if (!st.jobId || st.outcomeFor === st.jobId) return;
const { stage, message } = await jobStatus(st.jobId);
if (!stage || isActiveStage(stage)) return; // still running, or the Hub did not say
if (stage === 'COMPLETED') {
saveState({ outcomeFor: st.jobId, failures: 0, lastFailure: null, lastSuccessAt: Date.now() });
return;
}
saveState({
outcomeFor: st.jobId,
failures: (st.failures || 0) + 1,
lastFailure: { at: Date.now(), jobId: st.jobId, stage, message: message || null, reason: await failureReason(st.jobId) },
});
console.warn(`[backup] run ${st.jobId} ended ${stage}${message ? ` (${message})` : ''}`);
}
// Stages that mean a run is genuinely in flight. Observed from the Hub:
// SCHEDULING and RUNNING while live, COMPLETED or ERROR once finished.
//
// Deliberately a list of ACTIVE stages rather than terminal ones, so anything
// unrecognised — a new stage name, or `inspect` failing and returning nothing —
// counts as NOT running. Enumerating terminal stages had it the wrong way round:
// an unknown answer read as "running", which is the worse mistake twice over. It
// showed a run in progress that had long finished, and it would have blocked
// every future backup behind a job that no longer exists.
const ACTIVE = new Set(['SCHEDULING', 'RUNNING']);
export const isActiveStage = (stage) => !!stage && ACTIVE.has(stage);
/** Is the last launched Job still going? Unknown counts as finished, so a Hub
* hiccup can never wedge backups off forever. */
async function isRunning() {
const { jobId } = loadState();
if (!jobId) return false;
return isActiveStage(await jobStage(jobId));
}
/**
* One tick: launch a backup if one is due. Deliberately dumb — no cron, no
* catch-up queue. "Due" is just "the last one started more than an interval
* ago", which behaves correctly across restarts because it is derived from the
* timestamp on disk rather than from an in-memory schedule.
*/
export async function tick(cfg) {
// How the last run ended is recorded even when this tick does nothing else —
// otherwise a failure is only noticed by someone opening the settings page.
if (hasToken()) await recordOutcome().catch(() => {});
if (unavailableReason(cfg)) return { skipped: unavailableReason(cfg) };
const state = loadState();
const due = Date.now() - (state.startedAt || 0) >= intervalMs(cfg.backup.every);
if (!due) return { skipped: 'not due' };
// Never let two runs overlap: a big first backup can outlast an interval.
if (await isRunning()) return { skipped: 'previous backup still running' };
try {
const r = await runBackupNow(cfg);
console.log(`[backup] launched job ${r.job} (every ${cfg.backup.every})`);
return r;
} catch (e) {
const error = e.stderr || e.message;
saveState({ error, erroredAt: Date.now() });
console.warn('[backup] launch failed:', error);
return { error };
}
}
/**
* Start the loop. One unref'd timer that asks every 5 minutes whether a backup
* is due; the config is re-read each time, so switching the interval takes
* effect without restarting anything.
*/
/** Note when the schedule was first switched on, so "no success yet" can go
* stale for a backup that has never once worked — which is this Space today. */
export function armStaleClock(cfg) {
if (!intervalMs(cfg?.backup?.every)) return;
const st = loadState();
if (!st.firstArmedAt) saveState({ firstArmedAt: Date.now() });
}
export function startBackupTimer(getConfig) {
armStaleClock(getConfig());
const t = setInterval(() => { armStaleClock(getConfig()); tick(getConfig()).catch(() => {}); }, TICK_MS);
if (t.unref) t.unref();
// First check shortly after boot, once bucket discovery has landed.
const first = setTimeout(() => { tick(getConfig()).catch(() => {}); }, 30_000);
if (first.unref) first.unref();
return () => { clearInterval(t); clearTimeout(first); };
}
/**
* Backup health, from state alone — no Hub calls, because this rides on
* /api/info which every open tab polls every 15 seconds.
*
* Returns null when there is nothing wrong, so the dashboard shows nothing at
* all in the normal case. Two ways to be unwell:
* - `failing`: the last run ended in ERROR (or was killed).
* - `stale`: no successful run in three intervals. A silent stall — the timer
* never firing, the token going bad — leaves an operator just as wrongly
* confident as an outright failure, and looks fine without this.
*/
export function backupHealth(cfg) {
const every = cfg?.backup?.every || 'never';
const ms = intervalMs(every);
if (!ms) return null; // switched off: silence is correct
const st = loadState();
const f = st.lastFailure || null;
const lastSuccessAt = st.lastSuccessAt || null;
const since = lastSuccessAt || st.firstArmedAt || null;
const stale = !!since && Date.now() - since > ms * 3;
if (!f && !stale) return null;
return {
failing: !!f,
stale,
failures: st.failures || 0,
at: f ? f.at : null,
jobId: f ? f.jobId : null,
stage: f ? f.stage : null,
message: f ? f.message || null : null,
reason: f ? f.reason || null : null,
lastSuccessAt,
jobsUrl: jobsUrl(),
};
}
/**
* Status for the settings row. A backup nobody can see the state of is a backup
* nobody trusts — and the destination's privacy is the one thing that must be
* visible, so it is re-read here rather than remembered.
*/
export async function backupStatus(cfg) {
const state = loadState();
const every = cfg?.backup?.every || 'never';
const source = sourceBucket();
const { dataset, staging, defaults, exclude } = await targets(cfg);
// Not gated on the interval: an on-demand backup is a real backup, and its
// result has to be visible even with the schedule off.
const [stage, priv] = await Promise.all([
state.jobId ? jobStage(state.jobId) : null,
dataset && hasToken() ? datasetPrivate(dataset) : null,
]);
return {
every,
source,
dataset,
staging,
defaults,
hasToken: hasToken(),
canRunNow: !runNowBlockedBy(),
running: isActiveStage(stage),
unavailable: unavailableReason(cfg),
last: state.startedAt
? { at: state.startedAt, jobId: state.jobId || null, stage: stage || null }
: null,
jobName: jobName(),
jobsUrl: jobsUrl(),
// The tokens as stored, plus the shipped defaults, so Settings can offer
// "restore defaults" and show whether the current list differs from them.
exclude,
excludeDefaults: [...DEFAULT_EXCLUDE],
excludeIsDefault: exclude.length === DEFAULT_EXCLUDE.length
&& exclude.every((t, i) => t === DEFAULT_EXCLUDE[i]),
health: backupHealth(cfg),
failures: state.failures || 0,
lastFailure: state.lastFailure || null,
lastSuccessAt: state.lastSuccessAt || null,
nextDue: state.startedAt && intervalMs(every) ? state.startedAt + intervalMs(every) : null,
datasetPrivate: priv, // null = unknown or not created yet
error: state.error || null,
};
}
// One small authed read: the settings row needs the CURRENT answer, because a
// repo holding credentials can be flipped public at any time.
async function datasetPrivate(dataset) {
if (!validRepoId(dataset)) return null;
try {
const j = JSON.parse(await run(['datasets', 'info', dataset, '--json'], { timeout: 30_000 }));
return j.private === true;
} catch { return null; }
}