@indigoai-us/hq-cli 5.345.138 → 5.345.139
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/commands/storage-offload.d.ts +44 -11
- package/dist/commands/storage-offload.js +238 -76
- package/dist/commands/storage.d.ts +12 -1
- package/dist/commands/storage.js +44 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.139] — 2026-10-15
|
|
6
|
+
- `hq storage offload` and `hq storage fetch` no longer fail about 15 to 25 minutes into a run. Personal and company
|
|
7
|
+
vault credentials are now re-vended before they expire, for the whole run and in the middle of a multipart upload.
|
|
8
|
+
When S3 still refuses a request's credentials, HQ re-vends once and retries that request once. A request refused
|
|
9
|
+
again fails, and an interrupted company upload resumes from its last finished part on the next run.
|
|
10
|
+
- Vault errors now name the request, the vault, the HTTP status and the error code, for example
|
|
11
|
+
`HEAD failed in company vault 'acme': HTTP 403 Forbidden (no error body: credentials expired or access denied)`,
|
|
12
|
+
instead of a bare `UnknownError`.
|
|
13
|
+
|
|
5
14
|
## [5.345.138] — 2026-10-15
|
|
6
15
|
- `hq storage offload --current` can now free company files larger than the 50 MB sync limit. It uploads each one to its company vault, checks the vault copy against the file's sha256 and size, records it as cloud-only, writes the `.hqcloud` placeholder, and only then removes the local copy. An interrupted upload leaves the file in place and resumes on the next run. The dry run shows `upload+offload` with the byte count and the vault key. A path the `.hqinclude` allowlist excludes is refused with the line to add. `hq storage fetch` checks the vault copy's sha256 before restoring it. The upload path turns on once the bundled hq-cloud includes the new upload exports; until then these files report that HQ sync needs an update.
|
|
7
16
|
|
|
@@ -130,12 +130,54 @@ export interface BlobStore {
|
|
|
130
130
|
export interface OffloadS3Client {
|
|
131
131
|
send(cmd: PutObjectCommand | HeadObjectCommand | GetObjectCommand): Promise<unknown>;
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* True when S3 refused the request's credentials: an expired, invalid or
|
|
135
|
+
* denied token, or a 403 with no error body (what S3 sends for HEAD).
|
|
136
|
+
*/
|
|
137
|
+
export declare function isCredentialRejection(err: unknown): boolean;
|
|
138
|
+
/** A failed vault request, described with its HTTP status, error code and vault. */
|
|
139
|
+
export declare class VaultRequestError extends Error {
|
|
140
|
+
readonly status?: number;
|
|
141
|
+
readonly code?: string;
|
|
142
|
+
constructor(message: string, opts: {
|
|
143
|
+
status?: number;
|
|
144
|
+
code?: string;
|
|
145
|
+
cause: unknown;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Describes an AWS error from `op` against `where` so the user sees the HTTP
|
|
150
|
+
* status, the error code and the vault, never the SDK's bare "UnknownError".
|
|
151
|
+
* Errors that did not come from AWS pass through unchanged.
|
|
152
|
+
*/
|
|
153
|
+
export declare function describeVaultError(err: unknown, op: string, where: string, retried?: boolean): unknown;
|
|
154
|
+
/** Turns any per-file failure into one line for the run's error list. */
|
|
155
|
+
export declare function failureMessage(err: unknown): string;
|
|
156
|
+
/**
|
|
157
|
+
* Runs vault requests on `initial` and, when S3 refuses the credentials,
|
|
158
|
+
* re-vends once and retries the request once on the new client. Requests
|
|
159
|
+
* refused on the same client share one re-vend. A request refused again
|
|
160
|
+
* after the re-vend fails with a described error; it is never retried twice.
|
|
161
|
+
*/
|
|
162
|
+
export declare function credentialRetry<C>(initial: C, where: string, revend?: () => Promise<C>): <T>(op: string, fn: (client: C) => Promise<T>) => Promise<T>;
|
|
163
|
+
/**
|
|
164
|
+
* Wraps every method of `initial` (a multipart client) so each call gets
|
|
165
|
+
* {@link credentialRetry}: one re-vend and one retry when S3 refuses the
|
|
166
|
+
* credentials, and a described error otherwise.
|
|
167
|
+
*/
|
|
168
|
+
export declare function credentialRetryingClient<C extends object>(initial: C, where: string, revend: () => Promise<C>): C;
|
|
169
|
+
export interface S3StoreOptions<C> {
|
|
170
|
+
/** How errors name the vault, e.g. "personal vault" or "company vault 'acme'". */
|
|
171
|
+
where?: string;
|
|
172
|
+
/** Vends fresh credentials and returns a client that uses them. */
|
|
173
|
+
revend?: () => Promise<C>;
|
|
174
|
+
}
|
|
133
175
|
/**
|
|
134
176
|
* S3-backed store over the caller's personal vault bucket. Accepts only
|
|
135
177
|
* personal vault buckets (`hq-vault-prs-*`) so a mis-resolved entity can never
|
|
136
178
|
* receive offloaded bytes.
|
|
137
179
|
*/
|
|
138
|
-
export declare function s3BlobStore(bucket: string, s3: OffloadS3Client): BlobStore;
|
|
180
|
+
export declare function s3BlobStore(bucket: string, s3: OffloadS3Client, opts?: S3StoreOptions<OffloadS3Client>): BlobStore;
|
|
139
181
|
/** Read-only access to vault objects at their sync keys, pinned to an ETag. */
|
|
140
182
|
export interface VaultObjectStore {
|
|
141
183
|
/**
|
|
@@ -153,7 +195,7 @@ export interface VaultObjectStore {
|
|
|
153
195
|
export interface VaultObjectS3Client {
|
|
154
196
|
send(cmd: GetObjectCommand | HeadObjectCommand): Promise<unknown>;
|
|
155
197
|
}
|
|
156
|
-
export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client): VaultObjectStore;
|
|
198
|
+
export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client, opts?: S3StoreOptions<VaultObjectS3Client>): VaultObjectStore;
|
|
157
199
|
export interface OffloadDeps {
|
|
158
200
|
hqRoot: string;
|
|
159
201
|
now: () => Date;
|
|
@@ -484,13 +526,4 @@ export declare function getOffloadStatus(deps: OffloadDeps, opts: {
|
|
|
484
526
|
export declare function gitExportBlob(hqRoot: string): (sha: string, dest: string) => Promise<void>;
|
|
485
527
|
export declare function gitListPlaceholders(hqRoot: string): () => Promise<string[]>;
|
|
486
528
|
export declare function defaultTmpDir(): string;
|
|
487
|
-
export declare function personalS3Store(input: {
|
|
488
|
-
bucket: string;
|
|
489
|
-
region: string;
|
|
490
|
-
credentials: {
|
|
491
|
-
accessKeyId: string;
|
|
492
|
-
secretAccessKey: string;
|
|
493
|
-
sessionToken: string;
|
|
494
|
-
};
|
|
495
|
-
}): BlobStore;
|
|
496
529
|
//# sourceMappingURL=storage-offload.d.ts.map
|
|
@@ -50,7 +50,7 @@ import * as fs from "node:fs";
|
|
|
50
50
|
import * as os from "node:os";
|
|
51
51
|
import * as path from "node:path";
|
|
52
52
|
import { spawn, execFile } from "node:child_process";
|
|
53
|
-
import {
|
|
53
|
+
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3";
|
|
54
54
|
import { checkPreconditions, coreHasFlag, coreSupports, dropExpiredBackups, PRECONDITION_FALLBACK, coreKeepsUndo, reclaimFields, reclaimWindowHours, runCoreScript, } from "./storage-core.js";
|
|
55
55
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
56
56
|
export const PLACEHOLDER_SUFFIX = ".hqcloud";
|
|
@@ -211,103 +211,268 @@ function isNotFound(err) {
|
|
|
211
211
|
const e = err;
|
|
212
212
|
return e?.name === "NotFound" || e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404;
|
|
213
213
|
}
|
|
214
|
+
// ── Vault request errors and credential refresh ─────────────────────────────
|
|
215
|
+
/** Error codes S3 and STS return when the request's credentials are refused. */
|
|
216
|
+
const CREDENTIAL_ERROR_HINTS = {
|
|
217
|
+
ExpiredToken: "credentials expired",
|
|
218
|
+
ExpiredTokenException: "credentials expired",
|
|
219
|
+
TokenRefreshRequired: "credentials expired",
|
|
220
|
+
RequestExpired: "credentials expired",
|
|
221
|
+
InvalidToken: "credentials rejected",
|
|
222
|
+
InvalidAccessKeyId: "credentials rejected",
|
|
223
|
+
InvalidSecurityToken: "credentials rejected",
|
|
224
|
+
InvalidClientTokenId: "credentials rejected",
|
|
225
|
+
AccessDenied: "access denied",
|
|
226
|
+
};
|
|
227
|
+
/** Placeholder names the SDK uses when the response carried no error code. */
|
|
228
|
+
const NO_CODE = new Set(["", "Unknown", "UnknownError", "Error"]);
|
|
229
|
+
function awsErrorCode(err) {
|
|
230
|
+
const candidates = [err.Code, err.code, err.name, err.$response?.headers?.["x-amz-error-code"]];
|
|
231
|
+
return candidates.find((c) => typeof c === "string" && !NO_CODE.has(c));
|
|
232
|
+
}
|
|
233
|
+
function isAwsError(err) {
|
|
234
|
+
if (!err || typeof err !== "object")
|
|
235
|
+
return false;
|
|
236
|
+
const e = err;
|
|
237
|
+
return typeof e.$metadata === "object" || typeof e.$fault === "string" || e.name === "Unknown";
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* True when S3 refused the request's credentials: an expired, invalid or
|
|
241
|
+
* denied token, or a 403 with no error body (what S3 sends for HEAD).
|
|
242
|
+
*/
|
|
243
|
+
export function isCredentialRejection(err) {
|
|
244
|
+
if (!isAwsError(err))
|
|
245
|
+
return false;
|
|
246
|
+
const status = err.$metadata?.httpStatusCode;
|
|
247
|
+
const code = awsErrorCode(err);
|
|
248
|
+
if (code)
|
|
249
|
+
return code in CREDENTIAL_ERROR_HINTS && (status === undefined || status === 400 || status === 403);
|
|
250
|
+
return status === 403;
|
|
251
|
+
}
|
|
252
|
+
/** A failed vault request, described with its HTTP status, error code and vault. */
|
|
253
|
+
export class VaultRequestError extends Error {
|
|
254
|
+
status;
|
|
255
|
+
code;
|
|
256
|
+
constructor(message, opts) {
|
|
257
|
+
super(message, { cause: opts.cause });
|
|
258
|
+
this.name = "VaultRequestError";
|
|
259
|
+
this.status = opts.status;
|
|
260
|
+
this.code = opts.code;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Describes an AWS error from `op` against `where` so the user sees the HTTP
|
|
265
|
+
* status, the error code and the vault, never the SDK's bare "UnknownError".
|
|
266
|
+
* Errors that did not come from AWS pass through unchanged.
|
|
267
|
+
*/
|
|
268
|
+
export function describeVaultError(err, op, where, retried = false) {
|
|
269
|
+
if (err instanceof VaultRequestError || !isAwsError(err))
|
|
270
|
+
return err;
|
|
271
|
+
const status = err.$metadata?.httpStatusCode;
|
|
272
|
+
const code = awsErrorCode(err);
|
|
273
|
+
let what;
|
|
274
|
+
if (code) {
|
|
275
|
+
const hint = CREDENTIAL_ERROR_HINTS[code];
|
|
276
|
+
what = hint ? `${code} (${hint})` : code;
|
|
277
|
+
}
|
|
278
|
+
else if (status === 403) {
|
|
279
|
+
what = "Forbidden (no error body: credentials expired or access denied)";
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
what = "error with no error code";
|
|
283
|
+
}
|
|
284
|
+
let message = `${op} failed in ${where}: ${status ? `HTTP ${status} ` : ""}${what}`;
|
|
285
|
+
if (retried)
|
|
286
|
+
message += ", after re-vending credentials once";
|
|
287
|
+
const detail = err.message?.trim();
|
|
288
|
+
if (detail && !NO_CODE.has(detail) && detail !== code)
|
|
289
|
+
message += `: ${detail}`;
|
|
290
|
+
return new VaultRequestError(message, { status, code, cause: err });
|
|
291
|
+
}
|
|
292
|
+
/** Turns any per-file failure into one line for the run's error list. */
|
|
293
|
+
export function failureMessage(err) {
|
|
294
|
+
const described = describeVaultError(err, "Request", "the vault");
|
|
295
|
+
return described instanceof Error ? described.message : String(described);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Runs vault requests on `initial` and, when S3 refuses the credentials,
|
|
299
|
+
* re-vends once and retries the request once on the new client. Requests
|
|
300
|
+
* refused on the same client share one re-vend. A request refused again
|
|
301
|
+
* after the re-vend fails with a described error; it is never retried twice.
|
|
302
|
+
*/
|
|
303
|
+
export function credentialRetry(initial, where, revend) {
|
|
304
|
+
let client = initial;
|
|
305
|
+
let generation = 0;
|
|
306
|
+
let pending;
|
|
307
|
+
return async function call(op, fn) {
|
|
308
|
+
const used = generation;
|
|
309
|
+
try {
|
|
310
|
+
return await fn(client);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (!revend || !isCredentialRejection(err))
|
|
314
|
+
throw describeVaultError(err, op, where);
|
|
315
|
+
if (generation === used) {
|
|
316
|
+
pending ??= revend()
|
|
317
|
+
.then((fresh) => {
|
|
318
|
+
client = fresh;
|
|
319
|
+
generation += 1;
|
|
320
|
+
})
|
|
321
|
+
.finally(() => {
|
|
322
|
+
pending = undefined;
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
await pending;
|
|
326
|
+
}
|
|
327
|
+
catch (vendErr) {
|
|
328
|
+
const refused = describeVaultError(err, op, where);
|
|
329
|
+
throw new Error(`${refused.message}; re-vending credentials for ${where} failed: ${vendErr?.message ?? String(vendErr)}`, { cause: vendErr });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
return await fn(client);
|
|
334
|
+
}
|
|
335
|
+
catch (again) {
|
|
336
|
+
throw describeVaultError(again, op, where, true);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const CLIENT_METHOD_OPS = {
|
|
342
|
+
create: "CreateMultipartUpload",
|
|
343
|
+
uploadPart: "UploadPart",
|
|
344
|
+
listParts: "ListParts",
|
|
345
|
+
complete: "CompleteMultipartUpload",
|
|
346
|
+
abort: "AbortMultipartUpload",
|
|
347
|
+
head: "HeadObject",
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Wraps every method of `initial` (a multipart client) so each call gets
|
|
351
|
+
* {@link credentialRetry}: one re-vend and one retry when S3 refuses the
|
|
352
|
+
* credentials, and a described error otherwise.
|
|
353
|
+
*/
|
|
354
|
+
export function credentialRetryingClient(initial, where, revend) {
|
|
355
|
+
const call = credentialRetry(initial, where, revend);
|
|
356
|
+
const wrapped = {};
|
|
357
|
+
for (const name of Object.keys(initial)) {
|
|
358
|
+
if (typeof initial[name] !== "function")
|
|
359
|
+
continue;
|
|
360
|
+
wrapped[name] = (...args) => call(CLIENT_METHOD_OPS[name] ?? name, (c) => c[name].apply(c, args));
|
|
361
|
+
}
|
|
362
|
+
return { ...initial, ...wrapped };
|
|
363
|
+
}
|
|
364
|
+
function pipeToNewFile(body, dest) {
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
const out = fs.createWriteStream(dest, { flags: "wx" });
|
|
367
|
+
body.on("error", reject);
|
|
368
|
+
out.on("error", reject);
|
|
369
|
+
out.on("finish", () => resolve());
|
|
370
|
+
body.pipe(out);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
214
373
|
/**
|
|
215
374
|
* S3-backed store over the caller's personal vault bucket. Accepts only
|
|
216
375
|
* personal vault buckets (`hq-vault-prs-*`) so a mis-resolved entity can never
|
|
217
376
|
* receive offloaded bytes.
|
|
218
377
|
*/
|
|
219
|
-
export function s3BlobStore(bucket, s3) {
|
|
378
|
+
export function s3BlobStore(bucket, s3, opts = {}) {
|
|
220
379
|
if (!bucket || !bucket.startsWith(PERSONAL_BUCKET_PREFIX)) {
|
|
221
380
|
throw new Error(`Refusing to offload into '${bucket}': offload only writes to your personal vault.`);
|
|
222
381
|
}
|
|
382
|
+
const call = credentialRetry(s3, opts.where ?? `vault '${bucket}'`, opts.revend);
|
|
223
383
|
return {
|
|
224
384
|
vault: "personal",
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
385
|
+
head(key) {
|
|
386
|
+
return call("HEAD", async (client) => {
|
|
387
|
+
try {
|
|
388
|
+
const res = (await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })));
|
|
389
|
+
const fromChecksum = res.ChecksumSHA256 && !res.ChecksumSHA256.includes("-")
|
|
390
|
+
? Buffer.from(res.ChecksumSHA256, "base64").toString("hex")
|
|
391
|
+
: undefined;
|
|
392
|
+
// The server-computed checksum wins; client metadata is a fallback for
|
|
393
|
+
// objects stored without one.
|
|
394
|
+
return { size: res.ContentLength ?? -1, sha256: fromChecksum ?? res.Metadata?.sha256 };
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
if (isNotFound(err))
|
|
398
|
+
return null;
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
240
402
|
},
|
|
241
403
|
async put(key, file, digest) {
|
|
242
|
-
await
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
404
|
+
await call("PUT", async (client) => {
|
|
405
|
+
// A fresh stream per attempt: a refused attempt may have read from the last one.
|
|
406
|
+
const body = fs.createReadStream(file);
|
|
407
|
+
try {
|
|
408
|
+
await client.send(new PutObjectCommand({
|
|
409
|
+
Bucket: bucket,
|
|
410
|
+
Key: key,
|
|
411
|
+
Body: body,
|
|
412
|
+
ContentLength: digest.size,
|
|
413
|
+
ChecksumSHA256: Buffer.from(digest.sha256, "hex").toString("base64"),
|
|
414
|
+
StorageClass: "STANDARD",
|
|
415
|
+
Metadata: { sha256: digest.sha256, "git-blob": digest.gitBlob },
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
body.destroy();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
251
422
|
},
|
|
252
423
|
async download(key, dest) {
|
|
253
|
-
|
|
424
|
+
// Only the request is retried: the destination is created by the pipe below.
|
|
425
|
+
const res = await call("GET", async (client) => (await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))));
|
|
254
426
|
const body = res.Body;
|
|
255
427
|
if (!body)
|
|
256
428
|
throw new Error(`Empty download for ${key}.`);
|
|
257
|
-
await
|
|
258
|
-
const out = fs.createWriteStream(dest, { flags: "wx" });
|
|
259
|
-
body.on("error", reject);
|
|
260
|
-
out.on("error", reject);
|
|
261
|
-
out.on("finish", () => resolve());
|
|
262
|
-
body.pipe(out);
|
|
263
|
-
});
|
|
429
|
+
await pipeToNewFile(body, dest);
|
|
264
430
|
},
|
|
265
431
|
};
|
|
266
432
|
}
|
|
267
|
-
export function s3VaultObjectStore(bucket, s3) {
|
|
433
|
+
export function s3VaultObjectStore(bucket, s3, opts = {}) {
|
|
434
|
+
const call = credentialRetry(s3, opts.where ?? `vault '${bucket}'`, opts.revend);
|
|
268
435
|
return {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
436
|
+
head(key) {
|
|
437
|
+
return call("HEAD", async (client) => {
|
|
438
|
+
try {
|
|
439
|
+
const res = (await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key })));
|
|
440
|
+
const head = {
|
|
441
|
+
size: res.ContentLength ?? -1,
|
|
442
|
+
etag: normalizeEtag(res.ETag),
|
|
443
|
+
};
|
|
444
|
+
const sha = res.Metadata?.[VAULT_SHA256_METADATA_KEY];
|
|
445
|
+
if (sha && HEX64.test(sha))
|
|
446
|
+
head.sha256 = sha;
|
|
447
|
+
return head;
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
if (isNotFound(err))
|
|
451
|
+
return null;
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
});
|
|
286
455
|
},
|
|
287
456
|
async download(key, dest, etag) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
catch (err) {
|
|
293
|
-
const e = err;
|
|
294
|
-
if (e?.name === "PreconditionFailed" || e?.$metadata?.httpStatusCode === 412) {
|
|
295
|
-
throw new Error("The cloud copy of this file changed since HQ sync last saw it. Run `hq sync`, then try again.");
|
|
457
|
+
// Only the request is retried: the destination is created by the pipe below.
|
|
458
|
+
const res = await call("GET", async (client) => {
|
|
459
|
+
try {
|
|
460
|
+
return (await client.send(new GetObjectCommand({ Bucket: bucket, Key: key, IfMatch: `"${normalizeEtag(etag)}"` })));
|
|
296
461
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
462
|
+
catch (err) {
|
|
463
|
+
const e = err;
|
|
464
|
+
if (e?.name === "PreconditionFailed" || e?.$metadata?.httpStatusCode === 412) {
|
|
465
|
+
throw new Error("The cloud copy of this file changed since HQ sync last saw it. Run `hq sync`, then try again.");
|
|
466
|
+
}
|
|
467
|
+
if (isNotFound(err))
|
|
468
|
+
throw new Error(`This file is no longer in the cloud (${key}).`);
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
301
472
|
const body = res.Body;
|
|
302
473
|
if (!body)
|
|
303
474
|
throw new Error(`Empty download for ${key}.`);
|
|
304
|
-
await
|
|
305
|
-
const out = fs.createWriteStream(dest, { flags: "wx" });
|
|
306
|
-
body.on("error", reject);
|
|
307
|
-
out.on("error", reject);
|
|
308
|
-
out.on("finish", () => resolve());
|
|
309
|
-
body.pipe(out);
|
|
310
|
-
});
|
|
475
|
+
await pipeToNewFile(body, dest);
|
|
311
476
|
return normalizeEtag(res.ETag);
|
|
312
477
|
},
|
|
313
478
|
};
|
|
@@ -879,7 +1044,7 @@ async function runCurrent(opts, deps, result) {
|
|
|
879
1044
|
current.freed_bytes += plan.bytes;
|
|
880
1045
|
}
|
|
881
1046
|
catch (err) {
|
|
882
|
-
result.errors.push(`${plan.path}: ${err
|
|
1047
|
+
result.errors.push(`${plan.path}: ${failureMessage(err)}`);
|
|
883
1048
|
}
|
|
884
1049
|
}
|
|
885
1050
|
return "ok";
|
|
@@ -1023,7 +1188,7 @@ export async function runOffload(opts, deps) {
|
|
|
1023
1188
|
});
|
|
1024
1189
|
}
|
|
1025
1190
|
catch (err) {
|
|
1026
|
-
result.errors.push(`${blob.paths[0] ?? blob.sha}: ${err
|
|
1191
|
+
result.errors.push(`${blob.paths[0] ?? blob.sha}: ${failureMessage(err)}`);
|
|
1027
1192
|
}
|
|
1028
1193
|
}
|
|
1029
1194
|
if (manifest.length === 0)
|
|
@@ -1393,7 +1558,4 @@ export function gitListPlaceholders(hqRoot) {
|
|
|
1393
1558
|
export function defaultTmpDir() {
|
|
1394
1559
|
return os.tmpdir();
|
|
1395
1560
|
}
|
|
1396
|
-
export function personalS3Store(input) {
|
|
1397
|
-
return s3BlobStore(input.bucket, new S3Client({ region: input.region, credentials: input.credentials }));
|
|
1398
|
-
}
|
|
1399
1561
|
//# sourceMappingURL=storage-offload.js.map
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { Command } from "commander";
|
|
37
37
|
import { type ReclaimReport } from "./storage-core.js";
|
|
38
|
-
import { ListObjectVersionsCommand, DeleteObjectsCommand, type ListObjectVersionsCommandOutput, type DeleteObjectsCommandOutput } from "@aws-sdk/client-s3";
|
|
38
|
+
import { S3Client, ListObjectVersionsCommand, DeleteObjectsCommand, type ListObjectVersionsCommandOutput, type DeleteObjectsCommandOutput } from "@aws-sdk/client-s3";
|
|
39
39
|
import { type OffloadResult, type OffloadStatus } from "./storage-offload.js";
|
|
40
40
|
export type TrancheId = "7d" | "30d" | "90d" | "365d" | "older";
|
|
41
41
|
export declare const TRANCHES: readonly {
|
|
@@ -265,6 +265,17 @@ export declare const NEEDS_YES = "No terminal to confirm in. Re-run with --yes t
|
|
|
265
265
|
export declare function confirmOnTty(message: string, input?: NodeJS.ReadableStream & {
|
|
266
266
|
isTTY?: boolean;
|
|
267
267
|
}, output?: NodeJS.WritableStream): Promise<boolean>;
|
|
268
|
+
/**
|
|
269
|
+
* An S3 client for one vault whose credentials are re-vended before they
|
|
270
|
+
* expire, plus `revend`, which vends now and returns a client on the new
|
|
271
|
+
* credentials (the SDK caches credentials per client, so a forced refresh
|
|
272
|
+
* needs a new client).
|
|
273
|
+
*/
|
|
274
|
+
export declare function vendedS3(vend: () => Promise<VendedCredentials>, bucket: string, makeClient?: (credentials: ReturnType<typeof refreshingVendedCredentials>) => S3Client): {
|
|
275
|
+
bucket: string;
|
|
276
|
+
client: S3Client;
|
|
277
|
+
revend: () => Promise<S3Client>;
|
|
278
|
+
};
|
|
268
279
|
/** STS credentials from one vend, with the time they stop working. */
|
|
269
280
|
export interface VendedCredentials {
|
|
270
281
|
accessKeyId: string;
|
package/dist/commands/storage.js
CHANGED
|
@@ -48,7 +48,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
|
|
|
48
48
|
import { findHqRoot } from "../utils/manifest.js";
|
|
49
49
|
import { maxLength } from "../utils/table-width.js";
|
|
50
50
|
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
51
|
-
import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders,
|
|
51
|
+
import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders, credentialRetryingClient, resolveMinBytes, runFetch, runFetchGitBlob, runOffload, s3BlobStore, s3VaultObjectStore, syncExcludesOffload, } from "./storage-offload.js";
|
|
52
52
|
export const TRANCHES = [
|
|
53
53
|
{ id: "7d", label: "Last 7 days", maxAgeDays: 7 },
|
|
54
54
|
{ id: "30d", label: "8-30 days", maxAgeDays: 30 },
|
|
@@ -1018,8 +1018,14 @@ async function buildOffloadDeps() {
|
|
|
1018
1018
|
}
|
|
1019
1019
|
if (!entity.bucketName)
|
|
1020
1020
|
throw new Error("Your personal vault has no storage yet. Run `hq onboard`.");
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1021
|
+
const uid = entity.uid;
|
|
1022
|
+
// A run can outlast one STS session, so every client re-vends before the
|
|
1023
|
+
// credentials expire, and once more when S3 refuses them anyway.
|
|
1024
|
+
return vendedS3(async () => {
|
|
1025
|
+
const { vault } = await signedInVault();
|
|
1026
|
+
const vend = await vault.sts.vendSelf({ personUid: uid });
|
|
1027
|
+
return { ...vend.credentials, expiresAt: vend.expiresAt };
|
|
1028
|
+
}, entity.bucketName);
|
|
1023
1029
|
};
|
|
1024
1030
|
const activeCompanies = async () => {
|
|
1025
1031
|
const { token } = await signedInVault();
|
|
@@ -1037,7 +1043,13 @@ async function buildOffloadDeps() {
|
|
|
1037
1043
|
throw new Error(`You are not an active member of '${slug}'.`);
|
|
1038
1044
|
const { vault } = await signedInVault();
|
|
1039
1045
|
const entity = await vault.entity.get(company.uid);
|
|
1040
|
-
|
|
1046
|
+
const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
|
|
1047
|
+
const s3 = vendedS3(async () => {
|
|
1048
|
+
const { vault } = await signedInVault();
|
|
1049
|
+
const vend = await vault.sts.vend({ companyUid: company.uid });
|
|
1050
|
+
return { ...vend.credentials, expiresAt: vend.expiresAt };
|
|
1051
|
+
}, bucket);
|
|
1052
|
+
return { company, ...s3 };
|
|
1041
1053
|
};
|
|
1042
1054
|
// Company upload, the ignore explainer and the cloud-only journal row ship
|
|
1043
1055
|
// in a newer @indigoai-us/hq-cloud. Until the bundled one exports them,
|
|
@@ -1059,8 +1071,8 @@ async function buildOffloadDeps() {
|
|
|
1059
1071
|
tmpDir: defaultTmpDir,
|
|
1060
1072
|
syncExcludesOffload: () => syncExcludesOffload(PERSONAL_VAULT_EXCLUDED_TOP_LEVEL),
|
|
1061
1073
|
openPersonal: async () => {
|
|
1062
|
-
const { bucket,
|
|
1063
|
-
return
|
|
1074
|
+
const { bucket, client, revend } = await personalVault();
|
|
1075
|
+
return s3BlobStore(bucket, client, { where: "personal vault", revend });
|
|
1064
1076
|
},
|
|
1065
1077
|
activeCompanySlugs: async () => (await activeCompanies()).map((c) => c.slug),
|
|
1066
1078
|
// Current-file offload replaces a file with a placeholder only when the
|
|
@@ -1086,13 +1098,11 @@ async function buildOffloadDeps() {
|
|
|
1086
1098
|
// come from the existing membership-scoped /sts/vend path.
|
|
1087
1099
|
openVaultObjects: async (target) => {
|
|
1088
1100
|
if (target.vault === "personal") {
|
|
1089
|
-
const { bucket,
|
|
1090
|
-
return s3VaultObjectStore(bucket,
|
|
1101
|
+
const { bucket, client, revend } = await personalVault();
|
|
1102
|
+
return s3VaultObjectStore(bucket, client, { where: "personal vault", revend });
|
|
1091
1103
|
}
|
|
1092
|
-
const { company, bucket } = await companyVault(target.slug);
|
|
1093
|
-
|
|
1094
|
-
const vend = await vault.sts.vend({ companyUid: company.uid });
|
|
1095
|
-
return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials }));
|
|
1104
|
+
const { company, bucket, client, revend } = await companyVault(target.slug);
|
|
1105
|
+
return s3VaultObjectStore(bucket, client, { where: `company vault '${company.slug}'`, revend });
|
|
1096
1106
|
},
|
|
1097
1107
|
syncMaxFileBytes: typeof cloud.SYNC_MAX_FILE_BYTES === "number" ? cloud.SYNC_MAX_FILE_BYTES : undefined,
|
|
1098
1108
|
syncExclusion: explainIgnore
|
|
@@ -1106,16 +1116,13 @@ async function buildOffloadDeps() {
|
|
|
1106
1116
|
// since a multi-GB upload can outlast one STS session).
|
|
1107
1117
|
openCompanyUploader: uploadLarge && multipartClient && recordOffloaded
|
|
1108
1118
|
? async (slug) => {
|
|
1109
|
-
const { company, bucket } = await companyVault(slug);
|
|
1119
|
+
const { company, bucket, client: s3, revend } = await companyVault(slug);
|
|
1110
1120
|
if (bucket.startsWith("hq-vault-prs-")) {
|
|
1111
1121
|
throw new Error(`'${bucket}' is a personal vault, not the company vault for '${slug}'.`);
|
|
1112
1122
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
return { ...vend.credentials, expiresAt: vend.expiresAt };
|
|
1117
|
-
});
|
|
1118
|
-
const client = multipartClient(new S3Client({ region: DEFAULT_COGNITO.region, credentials }), bucket);
|
|
1123
|
+
// Each multipart request re-vends once if S3 still refuses the
|
|
1124
|
+
// credentials; a part refused twice leaves the upload resumable.
|
|
1125
|
+
const client = credentialRetryingClient(multipartClient(s3, bucket), `company vault '${company.slug}'`, async () => multipartClient(await revend(), bucket));
|
|
1119
1126
|
return {
|
|
1120
1127
|
slug: company.slug,
|
|
1121
1128
|
bucket,
|
|
@@ -1133,6 +1140,24 @@ async function buildOffloadDeps() {
|
|
|
1133
1140
|
: undefined,
|
|
1134
1141
|
};
|
|
1135
1142
|
}
|
|
1143
|
+
/**
|
|
1144
|
+
* An S3 client for one vault whose credentials are re-vended before they
|
|
1145
|
+
* expire, plus `revend`, which vends now and returns a client on the new
|
|
1146
|
+
* credentials (the SDK caches credentials per client, so a forced refresh
|
|
1147
|
+
* needs a new client).
|
|
1148
|
+
*/
|
|
1149
|
+
export function vendedS3(vend, bucket, makeClient = (credentials) => new S3Client({ region: DEFAULT_COGNITO.region, credentials })) {
|
|
1150
|
+
return {
|
|
1151
|
+
bucket,
|
|
1152
|
+
client: makeClient(refreshingVendedCredentials(vend)),
|
|
1153
|
+
revend: async () => {
|
|
1154
|
+
const credentials = refreshingVendedCredentials(vend);
|
|
1155
|
+
// Vend now, so a failed vend is reported as one rather than as a refused request.
|
|
1156
|
+
await credentials();
|
|
1157
|
+
return makeClient(credentials);
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1136
1161
|
/**
|
|
1137
1162
|
* AWS credential provider that vends fresh credentials on first use and again
|
|
1138
1163
|
* whenever the previous ones are within `marginMs` of expiring. Each vend calls
|