mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-08-24 17:44:58 -05:00
Merge pull request #113 from misenhower/codex/archive-pruner
Some checks failed
Update query hashes / check-and-create-pr (push) Has been cancelled
Build frontend / build (22.x) (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (22.x) (push) Has been cancelled
Some checks failed
Update query hashes / check-and-create-pr (push) Has been cancelled
Build frontend / build (22.x) (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (22.x) (push) Has been cancelled
Add archive pruning and verification commands
This commit is contained in:
147
app/data/ArchiveBuilder.mjs
Normal file
147
app/data/ArchiveBuilder.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import prefixedConsole from '../common/prefixedConsole.mjs';
|
||||
import { createArchiveManifest } from './ArchiveManifest.mjs';
|
||||
import TarZstdWriter from './TarZstdWriter.mjs';
|
||||
|
||||
const downloadLimit = 5;
|
||||
|
||||
export default class ArchiveBuilder
|
||||
{
|
||||
constructor(s3Client, archiveWriter = new TarZstdWriter) {
|
||||
this._client = s3Client;
|
||||
this.archiveWriter = archiveWriter;
|
||||
}
|
||||
|
||||
async build(candidate, objects) {
|
||||
let temporaryDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), `splatoon3ink-${candidate.date}-`),
|
||||
);
|
||||
let sourceDirectory = path.join(temporaryDirectory, 'source');
|
||||
let archivePath = path.join(temporaryDirectory, `${candidate.date}.tar.zst`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(sourceDirectory);
|
||||
this.console.log(`Downloading ${objects.length} files for ${candidate.date}`);
|
||||
let files = await this.downloadObjects(objects, sourceDirectory);
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
this.console.log(`Compressing ${candidate.date}`);
|
||||
await this.archiveWriter.write(sourceDirectory, archivePath, files.map(file => file.path));
|
||||
|
||||
let archive = {
|
||||
path: candidate.archiveKey,
|
||||
bytes: (await fs.stat(archivePath)).size,
|
||||
hash: `sha256:${await this.hashFile(archivePath)}`,
|
||||
};
|
||||
let manifest = createArchiveManifest(candidate.date, archive, files);
|
||||
|
||||
await this.upload(candidate.archiveKey, await fs.readFile(archivePath), 'application/zstd');
|
||||
await this.upload(
|
||||
candidate.manifestKey,
|
||||
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`),
|
||||
'application/json',
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(temporaryDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
get console() {
|
||||
this._console ??= prefixedConsole('Archive Builder');
|
||||
|
||||
return this._console;
|
||||
}
|
||||
|
||||
get s3Client() {
|
||||
return this._client ??= new S3Client({
|
||||
endpoint: process.env.AWS_S3_ENDPOINT,
|
||||
region: process.env.AWS_REGION,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async downloadObjects(objects, sourceDirectory) {
|
||||
let files = [];
|
||||
let nextObject = 0;
|
||||
let error;
|
||||
let worker = async () => {
|
||||
while (!error && nextObject < objects.length) {
|
||||
let index = nextObject++;
|
||||
try {
|
||||
files[index] = await this.downloadObject(objects[index], sourceDirectory);
|
||||
} catch (e) {
|
||||
error ??= e;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let workers = Array.from({ length: Math.min(downloadLimit, objects.length) }, worker);
|
||||
await Promise.all(workers);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async downloadObject(object, sourceDirectory) {
|
||||
let destination = path.join(sourceDirectory, object.path);
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
|
||||
let response = await this.s3Client.send(new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
Key: object.key,
|
||||
}));
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3 returned no body for ${object.key}`);
|
||||
}
|
||||
|
||||
await pipeline(response.Body, createWriteStream(destination, { flags: 'wx' }));
|
||||
let bytes = (await fs.stat(destination)).size;
|
||||
if (bytes !== object.bytes) {
|
||||
throw new Error(`Downloaded size does not match S3 listing for ${object.key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: object.path,
|
||||
bytes,
|
||||
hash: `sha256:${await this.hashFile(destination)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async hashFile(file) {
|
||||
let hash = crypto.createHash('sha256');
|
||||
for await (let chunk of createReadStream(file)) {
|
||||
hash.update(chunk);
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async upload(key, body, contentType) {
|
||||
await this.s3Client.send(new PutObjectCommand({
|
||||
ACL: 'public-read',
|
||||
Body: body,
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
ContentLength: body.length,
|
||||
ContentType: contentType,
|
||||
Key: key,
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import prefixedConsole from '../common/prefixedConsole.mjs';
|
||||
import { createArchiveManifest } from './ArchiveManifest.mjs';
|
||||
import TarZstdWriter from './TarZstdWriter.mjs';
|
||||
import ArchiveBuilder from './ArchiveBuilder.mjs';
|
||||
import ArchiveInventory from './ArchiveInventory.mjs';
|
||||
|
||||
const downloadLimit = 5;
|
||||
const quietPeriod = 30 * 60 * 1000;
|
||||
|
||||
export function compressArchives(maxDays = Infinity, dryRun = false) {
|
||||
@@ -54,9 +42,10 @@ export default class ArchiveCompressor
|
||||
dryRun = false;
|
||||
maxDays = Infinity;
|
||||
|
||||
constructor(s3Client, archiveWriter = new TarZstdWriter) {
|
||||
constructor(s3Client, archiveBuilder, archiveInventory) {
|
||||
this._client = s3Client;
|
||||
this.archiveWriter = archiveWriter;
|
||||
this._archiveBuilder = archiveBuilder;
|
||||
this._archiveInventory = archiveInventory;
|
||||
}
|
||||
|
||||
async process() {
|
||||
@@ -134,6 +123,14 @@ export default class ArchiveCompressor
|
||||
});
|
||||
}
|
||||
|
||||
get archiveBuilder() {
|
||||
return this._archiveBuilder ??= new ArchiveBuilder(this.s3Client);
|
||||
}
|
||||
|
||||
get archiveInventory() {
|
||||
return this._archiveInventory ??= new ArchiveInventory(this.s3Client);
|
||||
}
|
||||
|
||||
// Archive compression
|
||||
|
||||
async previewDate(candidate) {
|
||||
@@ -156,44 +153,14 @@ export default class ArchiveCompressor
|
||||
return false;
|
||||
}
|
||||
|
||||
let temporaryDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), `splatoon3ink-${candidate.date}-`),
|
||||
);
|
||||
let sourceDirectory = path.join(temporaryDirectory, 'source');
|
||||
let archivePath = path.join(temporaryDirectory, `${candidate.date}.tar.zst`);
|
||||
this.console.log(`Creating ${candidate.archiveKey}`);
|
||||
await this.archiveBuilder.build(candidate, objects);
|
||||
|
||||
try {
|
||||
await fs.mkdir(sourceDirectory);
|
||||
this.console.log(`Downloading ${objects.length} files for ${candidate.date}`);
|
||||
let files = await this.downloadObjects(candidate.prefix, objects, sourceDirectory);
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
this.console.log(`Compressing ${candidate.date}`);
|
||||
await this.archiveWriter.write(sourceDirectory, archivePath, files.map(file => file.path));
|
||||
|
||||
let archive = {
|
||||
path: candidate.archiveKey,
|
||||
bytes: (await fs.stat(archivePath)).size,
|
||||
hash: `sha256:${await this.hashFile(archivePath)}`,
|
||||
};
|
||||
let manifest = createArchiveManifest(candidate.date, archive, files);
|
||||
|
||||
this.console.log(`Uploading ${candidate.archiveKey}`);
|
||||
await this.upload(candidate.archiveKey, await fs.readFile(archivePath), 'application/zstd');
|
||||
await this.upload(
|
||||
candidate.manifestKey,
|
||||
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`),
|
||||
'application/json',
|
||||
);
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
await fs.rm(temporaryDirectory, { force: true, recursive: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getReadyObjects(candidate) {
|
||||
let objects = await this.getSourceObjects(candidate.prefix);
|
||||
let objects = await this.archiveInventory.getSourceObjects(candidate.prefix);
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -208,179 +175,13 @@ export default class ArchiveCompressor
|
||||
return objects;
|
||||
}
|
||||
|
||||
async downloadObjects(prefix, objects, sourceDirectory) {
|
||||
let files = [];
|
||||
let nextObject = 0;
|
||||
let error;
|
||||
let worker = async () => {
|
||||
while (!error && nextObject < objects.length) {
|
||||
let index = nextObject++;
|
||||
try {
|
||||
files[index] = await this.downloadObject(prefix, objects[index], sourceDirectory);
|
||||
} catch (e) {
|
||||
error ??= e;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let workers = Array.from({ length: Math.min(downloadLimit, objects.length) }, worker);
|
||||
await Promise.all(workers);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async downloadObject(prefix, object, sourceDirectory) {
|
||||
let relativePath = this.relativePath(prefix, object.key);
|
||||
let destination = path.join(sourceDirectory, relativePath);
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
|
||||
let response = await this.s3Client.send(new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
Key: object.key,
|
||||
}));
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3 returned no body for ${object.key}`);
|
||||
}
|
||||
|
||||
await pipeline(response.Body, createWriteStream(destination, { flags: 'wx' }));
|
||||
let bytes = (await fs.stat(destination)).size;
|
||||
if (bytes !== object.bytes) {
|
||||
throw new Error(`Downloaded size does not match S3 listing for ${object.key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relativePath,
|
||||
bytes,
|
||||
hash: `sha256:${await this.hashFile(destination)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async hashFile(file) {
|
||||
let hash = crypto.createHash('sha256');
|
||||
for await (let chunk of createReadStream(file)) {
|
||||
hash.update(chunk);
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async upload(key, body, contentType) {
|
||||
await this.s3Client.send(new PutObjectCommand({
|
||||
ACL: 'public-read',
|
||||
Body: body,
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
ContentLength: body.length,
|
||||
ContentType: contentType,
|
||||
Key: key,
|
||||
}));
|
||||
}
|
||||
|
||||
// S3 discovery
|
||||
|
||||
async getCandidates() {
|
||||
let candidates = [];
|
||||
let years = (await this.list('', '/')).prefixes.filter(prefix => /^\d{4}\/$/.test(prefix));
|
||||
let today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
for (let year of years.sort()) {
|
||||
let months = (await this.list(year, '/')).prefixes
|
||||
.filter(prefix => /^\d{4}\/\d{2}\/$/.test(prefix));
|
||||
|
||||
for (let month of months.sort()) {
|
||||
let listing = await this.list(month, '/');
|
||||
let existing = new Set(listing.objects.map(object => object.Key));
|
||||
|
||||
for (let prefix of listing.prefixes.sort()) {
|
||||
let date = this.dateFromPrefix(prefix);
|
||||
if (!date || date >= new Date().toISOString().slice(0, 10)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let archiveKey = `${month}${date}.tar.zst`;
|
||||
let manifestKey = `${archiveKey}.manifest.json`;
|
||||
if (existing.has(archiveKey) && existing.has(manifestKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({ archiveKey, date, manifestKey, prefix });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.sort((a, b) => a.date.localeCompare(b.date));
|
||||
return (await this.archiveInventory.getDates())
|
||||
.filter(item => item.hasSource && !item.hasArchive && item.date < today);
|
||||
}
|
||||
|
||||
async getSourceObjects(prefix) {
|
||||
let listing = await this.list(prefix);
|
||||
|
||||
return listing.objects
|
||||
.filter(object => object.Key && object.Key !== prefix && !object.Key.endsWith('/'))
|
||||
.map(object => {
|
||||
if (!object.LastModified || !Number.isFinite(object.Size)) {
|
||||
throw new Error(`S3 returned incomplete metadata for ${object.Key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
key: object.Key,
|
||||
bytes: object.Size,
|
||||
lastModified: object.LastModified,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list(prefix, delimiter) {
|
||||
let prefixes = [];
|
||||
let objects = [];
|
||||
let continuationToken;
|
||||
|
||||
do {
|
||||
let response = await this.s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
ContinuationToken: continuationToken,
|
||||
Delimiter: delimiter,
|
||||
Prefix: prefix,
|
||||
}));
|
||||
prefixes.push(...(response.CommonPrefixes ?? []).map(item => item.Prefix).filter(Boolean));
|
||||
objects.push(...(response.Contents ?? []));
|
||||
|
||||
if (response.IsTruncated && !response.NextContinuationToken) {
|
||||
throw new Error(`S3 listing for ${prefix} was truncated without a continuation token`);
|
||||
}
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return { objects, prefixes };
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
dateFromPrefix(prefix) {
|
||||
let match = prefix.match(/^(\d{4})\/(\d{2})\/(\d{2})\/$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let date = `${match[1]}-${match[2]}-${match[3]}`;
|
||||
let parsedDate = new Date(`${date}T00:00:00.000Z`);
|
||||
if (Number.isNaN(parsedDate.getTime()) || parsedDate.toISOString().slice(0, 10) !== date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
relativePath(prefix, key) {
|
||||
let relativePath = key.slice(prefix.length);
|
||||
if (!key.startsWith(prefix)
|
||||
|| !relativePath
|
||||
|| path.posix.normalize(relativePath) !== relativePath
|
||||
|| path.posix.isAbsolute(relativePath)
|
||||
|| relativePath.split('/').includes('..')) {
|
||||
throw new Error(`Invalid archive object path: ${key}`);
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,30 @@ import {
|
||||
PutObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ArchiveBuilder from './ArchiveBuilder.mjs';
|
||||
import ArchiveCompressor from './ArchiveCompressor.mjs';
|
||||
|
||||
const archiveKey = '2025/03/2025-03-05.tar.zst';
|
||||
const manifestKey = `${archiveKey}.manifest.json`;
|
||||
|
||||
function completedManifest() {
|
||||
return {
|
||||
version: 1,
|
||||
date: '2025-03-05',
|
||||
createdAt: '2026-08-20T00:00:00.000Z',
|
||||
archive: {
|
||||
path: archiveKey,
|
||||
bytes: 7,
|
||||
hash: `sha256:${'a'.repeat(64)}`,
|
||||
},
|
||||
files: [{
|
||||
path: 'alpha.json',
|
||||
bytes: 6,
|
||||
hash: `sha256:${'b'.repeat(64)}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
class FakeS3Client
|
||||
{
|
||||
completed = false;
|
||||
@@ -16,6 +38,7 @@ class FakeS3Client
|
||||
['2025/03/05/alpha.json', Buffer.from('alpha\n')],
|
||||
['2025/03/05/nested/beta.json', Buffer.from('beta\n')],
|
||||
]);
|
||||
manifestLastModified = new Date('2026-08-20T00:00:00.000Z');
|
||||
secondDay = false;
|
||||
uploads = [];
|
||||
|
||||
@@ -27,6 +50,17 @@ class FakeS3Client
|
||||
if (command instanceof GetObjectCommand) {
|
||||
this.downloads.push(command.input.Key);
|
||||
|
||||
if (command.input.Key === manifestKey) {
|
||||
return {
|
||||
Body: {
|
||||
transformToString: async () => JSON.stringify(completedManifest()),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command.input.Key === archiveKey) {
|
||||
return { Body: Readable.from('archive') };
|
||||
}
|
||||
|
||||
return { Body: Readable.from(this.files.get(command.input.Key)) };
|
||||
}
|
||||
|
||||
@@ -54,7 +88,10 @@ class FakeS3Client
|
||||
],
|
||||
Contents: this.completed ? [
|
||||
{ Key: '2025/03/2025-03-05.tar.zst' },
|
||||
{ Key: '2025/03/2025-03-05.tar.zst.manifest.json' },
|
||||
{
|
||||
Key: '2025/03/2025-03-05.tar.zst.manifest.json',
|
||||
LastModified: this.manifestLastModified,
|
||||
},
|
||||
] : [],
|
||||
};
|
||||
}
|
||||
@@ -62,6 +99,7 @@ class FakeS3Client
|
||||
return { Contents: [...this.files]
|
||||
.filter(([Key]) => Key.startsWith(prefix))
|
||||
.map(([Key, body]) => ({
|
||||
ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"',
|
||||
Key,
|
||||
LastModified: new Date('2025-03-05T23:45:00.000Z'),
|
||||
Size: body.length,
|
||||
@@ -120,7 +158,9 @@ describe('ArchiveCompressor', () => {
|
||||
await fs.writeFile(archivePath, 'fake compressed archive');
|
||||
},
|
||||
};
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveWriter);
|
||||
let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter);
|
||||
archiveBuilder._console = { log: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveBuilder);
|
||||
compressor._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await compressor.process();
|
||||
@@ -157,8 +197,8 @@ describe('ArchiveCompressor', () => {
|
||||
|
||||
it('does not download or upload files during a dry run', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
let archiveWriter = { write: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveWriter);
|
||||
let archiveBuilder = { build: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveBuilder);
|
||||
compressor.dryRun = true;
|
||||
compressor._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
@@ -166,21 +206,21 @@ describe('ArchiveCompressor', () => {
|
||||
|
||||
expect(s3Client.downloads).toEqual([]);
|
||||
expect(s3Client.uploads).toEqual([]);
|
||||
expect(archiveWriter.write).not.toHaveBeenCalled();
|
||||
expect(archiveBuilder.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a completed archive alone', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
s3Client.completed = true;
|
||||
let archiveWriter = { write: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveWriter);
|
||||
let archiveBuilder = { build: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveBuilder);
|
||||
compressor._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await compressor.process();
|
||||
|
||||
expect(s3Client.downloads).toEqual([]);
|
||||
expect(s3Client.uploads).toEqual([]);
|
||||
expect(archiveWriter.write).not.toHaveBeenCalled();
|
||||
expect(archiveBuilder.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops after a failed day and removes its temporary files', async () => {
|
||||
@@ -195,7 +235,9 @@ describe('ArchiveCompressor', () => {
|
||||
throw new Error('compression failed');
|
||||
},
|
||||
};
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveWriter);
|
||||
let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter);
|
||||
archiveBuilder._console = { log: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveBuilder);
|
||||
compressor._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await expect(compressor.process()).rejects.toThrow('compression failed');
|
||||
@@ -215,7 +257,9 @@ describe('ArchiveCompressor', () => {
|
||||
await fs.writeFile(archivePath, 'archive');
|
||||
},
|
||||
};
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveWriter);
|
||||
let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter);
|
||||
archiveBuilder._console = { log: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveBuilder);
|
||||
compressor._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await compressor.process();
|
||||
@@ -235,7 +279,9 @@ describe('ArchiveCompressor', () => {
|
||||
await fs.writeFile(archivePath, 'archive');
|
||||
},
|
||||
};
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveWriter);
|
||||
let archiveBuilder = new ArchiveBuilder(s3Client, archiveWriter);
|
||||
archiveBuilder._console = { log: vi.fn() };
|
||||
let compressor = new ArchiveCompressor(s3Client, archiveBuilder);
|
||||
compressor.maxDays = 1;
|
||||
compressor._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
|
||||
194
app/data/ArchiveContentVerifier.mjs
Normal file
194
app/data/ArchiveContentVerifier.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import * as tar from 'tar-stream';
|
||||
|
||||
class ArchiveContentError extends Error {}
|
||||
|
||||
export class AppleDoubleArchiveError extends ArchiveContentError
|
||||
{
|
||||
constructor(filePath) {
|
||||
super(`Tar archive contains an unexpected file: ${filePath}`);
|
||||
this.path = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function processCompletion(child) {
|
||||
let stderr = '';
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(
|
||||
`zstd failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${stderr.trim()}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function decompressZstd(stream) {
|
||||
let zstd = spawn('zstd', ['-dc'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
return {
|
||||
completed: Promise.all([
|
||||
pipeline(stream, zstd.stdin),
|
||||
processCompletion(zstd),
|
||||
]),
|
||||
stream: zstd.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
export default class ArchiveContentVerifier
|
||||
{
|
||||
constructor(decompress = decompressZstd) {
|
||||
this.decompress = decompress;
|
||||
}
|
||||
|
||||
async verify(archiveStream, manifest, sourceObjects) {
|
||||
let inventory;
|
||||
try {
|
||||
inventory = this.matchFiles(manifest.files, sourceObjects);
|
||||
} catch (error) {
|
||||
archiveStream.destroy?.();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let archiveBytes = 0;
|
||||
let archiveHash = crypto.createHash('sha256');
|
||||
let inspector = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
archiveBytes += chunk.length;
|
||||
archiveHash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
let decompressor = this.decompress(inspector);
|
||||
let results = await Promise.allSettled([
|
||||
pipeline(archiveStream, inspector),
|
||||
decompressor.completed,
|
||||
this.verifyTar(decompressor.stream, inventory),
|
||||
]);
|
||||
let verificationResult = results[2];
|
||||
if (verificationResult.status === 'rejected'
|
||||
&& verificationResult.reason instanceof ArchiveContentError) {
|
||||
throw verificationResult.reason;
|
||||
}
|
||||
let errors = results
|
||||
.filter(result => result.status === 'rejected')
|
||||
.map(result => result.reason);
|
||||
if (errors.length === 1) {
|
||||
throw errors[0];
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, 'Could not verify archive');
|
||||
}
|
||||
|
||||
if (archiveBytes !== manifest.archive.bytes) {
|
||||
throw new Error('Archive size does not match its manifest');
|
||||
}
|
||||
if (`sha256:${archiveHash.digest('hex')}` !== manifest.archive.hash) {
|
||||
throw new Error('Archive SHA-256 does not match its manifest');
|
||||
}
|
||||
}
|
||||
|
||||
matchFiles(manifestFiles, sourceObjects) {
|
||||
let manifestByPath = new Map(manifestFiles.map(file => [file.path, file]));
|
||||
if (sourceObjects === null) {
|
||||
if (manifestByPath.size !== manifestFiles.length) {
|
||||
throw new Error('Archive manifest contains duplicate file paths');
|
||||
}
|
||||
|
||||
return { manifestByPath, sourceByPath: null };
|
||||
}
|
||||
|
||||
let sourceByPath = new Map(sourceObjects.map(object => [object.path, object]));
|
||||
if (manifestByPath.size !== manifestFiles.length
|
||||
|| sourceByPath.size !== sourceObjects.length
|
||||
|| manifestByPath.size !== sourceByPath.size
|
||||
|| [...manifestByPath].some(([filePath, file]) => {
|
||||
let source = sourceByPath.get(filePath);
|
||||
|
||||
return !source || source.bytes !== file.bytes;
|
||||
})) {
|
||||
throw new Error('Live source files do not match the archive manifest');
|
||||
}
|
||||
|
||||
return { manifestByPath, sourceByPath };
|
||||
}
|
||||
|
||||
async verifyTar(stream, { manifestByPath, sourceByPath }) {
|
||||
let extract = tar.extract();
|
||||
let verified = new Set;
|
||||
extract.on('entry', (header, entry, next) => {
|
||||
this.verifyEntry(header, entry, manifestByPath, sourceByPath, verified)
|
||||
.then(next)
|
||||
.catch(error => extract.destroy(error));
|
||||
});
|
||||
await pipeline(stream, extract);
|
||||
|
||||
if (verified.size !== manifestByPath.size) {
|
||||
throw new ArchiveContentError('Tar archive is missing files from its manifest');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyEntry(header, entry, manifestByPath, sourceByPath, verified) {
|
||||
if (header.type !== 'file') {
|
||||
throw new ArchiveContentError(`Tar archive contains a non-file entry: ${header.name}`);
|
||||
}
|
||||
if (verified.has(header.name)) {
|
||||
throw new ArchiveContentError(`Tar archive contains a duplicate file: ${header.name}`);
|
||||
}
|
||||
|
||||
let manifestFile = manifestByPath.get(header.name);
|
||||
let sourceObject = sourceByPath?.get(header.name);
|
||||
if (!manifestFile || sourceByPath && !sourceObject) {
|
||||
if (path.posix.basename(header.name).startsWith('._')) {
|
||||
throw new AppleDoubleArchiveError(header.name);
|
||||
}
|
||||
|
||||
throw new ArchiveContentError(`Tar archive contains an unexpected file: ${header.name}`);
|
||||
}
|
||||
verified.add(header.name);
|
||||
|
||||
let bytes = 0;
|
||||
let md5 = crypto.createHash('md5');
|
||||
let sha256 = crypto.createHash('sha256');
|
||||
for await (let chunk of entry) {
|
||||
bytes += chunk.length;
|
||||
md5.update(chunk);
|
||||
sha256.update(chunk);
|
||||
}
|
||||
|
||||
if (bytes !== manifestFile.bytes) {
|
||||
throw new ArchiveContentError(`Size does not match the manifest for ${header.name}`);
|
||||
}
|
||||
if (`sha256:${sha256.digest('hex')}` !== manifestFile.hash) {
|
||||
throw new ArchiveContentError(`SHA-256 does not match the manifest for ${header.name}`);
|
||||
}
|
||||
|
||||
if (sourceObject) {
|
||||
let etag = sourceObject.etag;
|
||||
if (etag.startsWith('"') && etag.endsWith('"')) {
|
||||
etag = etag.slice(1, -1);
|
||||
}
|
||||
if (!/^[a-fA-F0-9]{32}$/.test(etag)) {
|
||||
throw new ArchiveContentError(`S3 ETag is not an MD5 hash for ${header.name}`);
|
||||
}
|
||||
if (md5.digest('hex') !== etag.toLowerCase()) {
|
||||
throw new ArchiveContentError(`MD5 does not match the S3 ETag for ${header.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/data/ArchiveContentVerifier.test.mjs
Normal file
155
app/data/ArchiveContentVerifier.test.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as tar from 'tar-stream';
|
||||
import ArchiveContentVerifier, { AppleDoubleArchiveError } from './ArchiveContentVerifier.mjs';
|
||||
|
||||
async function createTar(files) {
|
||||
let pack = tar.pack();
|
||||
for (let [name, contents] of files) {
|
||||
pack.entry({
|
||||
mode: 0o644,
|
||||
mtime: new Date(0),
|
||||
name,
|
||||
}, contents);
|
||||
}
|
||||
pack.finalize();
|
||||
|
||||
let chunks = [];
|
||||
for await (let chunk of pack) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function manifestFor(archive, fileHash = 'b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060') {
|
||||
return {
|
||||
archive: {
|
||||
bytes: archive.length,
|
||||
hash: 'sha256:5a500b4490cc8d86f7c6970ae673b15933dc1d29f45e6098b730572ec5d2396a',
|
||||
},
|
||||
files: [{
|
||||
path: 'alpha.json',
|
||||
bytes: 6,
|
||||
hash: `sha256:${fileHash}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function sourceObject(etag = '9f9f90dbe3e5ee1218c86b8839db1995') {
|
||||
return {
|
||||
path: 'alpha.json',
|
||||
bytes: 6,
|
||||
etag: `"${etag}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const identityDecompressor = stream => ({ completed: Promise.resolve(), stream });
|
||||
|
||||
describe('ArchiveContentVerifier', () => {
|
||||
it('verifies a tar stream against its manifest and live S3 objects', async () => {
|
||||
let archive = await createTar([['alpha.json', 'alpha\n']]);
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
|
||||
await expect(verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive),
|
||||
[sourceObject()],
|
||||
)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('verifies a pruned tar stream against its manifest', async () => {
|
||||
let archive = await createTar([['alpha.json', 'alpha\n']]);
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
|
||||
await expect(verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive),
|
||||
null,
|
||||
)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects duplicate live S3 paths', async () => {
|
||||
let archive = await createTar([['alpha.json', 'alpha\n']]);
|
||||
let source = sourceObject();
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
let archiveStream = new Readable({
|
||||
read() {
|
||||
this.destroy(new Error('Archive should not be read'));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(verifier.verify(
|
||||
archiveStream,
|
||||
manifestFor(archive),
|
||||
[source, source],
|
||||
)).rejects.toThrow('Live source files do not match the archive manifest');
|
||||
});
|
||||
|
||||
it('rejects a file whose SHA-256 does not match the manifest', async () => {
|
||||
let archive = await createTar([['alpha.json', 'alpha\n']]);
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
|
||||
await expect(verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive, '0'.repeat(64)),
|
||||
[sourceObject()],
|
||||
)).rejects.toThrow('SHA-256 does not match the manifest for alpha.json');
|
||||
});
|
||||
|
||||
it('rejects an S3 ETag that is not a plain MD5 hash', async () => {
|
||||
let archive = await createTar([['alpha.json', 'alpha\n']]);
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
|
||||
await expect(verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive),
|
||||
[sourceObject('multipart-etag-2')],
|
||||
)).rejects.toThrow('S3 ETag is not an MD5 hash for alpha.json');
|
||||
});
|
||||
|
||||
it('reports an unexpected tar file instead of the resulting broken pipe', async () => {
|
||||
let archive = await createTar([
|
||||
['alpha.json', 'alpha\n'],
|
||||
['extra.json', 'extra\n'],
|
||||
]);
|
||||
let verifier = new ArchiveContentVerifier(stream => ({
|
||||
completed: Promise.reject(new Error('zstd failed (exit 70): Broken pipe')),
|
||||
stream,
|
||||
}));
|
||||
|
||||
await expect(verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive),
|
||||
[sourceObject()],
|
||||
)).rejects.toThrow('Tar archive contains an unexpected file: extra.json');
|
||||
});
|
||||
|
||||
it('identifies unexpected macOS AppleDouble files', async () => {
|
||||
let archive = await createTar([
|
||||
['._alpha.json', 'metadata'],
|
||||
['alpha.json', 'alpha\n'],
|
||||
]);
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
|
||||
let error = await verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive),
|
||||
[sourceObject()],
|
||||
).catch(reason => reason);
|
||||
|
||||
expect(error).toBeInstanceOf(AppleDoubleArchiveError);
|
||||
expect(error.path).toBe('._alpha.json');
|
||||
});
|
||||
|
||||
it('rejects a file whose MD5 does not match its S3 ETag', async () => {
|
||||
let archive = await createTar([['alpha.json', 'alpha\n']]);
|
||||
let verifier = new ArchiveContentVerifier(identityDecompressor);
|
||||
|
||||
await expect(verifier.verify(
|
||||
Readable.from(archive),
|
||||
manifestFor(archive),
|
||||
[sourceObject('0'.repeat(32))],
|
||||
)).rejects.toThrow('MD5 does not match the S3 ETag for alpha.json');
|
||||
});
|
||||
});
|
||||
169
app/data/ArchiveInventory.mjs
Normal file
169
app/data/ArchiveInventory.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
export default class ArchiveInventory
|
||||
{
|
||||
constructor(s3Client) {
|
||||
this._client = s3Client;
|
||||
}
|
||||
|
||||
async getDates() {
|
||||
let dates = new Map;
|
||||
let years = (await this.list('', '/')).prefixes.filter(prefix => /^\d{4}\/$/.test(prefix));
|
||||
|
||||
for (let year of years.sort()) {
|
||||
let months = (await this.list(year, '/')).prefixes
|
||||
.filter(prefix => /^\d{4}\/\d{2}\/$/.test(prefix));
|
||||
|
||||
for (let month of months.sort()) {
|
||||
let listing = await this.list(month, '/');
|
||||
|
||||
for (let prefix of listing.prefixes) {
|
||||
let date = this.dateFromPrefix(prefix);
|
||||
if (date) {
|
||||
dates.set(date, this.createDate(date, prefix, true));
|
||||
}
|
||||
}
|
||||
|
||||
for (let object of listing.objects) {
|
||||
let match = object.Key?.match(/^(\d{4}\/\d{2})\/(\d{4}-\d{2}-\d{2})\.tar\.zst(\.manifest\.json)?$/);
|
||||
if (!match || `${match[1]}/` !== month || !this.isDate(match[2])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let date = match[2];
|
||||
if (`${date.slice(0, 4)}/${date.slice(5, 7)}/` !== month) {
|
||||
continue;
|
||||
}
|
||||
let item = dates.get(date) ?? this.createDate(
|
||||
date,
|
||||
`${date.slice(0, 4)}/${date.slice(5, 7)}/${date.slice(8, 10)}/`,
|
||||
false,
|
||||
);
|
||||
if (match[3]) {
|
||||
item.hasManifest = true;
|
||||
} else {
|
||||
item.hasArchive = true;
|
||||
}
|
||||
dates.set(date, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let item of dates.values()) {
|
||||
if (item.hasArchive !== item.hasManifest) {
|
||||
throw new Error(`Archive and manifest are incomplete for ${item.date}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...dates.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
async getSourceObjects(prefix) {
|
||||
let listing = await this.list(prefix);
|
||||
|
||||
return listing.objects
|
||||
.filter(object => object.Key && object.Key !== prefix && !object.Key.endsWith('/'))
|
||||
.map(object => {
|
||||
if (!object.LastModified
|
||||
|| !Number.isFinite(object.Size)
|
||||
|| typeof object.ETag !== 'string') {
|
||||
throw new Error(`S3 returned incomplete metadata for ${object.Key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: this.relativePath(prefix, object.Key),
|
||||
bytes: object.Size,
|
||||
etag: object.ETag,
|
||||
key: object.Key,
|
||||
lastModified: object.LastModified,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
get s3Client() {
|
||||
return this._client ??= new S3Client({
|
||||
endpoint: process.env.AWS_S3_ENDPOINT,
|
||||
region: process.env.AWS_REGION,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(prefix, delimiter) {
|
||||
let prefixes = [];
|
||||
let objects = [];
|
||||
let continuationToken;
|
||||
|
||||
do {
|
||||
let response = await this.s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
ContinuationToken: continuationToken,
|
||||
Delimiter: delimiter,
|
||||
Prefix: prefix,
|
||||
}));
|
||||
prefixes.push(...(response.CommonPrefixes ?? []).map(item => item.Prefix).filter(Boolean));
|
||||
objects.push(...(response.Contents ?? []));
|
||||
|
||||
if (response.IsTruncated && !response.NextContinuationToken) {
|
||||
throw new Error(`S3 listing for ${prefix} was truncated without a continuation token`);
|
||||
}
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return { objects, prefixes };
|
||||
}
|
||||
|
||||
createDate(date, prefix, hasSource) {
|
||||
let month = `${date.slice(0, 4)}/${date.slice(5, 7)}/`;
|
||||
let archiveKey = `${month}${date}.tar.zst`;
|
||||
|
||||
return {
|
||||
archiveKey,
|
||||
date,
|
||||
hasArchive: false,
|
||||
hasManifest: false,
|
||||
hasSource,
|
||||
manifestKey: `${archiveKey}.manifest.json`,
|
||||
prefix,
|
||||
};
|
||||
}
|
||||
|
||||
dateFromPrefix(prefix) {
|
||||
let match = prefix.match(/^(\d{4})\/(\d{2})\/(\d{2})\/$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let date = `${match[1]}-${match[2]}-${match[3]}`;
|
||||
|
||||
return this.isDate(date) ? date : null;
|
||||
}
|
||||
|
||||
isDate(date) {
|
||||
let parsedDate = new Date(`${date}T00:00:00.000Z`);
|
||||
|
||||
return !Number.isNaN(parsedDate.getTime()) && parsedDate.toISOString().slice(0, 10) === date;
|
||||
}
|
||||
|
||||
relativePath(prefix, key) {
|
||||
let relativePath = key.slice(prefix.length);
|
||||
if (!key.startsWith(prefix)
|
||||
|| !relativePath
|
||||
|| path.posix.normalize(relativePath) !== relativePath
|
||||
|| path.posix.isAbsolute(relativePath)
|
||||
|| relativePath.split('/').includes('..')) {
|
||||
throw new Error(`Invalid archive object path: ${key}`);
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
206
app/data/ArchivePruner.mjs
Normal file
206
app/data/ArchivePruner.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
DeleteObjectsCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import prefixedConsole from '../common/prefixedConsole.mjs';
|
||||
import ArchiveInventory from './ArchiveInventory.mjs';
|
||||
import RemoteArchiveVerifier from './RemoteArchiveVerifier.mjs';
|
||||
|
||||
const retentionPeriod = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function pruneArchives(maxDays = Infinity, dryRun = false) {
|
||||
let pruner = new ArchivePruner;
|
||||
pruner.maxDays = maxDays;
|
||||
pruner.dryRun = dryRun;
|
||||
|
||||
return pruner.process();
|
||||
}
|
||||
|
||||
export function pruneArchivesFromCli(args) {
|
||||
let dryRun = false;
|
||||
let maxDays = Infinity;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (args[i] === '--max-days' && /^\d+$/.test(args[i + 1])) {
|
||||
maxDays = Number(args[++i]);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Usage: npm run data:archive:prune -- [--dry-run] [--max-days DAYS]',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDays < 1) {
|
||||
throw new Error('--max-days must be at least 1');
|
||||
}
|
||||
|
||||
return pruneArchives(maxDays, dryRun);
|
||||
}
|
||||
|
||||
export default class ArchivePruner
|
||||
{
|
||||
dryRun = false;
|
||||
maxDays = Infinity;
|
||||
|
||||
constructor(s3Client, remoteArchiveVerifier, archiveInventory) {
|
||||
this._client = s3Client;
|
||||
this._remoteArchiveVerifier = remoteArchiveVerifier;
|
||||
this._archiveInventory = archiveInventory;
|
||||
}
|
||||
|
||||
async process() {
|
||||
if (!this.canRun) {
|
||||
this.console.log('Skipping archive pruner');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let currentDate;
|
||||
|
||||
try {
|
||||
let candidates = await this.getCandidates();
|
||||
this.console.log(`Found ${candidates.length} dates to verify`);
|
||||
let processedDays = 0;
|
||||
|
||||
for (let candidate of candidates) {
|
||||
if (processedDays >= this.maxDays) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDate = candidate.date;
|
||||
let manifest = await this.remoteArchiveVerifier.getManifest(candidate);
|
||||
if (!this.dryRun && !this.isEligible(manifest)) {
|
||||
this.console.log(
|
||||
`Skipping ${candidate.date}; not eligible for deletion until ${this.eligibleDate(manifest)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let sourceObjects = await this.archiveInventory.getSourceObjects(candidate.prefix);
|
||||
await this.remoteArchiveVerifier.verify(candidate, sourceObjects, manifest);
|
||||
processedDays++;
|
||||
|
||||
if (this.dryRun) {
|
||||
if (this.isEligible(manifest)) {
|
||||
this.console.log(
|
||||
`Verified ${candidate.date}; would delete ${this.fileCount(sourceObjects)}`,
|
||||
);
|
||||
} else {
|
||||
this.console.log(
|
||||
`Verified ${candidate.date}; not eligible for deletion until ${this.eligibleDate(manifest)}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentObjects = await this.archiveInventory.getSourceObjects(candidate.prefix);
|
||||
this.assertObjectsUnchanged(sourceObjects, currentObjects, candidate.date);
|
||||
await this.deleteObjects(currentObjects);
|
||||
this.console.log(`Pruned ${candidate.date}; deleted ${this.fileCount(currentObjects)}`);
|
||||
}
|
||||
|
||||
this.console.log(
|
||||
this.dryRun
|
||||
? `Verified ${processedDays} daily archives`
|
||||
: `Pruned ${processedDays} daily archives`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.console.error(e);
|
||||
Sentry.withScope(scope => {
|
||||
if (currentDate) {
|
||||
scope.setTag('archive.date', currentDate);
|
||||
}
|
||||
Sentry.captureException(e);
|
||||
});
|
||||
await Sentry.flush(2000).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
get console() {
|
||||
this._console ??= prefixedConsole('Archive Pruner');
|
||||
|
||||
return this._console;
|
||||
}
|
||||
|
||||
get canRun() {
|
||||
return process.env.AWS_S3_ENDPOINT
|
||||
&& process.env.AWS_REGION
|
||||
&& process.env.AWS_S3_ARCHIVE_BUCKET
|
||||
&& process.env.AWS_ACCESS_KEY_ID
|
||||
&& process.env.AWS_SECRET_ACCESS_KEY;
|
||||
}
|
||||
|
||||
get s3Client() {
|
||||
return this._client ??= new S3Client({
|
||||
endpoint: process.env.AWS_S3_ENDPOINT,
|
||||
region: process.env.AWS_REGION,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get remoteArchiveVerifier() {
|
||||
return this._remoteArchiveVerifier ??= new RemoteArchiveVerifier(this.s3Client);
|
||||
}
|
||||
|
||||
get archiveInventory() {
|
||||
return this._archiveInventory ??= new ArchiveInventory(this.s3Client);
|
||||
}
|
||||
|
||||
async getCandidates() {
|
||||
return (await this.archiveInventory.getDates())
|
||||
.filter(item => item.hasSource && item.hasArchive);
|
||||
}
|
||||
|
||||
async deleteObjects(objects) {
|
||||
for (let i = 0; i < objects.length; i += 1000) {
|
||||
let batch = objects.slice(i, i + 1000);
|
||||
let response = await this.s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
Delete: {
|
||||
Objects: batch.map(object => ({ Key: object.key })),
|
||||
Quiet: true,
|
||||
},
|
||||
}));
|
||||
if (response.Errors?.length) {
|
||||
let failures = response.Errors.map(error => `${error.Key}: ${error.Code}`).join(', ');
|
||||
throw new Error(`Could not delete archive source files: ${failures}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isEligible(manifest) {
|
||||
return Date.parse(manifest.createdAt) <= Date.now() - retentionPeriod;
|
||||
}
|
||||
|
||||
eligibleDate(manifest) {
|
||||
return new Date(Date.parse(manifest.createdAt) + retentionPeriod).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
assertObjectsUnchanged(expected, actual, date) {
|
||||
let unchanged = expected.length === actual.length
|
||||
&& expected.every((object, index) => {
|
||||
let current = actual[index];
|
||||
|
||||
return object.key === current.key
|
||||
&& object.bytes === current.bytes
|
||||
&& object.etag === current.etag;
|
||||
});
|
||||
if (!unchanged) {
|
||||
throw new Error(`Source files changed while verifying ${date}`);
|
||||
}
|
||||
}
|
||||
|
||||
fileCount(objects) {
|
||||
return `${objects.length} ${objects.length === 1 ? 'file' : 'files'}`;
|
||||
}
|
||||
|
||||
}
|
||||
195
app/data/ArchivePruner.test.mjs
Normal file
195
app/data/ArchivePruner.test.mjs
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ArchivePruner from './ArchivePruner.mjs';
|
||||
|
||||
const archiveKey = '2025/03/2025-03-05.tar.zst';
|
||||
const manifestKey = `${archiveKey}.manifest.json`;
|
||||
|
||||
function manifest(createdAt = '2026-08-21T00:00:00.000Z') {
|
||||
return {
|
||||
version: 1,
|
||||
date: '2025-03-05',
|
||||
createdAt,
|
||||
archive: {
|
||||
path: archiveKey,
|
||||
bytes: 7,
|
||||
hash: `sha256:${'a'.repeat(64)}`,
|
||||
},
|
||||
files: [{
|
||||
path: 'alpha.json',
|
||||
bytes: 6,
|
||||
hash: `sha256:${'b'.repeat(64)}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
class FakeS3Client
|
||||
{
|
||||
deletes = [];
|
||||
events = [];
|
||||
manifest = manifest();
|
||||
manifestMissing = false;
|
||||
|
||||
async send(command) {
|
||||
if (command instanceof ListObjectsV2Command) {
|
||||
return this.list(command.input.Prefix);
|
||||
}
|
||||
if (command instanceof GetObjectCommand && command.input.Key === manifestKey) {
|
||||
return {
|
||||
Body: {
|
||||
transformToString: async () => JSON.stringify(this.manifest),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command instanceof GetObjectCommand && command.input.Key === archiveKey) {
|
||||
return { Body: Readable.from('archive') };
|
||||
}
|
||||
if (command instanceof DeleteObjectsCommand) {
|
||||
this.events.push('delete');
|
||||
this.deletes.push(...command.input.Delete.Objects.map(object => object.Key));
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected S3 command: ${command.constructor.name}`);
|
||||
}
|
||||
|
||||
list(prefix) {
|
||||
if (prefix === '') {
|
||||
return { CommonPrefixes: [{ Prefix: '2025/' }] };
|
||||
}
|
||||
if (prefix === '2025/') {
|
||||
return { CommonPrefixes: [{ Prefix: '2025/03/' }] };
|
||||
}
|
||||
if (prefix === '2025/03/') {
|
||||
return {
|
||||
CommonPrefixes: [{ Prefix: '2025/03/05/' }],
|
||||
Contents: [
|
||||
{ Key: archiveKey },
|
||||
...(this.manifestMissing ? [] : [{ Key: manifestKey }]),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (prefix === '2025/03/05/') {
|
||||
return {
|
||||
Contents: [{
|
||||
ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"',
|
||||
Key: '2025/03/05/alpha.json',
|
||||
LastModified: new Date('2025-03-05T00:00:00.000Z'),
|
||||
Size: 6,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected S3 prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ArchivePruner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-22T12:00:00.000Z'));
|
||||
process.env.AWS_S3_ENDPOINT = 'https://example.invalid';
|
||||
process.env.AWS_REGION = 'test';
|
||||
process.env.AWS_S3_ARCHIVE_BUCKET = 'archive';
|
||||
process.env.AWS_ACCESS_KEY_ID = 'key';
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('fully verifies recent archives during a dry run without deleting files', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
let verifier = {
|
||||
getManifest: vi.fn(async () => s3Client.manifest),
|
||||
verify: vi.fn(),
|
||||
};
|
||||
let pruner = new ArchivePruner(s3Client, verifier);
|
||||
pruner.dryRun = true;
|
||||
pruner._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await pruner.process();
|
||||
|
||||
expect(verifier.verify).toHaveBeenCalledOnce();
|
||||
expect(s3Client.deletes).toEqual([]);
|
||||
expect(pruner._console.log).toHaveBeenCalledWith(
|
||||
'Verified 2025-03-05; not eligible for deletion until 2026-08-28',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips recent archives before downloading and verifying them', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
let verifier = {
|
||||
getManifest: vi.fn(async () => s3Client.manifest),
|
||||
verify: vi.fn(),
|
||||
};
|
||||
let pruner = new ArchivePruner(s3Client, verifier);
|
||||
pruner._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await pruner.process();
|
||||
|
||||
expect(verifier.verify).not.toHaveBeenCalled();
|
||||
expect(s3Client.deletes).toEqual([]);
|
||||
expect(pruner._console.log).toHaveBeenCalledWith(
|
||||
'Skipping 2025-03-05; not eligible for deletion until 2026-08-28',
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes an eligible day only after its archive is verified', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
s3Client.manifest = manifest('2026-08-01T00:00:00.000Z');
|
||||
let verifier = {
|
||||
getManifest: vi.fn(async () => s3Client.manifest),
|
||||
verify: vi.fn(async () => {
|
||||
s3Client.events.push('verify');
|
||||
}),
|
||||
};
|
||||
let pruner = new ArchivePruner(s3Client, verifier);
|
||||
pruner._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await pruner.process();
|
||||
|
||||
expect(s3Client.events).toEqual(['verify', 'delete']);
|
||||
expect(s3Client.deletes).toEqual(['2025/03/05/alpha.json']);
|
||||
expect(pruner._console.log).toHaveBeenCalledWith('Pruned 2025-03-05; deleted 1 file');
|
||||
});
|
||||
|
||||
it('stops when an archive exists without its manifest', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
s3Client.manifestMissing = true;
|
||||
let verifier = { getManifest: vi.fn(), verify: vi.fn() };
|
||||
let pruner = new ArchivePruner(s3Client, verifier);
|
||||
pruner._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await expect(pruner.process()).rejects.toThrow(
|
||||
'Archive and manifest are incomplete for 2025-03-05',
|
||||
);
|
||||
|
||||
expect(verifier.verify).not.toHaveBeenCalled();
|
||||
expect(s3Client.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops without deleting when archive verification fails', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
s3Client.manifest = manifest('2026-08-01T00:00:00.000Z');
|
||||
let verifier = {
|
||||
getManifest: vi.fn(async () => s3Client.manifest),
|
||||
verify: vi.fn(async () => {
|
||||
throw new Error('archive does not match');
|
||||
}),
|
||||
};
|
||||
let pruner = new ArchivePruner(s3Client, verifier);
|
||||
pruner._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await expect(pruner.process()).rejects.toThrow('archive does not match');
|
||||
|
||||
expect(s3Client.deletes).toEqual([]);
|
||||
});
|
||||
});
|
||||
161
app/data/ArchiveVerifyCommand.mjs
Normal file
161
app/data/ArchiveVerifyCommand.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import prefixedConsole from '../common/prefixedConsole.mjs';
|
||||
import ArchiveBuilder from './ArchiveBuilder.mjs';
|
||||
import ArchiveInventory from './ArchiveInventory.mjs';
|
||||
import { AppleDoubleArchiveError } from './ArchiveContentVerifier.mjs';
|
||||
import RemoteArchiveVerifier from './RemoteArchiveVerifier.mjs';
|
||||
|
||||
export function verifyArchives(maxDays = Infinity, repair = false) {
|
||||
let verification = new ArchiveVerifyCommand;
|
||||
verification.maxDays = maxDays;
|
||||
verification.repair = repair;
|
||||
|
||||
return verification.process();
|
||||
}
|
||||
|
||||
export function verifyArchivesFromCli(args) {
|
||||
let maxDays = Infinity;
|
||||
let repair = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--repair') {
|
||||
repair = true;
|
||||
} else if (args[i] === '--max-days' && /^\d+$/.test(args[i + 1])) {
|
||||
maxDays = Number(args[++i]);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Usage: npm run data:archive:verify -- [--repair] [--max-days DAYS]',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDays < 1) {
|
||||
throw new Error('--max-days must be at least 1');
|
||||
}
|
||||
|
||||
return verifyArchives(maxDays, repair);
|
||||
}
|
||||
|
||||
export default class ArchiveVerifyCommand
|
||||
{
|
||||
maxDays = Infinity;
|
||||
repair = false;
|
||||
|
||||
constructor(s3Client, remoteArchiveVerifier, archiveBuilder, archiveInventory) {
|
||||
this._client = s3Client;
|
||||
this._remoteArchiveVerifier = remoteArchiveVerifier;
|
||||
this._archiveBuilder = archiveBuilder;
|
||||
this._archiveInventory = archiveInventory;
|
||||
}
|
||||
|
||||
async process() {
|
||||
if (!this.canRun) {
|
||||
this.console.log('Skipping archive verification');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let currentDate;
|
||||
let affected = 0;
|
||||
let checked = 0;
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
let candidates = await this.getCandidates();
|
||||
this.console.log(`Found ${candidates.length} dates to verify`);
|
||||
|
||||
for (let candidate of candidates) {
|
||||
if ((this.repair ? affected : checked) >= this.maxDays) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDate = candidate.date;
|
||||
let sourceObjects = candidate.hasSource
|
||||
? await this.archiveInventory.getSourceObjects(candidate.prefix)
|
||||
: null;
|
||||
checked++;
|
||||
|
||||
try {
|
||||
await this.remoteArchiveVerifier.verify(candidate, sourceObjects);
|
||||
this.console.log(`Verified ${candidate.date}`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AppleDoubleArchiveError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
affected++;
|
||||
if (!this.repair) {
|
||||
this.console.log(`${candidate.date} contains unexpected macOS metadata`);
|
||||
} else {
|
||||
if (!sourceObjects) {
|
||||
throw new Error(`Cannot repair ${candidate.date}; its source files have been pruned`);
|
||||
}
|
||||
await this.archiveBuilder.build(candidate, sourceObjects);
|
||||
repaired++;
|
||||
this.console.log(`Repaired ${candidate.date}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.console.error(error);
|
||||
Sentry.withScope(scope => {
|
||||
if (currentDate) {
|
||||
scope.setTag('archive.date', currentDate);
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
await Sentry.flush(2000).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.repair) {
|
||||
this.console.log(`Verified ${checked} daily archives; repaired ${repaired}`);
|
||||
} else {
|
||||
this.console.log(`Verified ${checked} daily archives; ${affected} need repair`);
|
||||
}
|
||||
}
|
||||
|
||||
get console() {
|
||||
this._console ??= prefixedConsole('Archive Verification');
|
||||
|
||||
return this._console;
|
||||
}
|
||||
|
||||
get canRun() {
|
||||
return process.env.AWS_S3_ENDPOINT
|
||||
&& process.env.AWS_REGION
|
||||
&& process.env.AWS_S3_ARCHIVE_BUCKET
|
||||
&& process.env.AWS_ACCESS_KEY_ID
|
||||
&& process.env.AWS_SECRET_ACCESS_KEY;
|
||||
}
|
||||
|
||||
get s3Client() {
|
||||
return this._client ??= new S3Client({
|
||||
endpoint: process.env.AWS_S3_ENDPOINT,
|
||||
region: process.env.AWS_REGION,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get remoteArchiveVerifier() {
|
||||
return this._remoteArchiveVerifier ??= new RemoteArchiveVerifier(this.s3Client);
|
||||
}
|
||||
|
||||
get archiveBuilder() {
|
||||
return this._archiveBuilder ??= new ArchiveBuilder(this.s3Client);
|
||||
}
|
||||
|
||||
get archiveInventory() {
|
||||
return this._archiveInventory ??= new ArchiveInventory(this.s3Client);
|
||||
}
|
||||
|
||||
async getCandidates() {
|
||||
return (await this.archiveInventory.getDates()).filter(item => item.hasArchive);
|
||||
}
|
||||
}
|
||||
157
app/data/ArchiveVerifyCommand.test.mjs
Normal file
157
app/data/ArchiveVerifyCommand.test.mjs
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
ListObjectsV2Command,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ArchiveVerifyCommand, { verifyArchivesFromCli } from './ArchiveVerifyCommand.mjs';
|
||||
import { AppleDoubleArchiveError } from './ArchiveContentVerifier.mjs';
|
||||
|
||||
const candidate = {
|
||||
archiveKey: '2025/03/2025-03-05.tar.zst',
|
||||
date: '2025-03-05',
|
||||
manifestKey: '2025/03/2025-03-05.tar.zst.manifest.json',
|
||||
prefix: '2025/03/05/',
|
||||
};
|
||||
|
||||
class FakeS3Client
|
||||
{
|
||||
sourcePruned = false;
|
||||
|
||||
async send(command) {
|
||||
if (command instanceof ListObjectsV2Command) {
|
||||
return this.list(command.input.Prefix);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected S3 command: ${command.constructor.name}`);
|
||||
}
|
||||
|
||||
list(prefix) {
|
||||
if (prefix === '') {
|
||||
return { CommonPrefixes: [{ Prefix: '2025/' }] };
|
||||
}
|
||||
if (prefix === '2025/') {
|
||||
return { CommonPrefixes: [{ Prefix: '2025/03/' }] };
|
||||
}
|
||||
if (prefix === '2025/03/') {
|
||||
return {
|
||||
CommonPrefixes: this.sourcePruned ? [] : [{ Prefix: candidate.prefix }],
|
||||
Contents: [
|
||||
{ Key: candidate.archiveKey },
|
||||
{ Key: candidate.manifestKey },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (prefix === candidate.prefix) {
|
||||
return {
|
||||
Contents: [{
|
||||
ETag: '"9f9f90dbe3e5ee1218c86b8839db1995"',
|
||||
Key: `${candidate.prefix}alpha.json`,
|
||||
LastModified: new Date('2025-03-05T00:00:00.000Z'),
|
||||
Size: 6,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected S3 prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ArchiveVerifyCommand', () => {
|
||||
beforeEach(() => {
|
||||
process.env.AWS_S3_ENDPOINT = 'https://example.invalid';
|
||||
process.env.AWS_REGION = 'test';
|
||||
process.env.AWS_S3_ARCHIVE_BUCKET = 'archive';
|
||||
process.env.AWS_ACCESS_KEY_ID = 'key';
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'secret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not accept a redundant dry-run option', () => {
|
||||
expect(() => verifyArchivesFromCli(['--dry-run'])).toThrow(
|
||||
'Usage: npm run data:archive:verify',
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies archives without rebuilding valid ones', async () => {
|
||||
let verifier = { verify: vi.fn() };
|
||||
let builder = { build: vi.fn() };
|
||||
let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder);
|
||||
verification._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await verification.process();
|
||||
|
||||
expect(verifier.verify).toHaveBeenCalledOnce();
|
||||
expect(builder.build).not.toHaveBeenCalled();
|
||||
expect(verification._console.log).toHaveBeenCalledWith('Verified 2025-03-05');
|
||||
});
|
||||
|
||||
it('verifies pruned archives against their manifests', async () => {
|
||||
let s3Client = new FakeS3Client;
|
||||
s3Client.sourcePruned = true;
|
||||
let verifier = { verify: vi.fn() };
|
||||
let verification = new ArchiveVerifyCommand(s3Client, verifier, { build: vi.fn() });
|
||||
verification._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await verification.process();
|
||||
|
||||
expect(verifier.verify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ date: '2025-03-05' }),
|
||||
null,
|
||||
);
|
||||
expect(verification._console.log).toHaveBeenCalledWith('Verified 2025-03-05');
|
||||
});
|
||||
|
||||
it('reports AppleDouble archives without changing them by default', async () => {
|
||||
let verifier = {
|
||||
verify: vi.fn(async () => {
|
||||
throw new AppleDoubleArchiveError('._alpha.json');
|
||||
}),
|
||||
};
|
||||
let builder = { build: vi.fn() };
|
||||
let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder);
|
||||
verification._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await verification.process();
|
||||
|
||||
expect(builder.build).not.toHaveBeenCalled();
|
||||
expect(verification._console.log).toHaveBeenCalledWith(
|
||||
'2025-03-05 contains unexpected macOS metadata',
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs AppleDouble archives only when repair is enabled', async () => {
|
||||
let verifier = {
|
||||
verify: vi.fn(async () => {
|
||||
throw new AppleDoubleArchiveError('._alpha.json');
|
||||
}),
|
||||
};
|
||||
let builder = { build: vi.fn() };
|
||||
let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder);
|
||||
verification.repair = true;
|
||||
verification._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await verification.process();
|
||||
|
||||
expect(builder.build).toHaveBeenCalledOnce();
|
||||
expect(verification._console.log).toHaveBeenCalledWith('Repaired 2025-03-05');
|
||||
});
|
||||
|
||||
it('stops instead of repairing unrelated verification failures', async () => {
|
||||
let verifier = {
|
||||
verify: vi.fn(async () => {
|
||||
throw new Error('Archive SHA-256 does not match its manifest');
|
||||
}),
|
||||
};
|
||||
let builder = { build: vi.fn() };
|
||||
let verification = new ArchiveVerifyCommand(new FakeS3Client, verifier, builder);
|
||||
verification.repair = true;
|
||||
verification._console = { error: vi.fn(), log: vi.fn() };
|
||||
|
||||
await expect(verification.process()).rejects.toThrow(
|
||||
'Archive SHA-256 does not match its manifest',
|
||||
);
|
||||
expect(builder.build).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
62
app/data/RemoteArchiveVerifier.mjs
Normal file
62
app/data/RemoteArchiveVerifier.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
GetObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { parseArchiveManifest } from './ArchiveManifest.mjs';
|
||||
import ArchiveContentVerifier from './ArchiveContentVerifier.mjs';
|
||||
|
||||
export default class RemoteArchiveVerifier
|
||||
{
|
||||
constructor(s3Client, contentVerifier = new ArchiveContentVerifier) {
|
||||
this._client = s3Client;
|
||||
this.contentVerifier = contentVerifier;
|
||||
}
|
||||
|
||||
async verify(candidate, sourceObjects, manifest = null) {
|
||||
manifest ??= await this.getManifest(candidate);
|
||||
let archiveStream = await this.getArchive(candidate.archiveKey);
|
||||
await this.contentVerifier.verify(archiveStream, manifest, sourceObjects);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
get s3Client() {
|
||||
return this._client ??= new S3Client({
|
||||
endpoint: process.env.AWS_S3_ENDPOINT,
|
||||
region: process.env.AWS_REGION,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getManifest(candidate) {
|
||||
let response = await this.s3Client.send(new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
Key: candidate.manifestKey,
|
||||
}));
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3 returned no body for ${candidate.manifestKey}`);
|
||||
}
|
||||
|
||||
return parseArchiveManifest(
|
||||
await response.Body.transformToString(),
|
||||
candidate.archiveKey,
|
||||
);
|
||||
}
|
||||
|
||||
async getArchive(key) {
|
||||
let response = await this.s3Client.send(new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_ARCHIVE_BUCKET,
|
||||
Key: key,
|
||||
}));
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3 returned no body for ${key}`);
|
||||
}
|
||||
|
||||
return response.Body;
|
||||
}
|
||||
}
|
||||
64
app/data/RemoteArchiveVerifier.test.mjs
Normal file
64
app/data/RemoteArchiveVerifier.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import RemoteArchiveVerifier from './RemoteArchiveVerifier.mjs';
|
||||
|
||||
const candidate = {
|
||||
archiveKey: '2025/03/2025-03-05.tar.zst',
|
||||
date: '2025-03-05',
|
||||
manifestKey: '2025/03/2025-03-05.tar.zst.manifest.json',
|
||||
};
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
date: candidate.date,
|
||||
createdAt: '2026-08-20T00:00:00.000Z',
|
||||
archive: {
|
||||
path: candidate.archiveKey,
|
||||
bytes: 7,
|
||||
hash: `sha256:${'a'.repeat(64)}`,
|
||||
},
|
||||
files: [{
|
||||
path: 'alpha.json',
|
||||
bytes: 6,
|
||||
hash: `sha256:${'b'.repeat(64)}`,
|
||||
}],
|
||||
};
|
||||
|
||||
class FakeS3Client
|
||||
{
|
||||
async send(command) {
|
||||
if (!(command instanceof GetObjectCommand)) {
|
||||
throw new Error(`Unexpected S3 command: ${command.constructor.name}`);
|
||||
}
|
||||
if (command.input.Key === candidate.manifestKey) {
|
||||
return {
|
||||
Body: {
|
||||
transformToString: async () => JSON.stringify(manifest),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command.input.Key === candidate.archiveKey) {
|
||||
return { Body: Readable.from('archive') };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected S3 key: ${command.input.Key}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteArchiveVerifier', () => {
|
||||
it('downloads and verifies an archive and its manifest', async () => {
|
||||
let sourceObjects = [{
|
||||
path: 'alpha.json',
|
||||
bytes: 6,
|
||||
etag: '"9f9f90dbe3e5ee1218c86b8839db1995"',
|
||||
}];
|
||||
let contentVerifier = { verify: vi.fn() };
|
||||
let verifier = new RemoteArchiveVerifier(new FakeS3Client, contentVerifier);
|
||||
|
||||
await expect(verifier.verify(candidate, sourceObjects)).resolves.toEqual(manifest);
|
||||
expect(contentVerifier.verify).toHaveBeenCalledOnce();
|
||||
expect(contentVerifier.verify.mock.calls[0][1]).toEqual(manifest);
|
||||
expect(contentVerifier.verify.mock.calls[0][2]).toBe(sourceObjects);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export default class TarZstdWriter
|
||||
{
|
||||
async write(sourceDirectory, archivePath, files) {
|
||||
let tar = spawn('tar', ['-cf', '-', '-C', sourceDirectory, '--', ...files], {
|
||||
env: { ...process.env, COPYFILE_DISABLE: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let zstd = spawn('zstd', [
|
||||
|
||||
57
app/data/TarZstdWriter.test.mjs
Normal file
57
app/data/TarZstdWriter.test.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { expect, it } from 'vitest';
|
||||
import * as tar from 'tar-stream';
|
||||
import TarZstdWriter from './TarZstdWriter.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function listFiles(stream) {
|
||||
let files = [];
|
||||
let extract = tar.extract();
|
||||
extract.on('entry', (header, entry, next) => {
|
||||
files.push(header.name);
|
||||
entry.on('end', next);
|
||||
entry.resume();
|
||||
});
|
||||
await pipeline(stream, extract);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
it.runIf(process.platform === 'darwin')('does not add macOS metadata files', async () => {
|
||||
let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'splatoon3ink-tar-test-'));
|
||||
let sourceDirectory = path.join(temporaryDirectory, 'source');
|
||||
let archivePath = path.join(temporaryDirectory, 'archive.tar.zst');
|
||||
let rawArchivePath = path.join(temporaryDirectory, 'archive.tar');
|
||||
|
||||
try {
|
||||
await fs.mkdir(sourceDirectory);
|
||||
let sourcePath = path.join(sourceDirectory, 'sample.json');
|
||||
await fs.writeFile(sourcePath, '{}\n');
|
||||
await execFileAsync('xattr', ['-w', 'com.splatoon3ink.test', 'metadata', sourcePath]);
|
||||
let rawTarEnvironment = { ...process.env };
|
||||
delete rawTarEnvironment.COPYFILE_DISABLE;
|
||||
await execFileAsync('tar', [
|
||||
'-cf', rawArchivePath,
|
||||
'-C', sourceDirectory,
|
||||
'--', 'sample.json',
|
||||
], { env: rawTarEnvironment });
|
||||
expect(await listFiles(createReadStream(rawArchivePath))).toEqual([
|
||||
'._sample.json',
|
||||
'sample.json',
|
||||
]);
|
||||
|
||||
await new TarZstdWriter().write(sourceDirectory, archivePath, ['sample.json']);
|
||||
|
||||
let zstd = spawn('zstd', ['-dc', archivePath]);
|
||||
expect(await listFiles(zstd.stdout)).toEqual(['sample.json']);
|
||||
} finally {
|
||||
await fs.rm(temporaryDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -10,7 +10,9 @@ import BlueskyClient from './social/clients/BlueskyClient.mjs';
|
||||
import ThreadsClient from './social/clients/ThreadsClient.mjs';
|
||||
import { archiveData } from './data/DataArchiver.mjs';
|
||||
import { compressArchivesFromCli } from './data/ArchiveCompressor.mjs';
|
||||
import { pruneArchivesFromCli } from './data/ArchivePruner.mjs';
|
||||
import { reportArchiveStatsFromCli } from './data/ArchiveStats.mjs';
|
||||
import { verifyArchivesFromCli } from './data/ArchiveVerifyCommand.mjs';
|
||||
import { sentryInit } from './common/sentry.mjs';
|
||||
import { sync, syncUpload, syncDownload } from './sync/index.mjs';
|
||||
import { updateAvatars } from './social/updateAvatars.mjs';
|
||||
@@ -31,7 +33,9 @@ const actions = {
|
||||
warmCaches,
|
||||
dataArchive: archiveData,
|
||||
archiveCompress: (...args) => compressArchivesFromCli(args),
|
||||
archivePrune: (...args) => pruneArchivesFromCli(args),
|
||||
archiveStats: (...args) => reportArchiveStatsFromCli(args),
|
||||
archiveVerify: (...args) => verifyArchivesFromCli(args),
|
||||
sync,
|
||||
syncUpload,
|
||||
syncDownload,
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"s3-sync-client": "^4.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sirv": "^3.0.2",
|
||||
"tar-stream": "^3.2.0",
|
||||
"twitter-api-v2": "^1.29.0",
|
||||
"vue": "^3.5.28",
|
||||
"vue-i18n": "^11.2.8",
|
||||
@@ -5872,11 +5873,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bare-fs": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz",
|
||||
"integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==",
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz",
|
||||
"integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-events": "^2.5.4",
|
||||
"bare-path": "^3.0.0",
|
||||
@@ -5885,7 +5885,7 @@
|
||||
"fast-fifo": "^1.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=1.16.0"
|
||||
"bare": ">=1.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
@@ -5901,7 +5901,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz",
|
||||
"integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"bare": ">=1.14.0"
|
||||
}
|
||||
@@ -5911,7 +5910,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
|
||||
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-os": "^3.0.1"
|
||||
}
|
||||
@@ -5921,7 +5919,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz",
|
||||
"integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"streamx": "^2.21.0"
|
||||
},
|
||||
@@ -5943,7 +5940,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
|
||||
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
@@ -10914,12 +10910,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
|
||||
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
||||
"integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4",
|
||||
"bare-fs": "^4.5.5",
|
||||
"fast-fifo": "^1.2.0",
|
||||
"streamx": "^2.15.0"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
"data:archive": "node app/index.mjs dataArchive",
|
||||
"data:archive:compress": "node app/index.mjs archiveCompress",
|
||||
"data:archive:compress:dry-run": "node app/index.mjs archiveCompress --dry-run",
|
||||
"data:archive:prune": "node app/index.mjs archivePrune",
|
||||
"data:archive:prune:dry-run": "node app/index.mjs archivePrune --dry-run",
|
||||
"data:archive:stats": "node app/index.mjs archiveStats",
|
||||
"data:archive:verify": "node app/index.mjs archiveVerify",
|
||||
"data:archive:verify:repair": "node app/index.mjs archiveVerify --repair",
|
||||
"sync": "node app/index.mjs sync",
|
||||
"sync:upload": "node app/index.mjs syncUpload",
|
||||
"sync:download": "node app/index.mjs syncDownload"
|
||||
@@ -53,6 +57,7 @@
|
||||
"s3-sync-client": "^4.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sirv": "^3.0.2",
|
||||
"tar-stream": "^3.2.0",
|
||||
"twitter-api-v2": "^1.29.0",
|
||||
"vue": "^3.5.28",
|
||||
"vue-i18n": "^11.2.8",
|
||||
|
||||
Reference in New Issue
Block a user