Public: anyone can read this; the crew can change it.
Every platform backup lane names its newest object and its age, read from its store, on the Resilience screen and in MCP backups #8831
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8811-every-platform-backup-lane-names-its-new → main 3 commits fork 1 thread · 1 unresolved
7 files
changed +693
−7
docs/SNAPSHOT.md +4
| @@ -121,6 +121,10 @@ as failed. A put's deadline follows its size: its bytes at a floor of | ||
| 121 | 121 | 5 Mbit/s, plus 60 s. A fixed 180 s once failed every attempt at a 334 MB |
| 122 | 122 | database behind a 15 Mbit/s uplink (#2536). |
| 123 | 123 | |
| 124 | ## Backup lanes | |
| 125 | ||
| 126 | The platform's backup lanes (`FORGE_BACKUP_LANES`) and how each is judged: docs/features/backup-truth.md, "Platform backup lanes". | |
| 127 | ||
| 124 | 128 | ## Progress |
| 125 | 129 | |
| 126 | 130 | A snapshot uploads eight objects at a time. Run logs older than a day go up |
docs/features/backup-truth.md +31 −1
| @@ -6,7 +6,37 @@ | ||
| 6 | 6 | - **Catch-up.** A boot with the newest snapshot older than the last 08:00 UTC slot takes one two minutes later (`snapshotDue`). A snapshot cut short by a release has no manifest, so it counts as lost. |
| 7 | 7 | - **In the terminal.** The Resilience dialog (palette: Resilience, admins) opens with the same verdict, each lane's newest, and every reason. On the web it is home's backup tile and `/settings/backups`; for an agent, `verdict` in the MCP `backups` tool. |
| 8 | 8 | - **Off the serving thread.** `VACUUM INTO` runs in a worker (`vacuum.ts`). |
| 9 | - **Not done, tracked:** platform/K8s lanes on home (#8805); snapshots outside the serving process or resumable (#8828; the off-process probe is below). Catch-up retakes a lost snapshot whole. | |
| 9 | - **Not done, tracked:** snapshots outside the serving process or resumable (#8828; the off-process probe is below). Catch-up retakes a lost snapshot whole. | |
| 10 | ||
| 11 | ## Platform backup lanes (#8811) | |
| 12 | ||
| 13 | Every other place a backup lands — one per CronJob in the `backup` namespace — | |
| 14 | is a lane the platform declares in `FORGE_BACKUP_LANES`, a JSON list kept in | |
| 15 | the forge's configuration, never in this repository: | |
| 16 | ||
| 17 | json | |
| 18 | [{ "name": "backup/postgres", "endpoint": "https://s3.example.com", "bucket": "platform-backups", | |
| 19 | "prefix": "postgres/", "region": "us-east-1", "accessKey": "…", "secretKey": "…" }] | |
| 20 | ||
| 21 | ||
| 22 | - **List-only keys.** `src/snapshot/lanes.ts` only lists; give each lane keys | |
| 23 | that can do nothing else. | |
| 24 | - **Read on the watch's clock.** Each pass reads every lane beside `primary` and | |
| 25 | `mirror`, and the verdict names each one's newest object, size and age. | |
| 26 | - **Bounded, never a full listing.** One delimited listing per folder level (at | |
| 27 | most three pages of 1,000), walking only into the folder whose name sorts | |
| 28 | last, since backup names carry their date. A level wider than that is read | |
| 29 | in part: a fresh object found still proves the lane fresh; a stale one, or | |
| 30 | none, is red and says to name a narrower prefix. | |
| 31 | - **Red when:** the store does not answer within 10 s, nothing is under the | |
| 32 | prefix, or the newest object is over 26 h old. A lane the configuration | |
| 33 | names but that does not read whole is red with what is wrong, never with its | |
| 34 | values. | |
| 35 | - **Nothing unsafe printed.** Errors are scrubbed of keys, and every key or | |
| 36 | error the store wrote has its control bytes replaced with `?` before it | |
| 37 | reaches a terminal or an MCP client. | |
| 38 | - **Where to see it.** MCP `backups` → `verdict.lanes`; the terminal's | |
| 39 | Resilience dialog; home's backup tile reads the same verdict. | |
| 10 | 40 | |
| 11 | 41 | ## The probe outside the forge (#8806, part 1) |
| 12 | 42 |
src/snapshot/bucket.ts +24 −5
| @@ -86,6 +86,13 @@ export function sizeWords(bytes: number): string { | ||
| 86 | 86 | return `${(bytes / 1_073_741_824).toFixed(2)} GiB`; |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | /** | |
| 90 | * Text a store wrote (an object key, its error), safe to print: every C0/C1 | |
| 91 | * control byte and bidi override becomes `?`, so an ESC in a key never | |
| 92 | * reaches a terminal or an MCP client's screen (#8811). | |
| 93 | */ | |
| 94 | export const printable = (text: string): string => text.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "?"); | |
| 95 | ||
| 89 | 96 | /** An error as one short sentence with no key and no presigned query in it. */ |
| 90 | 97 | export function scrubError(targets: readonly (S3Target | undefined)[], error: unknown): string { |
| 91 | 98 | let reason = String(error instanceof Error ? error.message : error); |
| @@ -166,14 +173,19 @@ export function judge( | ||
| 166 | 173 | for (const r of state?.lanes ?? []) { |
| 167 | 174 | let says: string; |
| 168 | 175 | let red = true; |
| 169 | if (r.error !== undefined) says = `${r.lane} (${r.where}) cannot be read: ${r.error}`; | |
| 170 | else if (r.newestAt === null) says = `${r.lane} (${r.where}) holds no snapshot`; | |
| 176 | // Every word a store or the configuration supplied is made printable before it joins the line. | |
| 177 | const lane = printable(r.lane); | |
| 178 | const where = printable(r.where); | |
| 179 | if (r.error !== undefined) says = `${lane} (${where}) cannot be read: ${printable(r.error)}`; | |
| 180 | else if (r.newestAt === null) says = `${lane} (${where}) holds no snapshot`; | |
| 171 | 181 | else { |
| 172 | 182 | const age = now - Date.parse(r.newestAt); |
| 173 | if (!(age <= LATE_MS)) says = `${r.lane} (${r.where}) newest snapshot is ${ageWords(age)} old`; | |
| 183 | // The newest object by name, so a person can find it in the store (#8811). | |
| 184 | const name = r.newestName === null ? "" : ` ${printable(r.newestName)}`; | |
| 185 | if (!(age <= LATE_MS)) says = `${lane} (${where}) newest snapshot${name} is ${ageWords(age)} old`; | |
| 174 | 186 | else { |
| 175 | 187 | red = false; |
| 176 | says = `${r.lane} newest ${ageWords(age)} old, ${sizeWords(r.newestBytes ?? 0)}`; | |
| 188 | says = `${lane} newest${name} ${ageWords(age)} old, ${sizeWords(r.newestBytes ?? 0)}`; | |
| 177 | 189 | } |
| 178 | 190 | } |
| 179 | 191 | if (red) { |
| @@ -248,13 +260,20 @@ export function watchBucket( | ||
| 248 | 260 | firstMs = 5_000, |
| 249 | 261 | /** The credentials in use, struck out of any error a read keeps. */ |
| 250 | 262 | targets: () => readonly (S3Target | undefined)[] = () => [], |
| 263 | /** The platform's own lanes (`lanes.ts`), read after the forge's and judged beside them. */ | |
| 264 | declared: (now: number) => Promise<readonly LaneRead[]> = async () => [], | |
| 251 | 265 | ): { stop: () => void; pass: () => Promise<void> } { |
| 252 | 266 | watching.set(dataDir, new Date().toISOString()); |
| 253 | 267 | let stopped = false; |
| 254 | 268 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 255 | 269 | const pass = async (): Promise<void> => { |
| 256 | 270 | const stores = lanes().filter((l): l is { lane: string; store: Store } => l.store !== undefined); |
| 257 | keepReads(dataDir, await Promise.all(stores.map((l) => readLane(l.lane, l.store, Date.now(), targets)))); | |
| 271 | const now = Date.now(); | |
| 272 | const [own, more] = await Promise.all([ | |
| 273 | Promise.all(stores.map((l) => readLane(l.lane, l.store, now, targets))), | |
| 274 | declared(now).catch(() => []), | |
| 275 | ]); | |
| 276 | keepReads(dataDir, [...own, ...more]); | |
| 258 | 277 | }; |
| 259 | 278 | const next = (ms: number): void => { |
| 260 | 279 | if (stopped) return; |
src/snapshot/lanes.ts added +258
| @@ -0,0 +1,258 @@ | ||
| 1 | /** | |
| 2 | * The platform's backup lanes, read from their stores (#8811). | |
| 3 | * | |
| 4 | * The forge's own lanes (`primary`, `mirror`) are read by manifest in | |
| 5 | * `bucket.ts`. Every other place backups land — one per CronJob in the | |
| 6 | * `backup` namespace — the platform declares in `FORGE_BACKUP_LANES`, a JSON | |
| 7 | * list of `{ name, endpoint, bucket, prefix, region?, accessKey, secretKey }` | |
| 8 | * kept in the forge's configuration, never in this repository, with keys that | |
| 9 | * can only list. This module only lists: it holds no put and no delete. Each | |
| 10 | * lane is read here into `bucket.ts`'s `LaneRead`, the bucket watch keeps it | |
| 11 | * beside the forge's own, and `judge` says it: red when its store does not | |
| 12 | * answer, when nothing is under its prefix, or past 26 h. Home, vitals, MCP | |
| 13 | * `backups` and the terminal's Resilience screen all read that one verdict. | |
| 14 | * | |
| 15 | * Never a full listing. One delimited listing per folder level, at most | |
| 16 | * `PAGES` pages of 1,000 each, and only the folder whose name sorts last is | |
| 17 | * walked into: backup names carry their date (`2026-11-02T…`), so the last | |
| 18 | * name is the newest. The newest object is the one with the latest | |
| 19 | * LastModified among those read. When a level has more than `PAGES` pages | |
| 20 | * the read is bounded: a fresh object found still proves the lane fresh; a | |
| 21 | * stale one or none does not prove it stale, and the lane says so. | |
| 22 | * | |
| 23 | * Credentials never leave: a lane's place is `host/bucket/prefix`, and an | |
| 24 | * error is one line with the lane's keys struck out (`scrubError`). | |
| 25 | */ | |
| 26 | ||
| 27 | import { LATE_MS, printable, scrubError, type LaneRead } from "./bucket.ts"; | |
| 28 | import { isOffBox } from "./off_box.ts"; | |
| 29 | import { s3Where, type S3Target } from "./store.ts"; | |
| 30 | ||
| 31 | /** Pages read at one folder level before the read counts as bounded. */ | |
| 32 | export const PAGES = 3; | |
| 33 | /** Entries asked for per page; S3's own maximum. */ | |
| 34 | export const PAGE_KEYS = 1_000; | |
| 35 | /** Folder levels walked into at most. */ | |
| 36 | export const DEPTH = 6; | |
| 37 | /** How long one lane may take to answer, all its pages together. */ | |
| 38 | export const LANE_DEADLINE_MS = 10_000; | |
| 39 | /** Lanes read from configuration at most. */ | |
| 40 | export const MAX_LANES = 32; | |
| 41 | ||
| 42 | export interface LaneObject { | |
| 43 | readonly key: string; | |
| 44 | readonly bytes: number; | |
| 45 | /** LastModified, ISO 8601. */ | |
| 46 | readonly at: string; | |
| 47 | } | |
| 48 | ||
| 49 | /** One delimited page: the objects at this level and the folders under it. */ | |
| 50 | export interface LanePage { | |
| 51 | readonly objects: readonly LaneObject[]; | |
| 52 | readonly folders: readonly string[]; | |
| 53 | /** Continue with this, when there is more. */ | |
| 54 | readonly next?: string | undefined; | |
| 55 | } | |
| 56 | ||
| 57 | /** A store as a lane sees it: one listing page at a time, nothing else. */ | |
| 58 | export interface Lister { | |
| 59 | /** The store's place, never a key. */ | |
| 60 | readonly where: string; | |
| 61 | page(prefix: string, next?: string): Promise<LanePage>; | |
| 62 | } | |
| 63 | ||
| 64 | /** A lane as declared: where it lists, never yet read. */ | |
| 65 | export interface Lane { | |
| 66 | readonly name: string; | |
| 67 | readonly prefix: string; | |
| 68 | readonly lister: Lister; | |
| 69 | /** Struck out of every error this lane says. */ | |
| 70 | readonly target?: S3Target | undefined; | |
| 71 | } | |
| 72 | ||
| 73 | /** The newest object under `prefix`, walking only the last-named folder at each level; `seen` is how many objects were read. */ | |
| 74 | export async function newestUnder( | |
| 75 | lister: Lister, | |
| 76 | prefix: string, | |
| 77 | ): Promise<{ newest: LaneObject | undefined; bounded: boolean; seen: number }> { | |
| 78 | let newest: LaneObject | undefined; | |
| 79 | let bounded = false; | |
| 80 | let seen = 0; | |
| 81 | let at = prefix; | |
| 82 | for (let depth = 0; depth < DEPTH; depth++) { | |
| 83 | let last: string | undefined; | |
| 84 | let next: string | undefined; | |
| 85 | let pages = 0; | |
| 86 | do { | |
| 87 | const page = await lister.page(at, next); | |
| 88 | pages++; | |
| 89 | seen += page.objects.length; | |
| 90 | for (const o of page.objects) if (newest === undefined || Date.parse(o.at) > Date.parse(newest.at)) newest = o; | |
| 91 | for (const f of page.folders) if (last === undefined || f > last) last = f; | |
| 92 | next = page.next; | |
| 93 | } while (next !== undefined && pages < PAGES); | |
| 94 | if (next !== undefined) bounded = true; | |
| 95 | if (last === undefined) break; | |
| 96 | at = last; | |
| 97 | } | |
| 98 | return { newest, bounded, seen }; | |
| 99 | } | |
| 100 | ||
| 101 | function deadline<T>(work: Promise<T>, ms: number): Promise<T> { | |
| 102 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 103 | const late = new Promise<never>((_, reject) => { | |
| 104 | timer = setTimeout(() => reject(new Error(`no answer in ${Math.round(ms / 1000)} s`)), ms); | |
| 105 | }); | |
| 106 | work.catch(() => {}); | |
| 107 | return Promise.race([work, late]).finally(() => clearTimeout(timer)); | |
| 108 | } | |
| 109 | ||
| 110 | /** Read one lane into the watch's shape. Never throws: a store that refuses is a read with its reason. */ | |
| 111 | export async function readObjectLane(lane: Lane, now: number, deadlineMs = LANE_DEADLINE_MS): Promise<LaneRead> { | |
| 112 | const where = lane.prefix === "" ? lane.lister.where : `${lane.lister.where}/${lane.prefix}`; | |
| 113 | const base = { lane: lane.name, where, offBox: isOffBox({ where: lane.lister.where }), readAt: new Date(now).toISOString() }; | |
| 114 | const none = { newestAt: null, newestName: null, newestBytes: null, count: 0 }; | |
| 115 | let found: Awaited<ReturnType<typeof newestUnder>>; | |
| 116 | try { | |
| 117 | found = await deadline(newestUnder(lane.lister, lane.prefix), deadlineMs); | |
| 118 | } catch (error) { | |
| 119 | // One line whatever the store said: a proxy's refusal ends in a newline. | |
| 120 | return { ...base, ...none, error: printable(scrubError([lane.target], error).replace(/\s+/g, " ").trim()) }; | |
| 121 | } | |
| 122 | const o = found.newest; | |
| 123 | const read = o === undefined ? none : { newestAt: o.at, newestName: o.key, newestBytes: o.bytes, count: found.seen }; | |
| 124 | // A bounded read proves a lane fresh, never stale or empty: it says what it could not see. | |
| 125 | if (found.bounded && (o === undefined || now - Date.parse(o.at) > LATE_MS)) | |
| 126 | return { ...base, ...read, error: `only the first ${PAGES * PAGE_KEYS} entries of a folder were read; name a narrower prefix` }; | |
| 127 | return { ...base, ...read }; | |
| 128 | } | |
| 129 | ||
| 130 | /** A bucket listed one delimited page at a time, one attempt per call. */ | |
| 131 | export function s3Lister(target: S3Target): Lister { | |
| 132 | const client = new Bun.S3Client({ | |
| 133 | endpoint: target.endpoint, | |
| 134 | bucket: target.bucket, | |
| 135 | accessKeyId: target.accessKeyId, | |
| 136 | secretAccessKey: target.secretAccessKey, | |
| 137 | ...(target.region === undefined ? {} : { region: target.region }), | |
| 138 | }); | |
| 139 | return { | |
| 140 | where: s3Where(target), | |
| 141 | async page(prefix, next) { | |
| 142 | const page = await client.list({ | |
| 143 | prefix, | |
| 144 | delimiter: "/", | |
| 145 | maxKeys: PAGE_KEYS, | |
| 146 | ...(next === undefined ? {} : { continuationToken: next }), | |
| 147 | }); | |
| 148 | return { | |
| 149 | objects: (page.contents ?? []).flatMap((c) => { | |
| 150 | const at = c.lastModified === undefined ? NaN : Date.parse(c.lastModified); | |
| 151 | // A key is the store's text: no control byte of it is kept. | |
| 152 | return Number.isFinite(at) ? [{ key: printable(c.key), bytes: c.size ?? 0, at: new Date(at).toISOString() }] : []; | |
| 153 | }), | |
| 154 | folders: (page.commonPrefixes ?? []).map((p) => p.prefix), | |
| 155 | next: page.isTruncated ? page.nextContinuationToken : undefined, | |
| 156 | }; | |
| 157 | }, | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._:/-]{0,63}$/; | |
| 162 | const BUCKET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/; | |
| 163 | ||
| 164 | const text = (v: unknown): string | undefined => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined); | |
| 165 | ||
| 166 | /** One declared lane, or why it is refused; the sentence never carries a value but the name. */ | |
| 167 | function declared(raw: unknown, i: number): { name: string; target: S3Target; prefix: string } | { name: string; refused: string } { | |
| 168 | const o = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}; | |
| 169 | const given = text(o["name"]); | |
| 170 | const name = given !== undefined && NAME.test(given) ? given : `lane ${i + 1}`; | |
| 171 | if (given === undefined || !NAME.test(given)) return { name, refused: "its name is missing or not 1–64 letters, digits, space . _ : / -" }; | |
| 172 | const endpoint = text(o["endpoint"]); | |
| 173 | let url: URL | undefined; | |
| 174 | try { | |
| 175 | url = endpoint === undefined ? undefined : new URL(endpoint); | |
| 176 | } catch { | |
| 177 | url = undefined; | |
| 178 | } | |
| 179 | if (url === undefined || (url.protocol !== "https:" && url.protocol !== "http:") || url.username !== "" || url.password !== "") | |
| 180 | return { name, refused: "its endpoint is not an http(s) URL" }; | |
| 181 | const bucket = text(o["bucket"]); | |
| 182 | if (bucket === undefined || !BUCKET.test(bucket)) return { name, refused: "its bucket is missing or not a bucket name" }; | |
| 183 | const prefix = o["prefix"] === undefined ? "" : o["prefix"]; | |
| 184 | // Control bytes and a leading slash never reach a listing. | |
| 185 | if (typeof prefix !== "string" || prefix.length > 512 || /[\u0000-\u001f\u007f]/.test(prefix) || prefix.startsWith("/")) | |
| 186 | return { name, refused: "its prefix is not a string of at most 512 printable characters" }; | |
| 187 | const accessKeyId = text(o["accessKey"]); | |
| 188 | const secretAccessKey = text(o["secretKey"]); | |
| 189 | if (accessKeyId === undefined || secretAccessKey === undefined) return { name, refused: "its accessKey or secretKey is missing" }; | |
| 190 | const region = o["region"] === undefined ? undefined : text(o["region"]); | |
| 191 | if (o["region"] !== undefined && (region === undefined || !/^[A-Za-z0-9-]{1,32}$/.test(region))) | |
| 192 | return { name, refused: "its region is not a region name" }; | |
| 193 | return { name, prefix, target: { endpoint: url.href.replace(/\/$/, ""), bucket, accessKeyId, secretAccessKey, region } }; | |
| 194 | } | |
| 195 | ||
| 196 | export interface Declared { | |
| 197 | readonly lanes: readonly { name: string; target: S3Target; prefix: string }[]; | |
| 198 | /** Lanes the configuration names but that do not read whole: each a red lane on the screen. */ | |
| 199 | readonly refused: readonly { name: string; says: string }[]; | |
| 200 | } | |
| 201 | ||
| 202 | /** `FORGE_BACKUP_LANES` read at the boundary: every field checked, a bad lane refused by name, never echoed. */ | |
| 203 | export function parseLanes(raw: string | undefined): Declared { | |
| 204 | if (raw === undefined || raw.trim() === "") return { lanes: [], refused: [] }; | |
| 205 | let list: unknown; | |
| 206 | try { | |
| 207 | list = JSON.parse(raw); | |
| 208 | } catch { | |
| 209 | list = undefined; | |
| 210 | } | |
| 211 | if (!Array.isArray(list)) | |
| 212 | return { lanes: [], refused: [{ name: "FORGE_BACKUP_LANES", says: "configuration: not a JSON list of lanes" }] }; | |
| 213 | const lanes: Declared["lanes"][number][] = []; | |
| 214 | const refused: { name: string; says: string }[] = []; | |
| 215 | const seen = new Set<string>(); | |
| 216 | list.slice(0, MAX_LANES).forEach((raw, i) => { | |
| 217 | const d = declared(raw, i); | |
| 218 | if ("refused" in d) refused.push({ name: d.name, says: `configuration: ${d.refused}` }); | |
| 219 | else if (seen.has(d.name)) refused.push({ name: d.name, says: "configuration: a second lane of this name" }); | |
| 220 | else { | |
| 221 | seen.add(d.name); | |
| 222 | lanes.push(d); | |
| 223 | } | |
| 224 | }); | |
| 225 | if (list.length > MAX_LANES) | |
| 226 | refused.push({ name: "FORGE_BACKUP_LANES", says: `configuration: ${list.length} lanes, only the first ${MAX_LANES} read` }); | |
| 227 | return { lanes, refused }; | |
| 228 | } | |
| 229 | ||
| 230 | /** | |
| 231 | * Every declared lane read at once, a lane the configuration refuses kept as | |
| 232 | * a read with its reason. The bucket watch calls this on each pass. | |
| 233 | */ | |
| 234 | export async function readDeclaredLanes( | |
| 235 | env: Record<string, string | undefined> = process.env, | |
| 236 | now = Date.now(), | |
| 237 | /** A lister for a bucket; tests hand a fake. */ | |
| 238 | listerFor: (target: S3Target) => Lister = s3Lister, | |
| 239 | ): Promise<LaneRead[]> { | |
| 240 | const { lanes, refused } = parseLanes(env["FORGE_BACKUP_LANES"]); | |
| 241 | const read = await Promise.all( | |
| 242 | lanes.map((d) => readObjectLane({ name: d.name, prefix: d.prefix, lister: listerFor(d.target), target: d.target }, now)), | |
| 243 | ); | |
| 244 | return [ | |
| 245 | ...read, | |
| 246 | ...refused.map((r) => ({ | |
| 247 | lane: r.name, | |
| 248 | where: "FORGE_BACKUP_LANES", | |
| 249 | offBox: false, | |
| 250 | newestAt: null, | |
| 251 | newestName: null, | |
| 252 | newestBytes: null, | |
| 253 | count: 0, | |
| 254 | error: r.says, | |
| 255 | readAt: new Date(now).toISOString(), | |
| 256 | })), | |
| 257 | ]; | |
| 258 | } |
src/snapshot/service.ts +2
| @@ -25,6 +25,7 @@ import { describeTimes, runDrill, type DrillDeps, type DrillResult } from "./dri | ||
| 25 | 25 | import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS, snapshotDue } from "./due.ts"; |
| 26 | 26 | import { sealedTarget } from "./destination.ts"; |
| 27 | 27 | import { watchBucket } from "./bucket.ts"; |
| 28 | import { readDeclaredLanes } from "./lanes.ts"; | |
| 28 | 29 | import { lastDrill, recordDrill } from "./drills.ts"; |
| 29 | 30 | import { lastMirror, mirrorPass, mirrorTarget, MIRROR_EVERY_MS, recordMirror, type MirrorState } from "./mirror.ts"; |
| 30 | 31 | import { isOffBox } from "./off_box.ts"; |
| @@ -430,6 +431,7 @@ export function startSnapshots( | ||
| 430 | 431 | return [s3FromEnv()]; |
| 431 | 432 | } |
| 432 | 433 | }, |
| 434 | (now) => readDeclaredLanes(process.env, now), | |
| 433 | 435 | ); |
| 434 | 436 | |
| 435 | 437 | const handle: SnapshotsHandle = { |
src/snapshot/store.ts +5 −1
| @@ -184,6 +184,10 @@ export function s3Store(target: S3Target, attempts?: number): Store { | ||
| 184 | 184 | return withRetries(rawS3Store(target), attempts === undefined ? {} : { attempts }); |
| 185 | 185 | } |
| 186 | 186 | |
| 187 | /** A bucket's place for a page and a log: `host[:port]/bucket`, never a key. */ | |
| 188 | export const s3Where = (target: Pick<S3Target, "endpoint" | "bucket">): string => | |
| 189 | `${target.endpoint.replace(/^https?:\/\//, "")}/${target.bucket}`; | |
| 190 | ||
| 187 | 191 | function rawS3Store(target: S3Target): Store { |
| 188 | 192 | const client = new Bun.S3Client({ |
| 189 | 193 | endpoint: target.endpoint, |
| @@ -193,7 +197,7 @@ function rawS3Store(target: S3Target): Store { | ||
| 193 | 197 | ...(target.region === undefined ? {} : { region: target.region }), |
| 194 | 198 | }); |
| 195 | 199 | return { |
| 196 | where: `${target.endpoint.replace(/^https?:\/\//, "")}/${target.bucket}`, | |
| 200 | where: s3Where(target), | |
| 197 | 201 | async put(key, body) { |
| 198 | 202 | await client.write(key, body); |
| 199 | 203 | }, |
test/backup-lanes.test.ts added +369
| @@ -0,0 +1,369 @@ | ||
| 1 | /** | |
| 2 | * Backup lanes (#8811): each platform lane's newest object and age, read | |
| 3 | * from its store with a fake one — fresh, 27 h old, empty, refusing — | |
| 4 | * bounded to the last-named folder, judged by the one bucket verdict home, | |
| 5 | * MCP `backups` and Resilience read, and never a credential in the answer. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { backupVerdict, judge, LATE_MS, watchBucket, type LaneRead } from "../src/snapshot/bucket.ts"; | |
| 17 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 18 | import { | |
| 19 | PAGES, | |
| 20 | newestUnder, | |
| 21 | parseLanes, | |
| 22 | readDeclaredLanes, | |
| 23 | readObjectLane, | |
| 24 | s3Lister, | |
| 25 | type LanePage, | |
| 26 | type Lister, | |
| 27 | } from "../src/snapshot/lanes.ts"; | |
| 28 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 29 | import { verdictLines } from "../src/tui/screens/resilience.ts"; | |
| 30 | ||
| 31 | const NOW = Date.parse("2026-11-02T12:00:00Z"); | |
| 32 | const H = 3_600_000; | |
| 33 | const SECRET = "sk-very-secret-lane-key"; | |
| 34 | const ACCESS = "AKIA-lane-access"; | |
| 35 | ||
| 36 | /** A bucket in memory: keys with times, listed one delimited level at a time, `perPage` entries a page. */ | |
| 37 | function fakeStore(objects: Record<string, { bytes: number; at: number }>, perPage = 1000) { | |
| 38 | const calls: string[] = []; | |
| 39 | const lister: Lister = { | |
| 40 | where: "s3.test/bucket", | |
| 41 | async page(prefix, next) { | |
| 42 | calls.push(prefix); | |
| 43 | const entries = new Map<string, LanePage["objects"][number] | "folder">(); | |
| 44 | for (const [key, o] of Object.entries(objects)) { | |
| 45 | if (!key.startsWith(prefix)) continue; | |
| 46 | const rest = key.slice(prefix.length); | |
| 47 | const slash = rest.indexOf("/"); | |
| 48 | if (slash === -1) entries.set(key, { key, bytes: o.bytes, at: new Date(o.at).toISOString() }); | |
| 49 | else entries.set(prefix + rest.slice(0, slash + 1), "folder"); | |
| 50 | } | |
| 51 | const sorted = [...entries.keys()].sort(); | |
| 52 | const from = next === undefined ? 0 : Number(next); | |
| 53 | const slice = sorted.slice(from, from + perPage); | |
| 54 | return { | |
| 55 | objects: slice.flatMap((k) => { | |
| 56 | const e = entries.get(k)!; | |
| 57 | return e === "folder" ? [] : [e]; | |
| 58 | }), | |
| 59 | folders: slice.filter((k) => entries.get(k) === "folder"), | |
| 60 | next: from + perPage < sorted.length ? String(from + perPage) : undefined, | |
| 61 | }; | |
| 62 | }, | |
| 63 | }; | |
| 64 | return { lister, calls }; | |
| 65 | } | |
| 66 | ||
| 67 | const refusing: Lister = { | |
| 68 | where: "s3.test/bucket", | |
| 69 | async page() { | |
| 70 | throw new Error(`403 Forbidden for ${ACCESS} signed with ${SECRET}\n`); | |
| 71 | }, | |
| 72 | }; | |
| 73 | ||
| 74 | const target = { endpoint: "https://s3.test", bucket: "bucket", accessKeyId: ACCESS, secretAccessKey: SECRET }; | |
| 75 | ||
| 76 | const drill: DrillResult = { | |
| 77 | at: "2026-11-02T09:00:00Z", | |
| 78 | snapshotId: "s", | |
| 79 | ms: 1, | |
| 80 | counts: { repositories: 4, issues: 9, users: 2, pulls: 1, apps: 1 }, | |
| 81 | health: "ok", | |
| 82 | verdict: "green", | |
| 83 | failedStep: null, | |
| 84 | phases: [], | |
| 85 | budgetMs: 1, | |
| 86 | reason: null, | |
| 87 | }; | |
| 88 | /** The one verdict, over these reads, now. */ | |
| 89 | const verdict = (lanes: LaneRead[]) => judge({ lanes, checkedAt: new Date(NOW).toISOString() }, drill, NOW); | |
| 90 | ||
| 91 | describe("one lane", () => { | |
| 92 | test("a fresh lane is green and names its newest object, size and age", async () => { | |
| 93 | const { lister } = fakeStore({ | |
| 94 | "pg/2026-11-01.sql.gz": { bytes: 2048, at: NOW - 27 * H }, | |
| 95 | "pg/2026-11-02.sql.gz": { bytes: 3 * 1024 * 1024, at: NOW - 3 * H }, | |
| 96 | }); | |
| 97 | const r = await readObjectLane({ name: "backup/postgres", prefix: "pg/", lister }, NOW); | |
| 98 | expect(r).toMatchObject({ | |
| 99 | lane: "backup/postgres", | |
| 100 | where: "s3.test/bucket/pg/", | |
| 101 | offBox: true, | |
| 102 | newestName: "pg/2026-11-02.sql.gz", | |
| 103 | newestBytes: 3 * 1024 * 1024, | |
| 104 | newestAt: new Date(NOW - 3 * H).toISOString(), | |
| 105 | }); | |
| 106 | const v = verdict([r]); | |
| 107 | expect(v.state).toBe("green"); | |
| 108 | expect(v.lanes[0]!.says).toBe("backup/postgres newest pg/2026-11-02.sql.gz 3 h old, 3.0 MiB"); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a lane 27 h old is red and says it is late, naming the object it found", async () => { | |
| 112 | const { lister } = fakeStore({ "pg/old.sql.gz": { bytes: 100, at: NOW - 27 * H } }); | |
| 113 | const v = verdict([await readObjectLane({ name: "backup/postgres", prefix: "pg/", lister }, NOW)]); | |
| 114 | expect(v.state).toBe("red"); | |
| 115 | expect(v.reasons).toEqual(["Backup/postgres (s3.test/bucket/pg/) newest snapshot pg/old.sql.gz is 27 h old."]); | |
| 116 | }); | |
| 117 | ||
| 118 | test("exactly 26 h old is green; a minute past is red", async () => { | |
| 119 | const at = (ms: number) => fakeStore({ "pg/x.sql": { bytes: 1, at: NOW - ms } }).lister; | |
| 120 | const edge = await readObjectLane({ name: "edge", prefix: "pg/", lister: at(LATE_MS) }, NOW); | |
| 121 | expect(verdict([edge]).lanes[0]!.state).toBe("green"); | |
| 122 | const past = await readObjectLane({ name: "edge", prefix: "pg/", lister: at(LATE_MS + 60_000) }, NOW); | |
| 123 | expect(verdict([past]).lanes[0]!.state).toBe("red"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("an ESC or other control byte a store wrote never reaches the line", () => { | |
| 127 | const base = { lane: "backup/x", where: "s3.test/b", offBox: true, newestBytes: 1, count: 1, readAt: new Date(NOW).toISOString() }; | |
| 128 | const v = verdict([ | |
| 129 | { ...base, newestAt: new Date(NOW - H).toISOString(), newestName: "pg/\u001b[2J\u001b]0;owned\u0007dump\u202e.sql" }, | |
| 130 | { ...base, newestAt: null, newestName: null, error: "denied \u001b[31mred\u009b" }, | |
| 131 | ]); | |
| 132 | const text = JSON.stringify([v.lanes.map((l) => l.says), v.reasons]); | |
| 133 | expect(text).not.toMatch(/\\u00(1b|07|9b)|\\u202e/); | |
| 134 | expect(v.lanes[0]!.says).toBe("backup/x newest pg/?[2J?]0;owned?dump?.sql 1 h old, 1 B"); | |
| 135 | expect(v.lanes[1]!.says).toBe("backup/x (s3.test/b) cannot be read: denied ?[31mred?"); | |
| 136 | }); | |
| 137 | ||
| 138 | test("an empty lane is red and says it holds nothing", async () => { | |
| 139 | const { lister } = fakeStore({ "other/x": { bytes: 1, at: NOW } }); | |
| 140 | const r = await readObjectLane({ name: "backup/redis", prefix: "redis/", lister }, NOW); | |
| 141 | expect(r.newestAt).toBeNull(); | |
| 142 | expect(verdict([r]).reasons).toEqual(["Backup/redis (s3.test/bucket/redis/) holds no snapshot."]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a store that refuses is red, says so on one line, and never carries its keys", async () => { | |
| 146 | const r = await readObjectLane({ name: "backup/postgres", prefix: "pg/", lister: refusing, target }, NOW); | |
| 147 | expect(r.error).toBe("403 Forbidden for … signed with …"); | |
| 148 | const v = verdict([r]); | |
| 149 | expect(v.state).toBe("red"); | |
| 150 | expect(v.reasons[0]).toContain("cannot be read: 403 Forbidden"); | |
| 151 | expect(JSON.stringify(v)).not.toContain(SECRET); | |
| 152 | expect(JSON.stringify(v)).not.toContain(ACCESS); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a store that never answers is red within the lane's deadline", async () => { | |
| 156 | const hung: Lister = { where: "s3.test/bucket", page: () => new Promise(() => {}) }; | |
| 157 | const r = await readObjectLane({ name: "slow", prefix: "", lister: hung }, NOW, 20); | |
| 158 | expect(r.error).toContain("no answer"); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("bounded: never a full listing", () => { | |
| 163 | test("only the last-named folder is walked into, one page per level", async () => { | |
| 164 | const objects: Record<string, { bytes: number; at: number }> = {}; | |
| 165 | for (let d = 1; d <= 30; d++) { | |
| 166 | const day = `2026-10-${String(d).padStart(2, "0")}`; | |
| 167 | for (const part of ["db", "repos", "manifest.json"]) objects[`backups/${day}/${part}`] = { bytes: d, at: NOW - (31 - d) * H }; | |
| 168 | } | |
| 169 | const { lister, calls } = fakeStore(objects); | |
| 170 | const r = await readObjectLane({ name: "velero", prefix: "backups/", lister }, NOW); | |
| 171 | expect(calls).toEqual(["backups/", "backups/2026-10-30/"]); | |
| 172 | expect(r.newestName?.startsWith("backups/2026-10-30/")).toBe(true); | |
| 173 | expect(r.error).toBeUndefined(); | |
| 174 | }); | |
| 175 | ||
| 176 | test("a level wider than its pages stops at the bound, and a stale find there says so", async () => { | |
| 177 | const objects: Record<string, { bytes: number; at: number }> = {}; | |
| 178 | for (let i = 0; i < 50; i++) objects[`flat/${String(i).padStart(3, "0")}`] = { bytes: 1, at: NOW - 40 * H + i * 1000 }; | |
| 179 | const { lister, calls } = fakeStore(objects, 10); | |
| 180 | const found = await newestUnder(lister, "flat/"); | |
| 181 | expect(calls).toHaveLength(PAGES); | |
| 182 | expect(found.bounded).toBe(true); | |
| 183 | const r = await readObjectLane({ name: "flat", prefix: "flat/", lister }, NOW); | |
| 184 | expect(r.error).toContain("name a narrower prefix"); | |
| 185 | }); | |
| 186 | }); | |
| 187 | ||
| 188 | describe("configuration", () => { | |
| 189 | const lane = { name: "backup/postgres", endpoint: "https://s3.test", bucket: "backups", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET }; | |
| 190 | ||
| 191 | test("a whole lane reads; nothing set is no lanes", () => { | |
| 192 | expect(parseLanes(undefined)).toEqual({ lanes: [], refused: [] }); | |
| 193 | const got = parseLanes(JSON.stringify([lane])); | |
| 194 | expect(got.refused).toEqual([]); | |
| 195 | expect(got.lanes).toEqual([ | |
| 196 | { name: "backup/postgres", prefix: "pg/", target: { endpoint: "https://s3.test", bucket: "backups", accessKeyId: ACCESS, secretAccessKey: SECRET, region: undefined } }, | |
| 197 | ]); | |
| 198 | }); | |
| 199 | ||
| 200 | test("a lane that does not read whole is refused by name, and no value of it is said", () => { | |
| 201 | const got = parseLanes( | |
| 202 | JSON.stringify([ | |
| 203 | { ...lane, endpoint: "file:///etc/passwd" }, | |
| 204 | { ...lane, name: "nokey", secretKey: "" }, | |
| 205 | { ...lane, name: "ctl", prefix: "a\u0000b" }, | |
| 206 | { ...lane, name: "creds", endpoint: `https://${ACCESS}:${SECRET}@s3.test` }, | |
| 207 | lane, | |
| 208 | lane, | |
| 209 | ]), | |
| 210 | ); | |
| 211 | expect(got.lanes.map((l) => l.name)).toEqual(["backup/postgres"]); | |
| 212 | expect(got.refused.map((r) => r.name)).toEqual(["backup/postgres", "nokey", "ctl", "creds", "backup/postgres"]); | |
| 213 | expect(JSON.stringify(got.refused)).not.toContain(SECRET); | |
| 214 | expect(parseLanes("{not json").refused[0]!.says).toBe("configuration: not a JSON list of lanes"); | |
| 215 | }); | |
| 216 | ||
| 217 | test("readDeclaredLanes reads every declared lane; a refused one is a red read with its reason", async () => { | |
| 218 | const { lister } = fakeStore({ "pg/a.sql": { bytes: 5, at: NOW - H } }); | |
| 219 | const read = await readDeclaredLanes( | |
| 220 | { FORGE_BACKUP_LANES: JSON.stringify([lane, { ...lane, name: "broken", bucket: "" }]) }, | |
| 221 | NOW, | |
| 222 | () => lister, | |
| 223 | ); | |
| 224 | const v = verdict(read); | |
| 225 | expect(v.lanes.map((l) => [l.lane, l.state])).toEqual([ | |
| 226 | ["backup/postgres", "green"], | |
| 227 | ["broken", "red"], | |
| 228 | ]); | |
| 229 | expect(v.lanes[1]!.says).toContain("configuration: its bucket is missing"); | |
| 230 | expect(JSON.stringify(v)).not.toContain(SECRET); | |
| 231 | }); | |
| 232 | }); | |
| 233 | ||
| 234 | describe("the bucket watch reads the declared lanes beside the forge's own", () => { | |
| 235 | let dir = ""; | |
| 236 | let app: App; | |
| 237 | let servers: Servers; | |
| 238 | let admin = ""; | |
| 239 | beforeAll(async () => { | |
| 240 | dir = await mkdtemp(join(tmpdir(), "forge-lanes-watch-")); | |
| 241 | app = createApp({ dataDir: dir, port: 0 }); | |
| 242 | servers = await serve(app); | |
| 243 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-lanes-passphrase", isAdmin: true }); | |
| 244 | if (!made.ok) throw new Error(made.error); | |
| 245 | admin = createToken(app.db, made.value.id, "t", "ro").token; | |
| 246 | }); | |
| 247 | afterAll(async () => { | |
| 248 | servers.stop(); | |
| 249 | app.close(); | |
| 250 | await rm(dir, { recursive: true, force: true }); | |
| 251 | }); | |
| 252 | ||
| 253 | test("one pass: primary and every platform lane in the verdict home, MCP and Resilience read", async () => { | |
| 254 | const { lister } = fakeStore({ "pg/a.sql": { bytes: 5, at: Date.now() - 27 * H } }); | |
| 255 | const w = watchBucket( | |
| 256 | dir, | |
| 257 | () => [{ lane: "primary", store: dirStore(join(dir, "store")) }], | |
| 258 | 3_600_000, | |
| 259 | 3_600_000, | |
| 260 | () => [], | |
| 261 | (now) => readDeclaredLanes({ FORGE_BACKUP_LANES: JSON.stringify([{ name: "backup/postgres", endpoint: "https://s3.test", bucket: "b", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET }]) }, now, () => lister), | |
| 262 | ); | |
| 263 | try { | |
| 264 | await w.pass(); | |
| 265 | const v = backupVerdict(dir, drill); | |
| 266 | expect(v.lanes.map((l) => [l.lane, l.state])).toEqual([ | |
| 267 | ["primary", "red"], | |
| 268 | ["backup/postgres", "red"], | |
| 269 | ]); | |
| 270 | const lines = verdictLines({ app } as never); | |
| 271 | // The dialog wraps at its width; the words are what count. | |
| 272 | expect(lines.join(" ").replace(/\s+/g, " ")).toContain("! backup/postgres (s3.test/bucket/pg/) newest snapshot pg/a.sql is 27 h old"); | |
| 273 | expect(lines.join("\n")).not.toContain(SECRET); | |
| 274 | } finally { | |
| 275 | w.stop(); | |
| 276 | } | |
| 277 | }); | |
| 278 | ||
| 279 | test("MCP backups returns the platform lane in verdict.lanes, with its newest object and no key", async () => { | |
| 280 | const { lister } = fakeStore({ "pg/2026-11-02.sql.gz": { bytes: 2048, at: Date.now() - 2 * H } }); | |
| 281 | const w = watchBucket(dir, () => [], 3_600_000, 3_600_000, () => [], (now) => | |
| 282 | readDeclaredLanes({ FORGE_BACKUP_LANES: JSON.stringify([{ name: "backup/postgres", endpoint: "https://s3.test", bucket: "b", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET }]) }, now, () => lister), | |
| 283 | ); | |
| 284 | try { | |
| 285 | await w.pass(); | |
| 286 | const res = await fetch(`${servers.url}/mcp`, { | |
| 287 | method: "POST", | |
| 288 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${admin}` }, | |
| 289 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "backups", arguments: {} } }), | |
| 290 | }); | |
| 291 | const text = ((await res.json()) as any).result.content[0].text as string; | |
| 292 | const lane = JSON.parse(text).verdict.lanes.find((l: { lane: string }) => l.lane === "backup/postgres"); | |
| 293 | expect(lane).toMatchObject({ state: "green", where: "s3.test/bucket/pg/", newestName: "pg/2026-11-02.sql.gz", newestBytes: 2048 }); | |
| 294 | expect(lane.says).toBe("backup/postgres newest pg/2026-11-02.sql.gz 2 h old, 2.0 KiB"); | |
| 295 | expect(text).not.toContain(SECRET); | |
| 296 | expect(text).not.toContain(ACCESS); | |
| 297 | } finally { | |
| 298 | w.stop(); | |
| 299 | } | |
| 300 | }); | |
| 301 | ||
| 302 | test("a declared reader that throws costs the pass nothing: the forge's own lanes are still kept", async () => { | |
| 303 | const w = watchBucket(dir, () => [{ lane: "primary", store: dirStore(join(dir, "store")) }], 3_600_000, 3_600_000, () => [], () => | |
| 304 | Promise.reject(new Error("boom")), | |
| 305 | ); | |
| 306 | try { | |
| 307 | await w.pass(); | |
| 308 | expect(backupVerdict(dir, drill).lanes.map((l) => l.lane)).toEqual(["primary"]); | |
| 309 | } finally { | |
| 310 | w.stop(); | |
| 311 | } | |
| 312 | }); | |
| 313 | }); | |
| 314 | ||
| 315 | describe("the S3 lister asks for one delimited page", () => { | |
| 316 | const realS3 = Bun.S3Client; | |
| 317 | const asked: Record<string, unknown>[] = []; | |
| 318 | let made: Record<string, unknown> | undefined; | |
| 319 | let refuse = false; | |
| 320 | beforeAll(() => { | |
| 321 | (Bun as any).S3Client = class { | |
| 322 | constructor(options: Record<string, unknown>) { | |
| 323 | made = options; | |
| 324 | } | |
| 325 | async list(options: Record<string, unknown>) { | |
| 326 | asked.push(options); | |
| 327 | if (refuse) throw new Error(`AccessDenied: key ${ACCESS} refused`); | |
| 328 | if (options["prefix"] === "pg/") | |
| 329 | return { | |
| 330 | contents: [{ key: "pg/a.sql", lastModified: new Date(NOW - 2 * H).toISOString(), size: 42 }], | |
| 331 | commonPrefixes: [{ prefix: "pg/2026-11-01/" }, { prefix: "pg/2026-11-02/" }], | |
| 332 | isTruncated: false, | |
| 333 | }; | |
| 334 | if (options["prefix"] === "esc/") | |
| 335 | return { contents: [{ key: "esc/\u001b[2Jdump.sql", lastModified: new Date(NOW - H).toISOString(), size: 1 }], isTruncated: false }; | |
| 336 | if (options["prefix"] === "pg/2026-11-02/") | |
| 337 | return { contents: [{ key: "pg/2026-11-02/dump.sql", lastModified: new Date(NOW - H).toISOString(), size: 4096 }], isTruncated: false }; | |
| 338 | return { isTruncated: false }; | |
| 339 | } | |
| 340 | }; | |
| 341 | }); | |
| 342 | afterAll(() => { | |
| 343 | (Bun as any).S3Client = realS3; | |
| 344 | }); | |
| 345 | ||
| 346 | test("newest found by walking the last folder; delimiter and 1,000 keys asked each time", async () => { | |
| 347 | const r = await readObjectLane({ name: "postgres", prefix: "pg/", lister: s3Lister(target), target }, NOW); | |
| 348 | expect(made).toMatchObject({ endpoint: "https://s3.test", bucket: "bucket" }); | |
| 349 | expect(r).toMatchObject({ newestName: "pg/2026-11-02/dump.sql", newestBytes: 4096, newestAt: new Date(NOW - H).toISOString() }); | |
| 350 | expect(r.error).toBeUndefined(); | |
| 351 | expect(asked).toEqual([ | |
| 352 | { prefix: "pg/", delimiter: "/", maxKeys: 1000 }, | |
| 353 | { prefix: "pg/2026-11-02/", delimiter: "/", maxKeys: 1000 }, | |
| 354 | ]); | |
| 355 | }); | |
| 356 | ||
| 357 | test("a key holding an ESC is kept with the byte replaced", async () => { | |
| 358 | const r = await readObjectLane({ name: "esc", prefix: "esc/", lister: s3Lister(target), target }, NOW); | |
| 359 | expect(r.newestName).toBe("esc/?[2Jdump.sql"); | |
| 360 | }); | |
| 361 | ||
| 362 | test("a refusal is red, and the access key it echoes is struck out", async () => { | |
| 363 | refuse = true; | |
| 364 | const r = await readObjectLane({ name: "postgres", prefix: "pg/", lister: s3Lister(target), target }, NOW); | |
| 365 | expect(r.error).toBe("AccessDenied: key … refused"); | |
| 366 | expect(JSON.stringify(r)).not.toContain(ACCESS); | |
| 367 | }); | |
| 368 | }); | |
| 369 |
new above
offline
newestName is a key written by the store, so it can hold control bytes; sanitise before it joins the line that the terminal and MCP print.