260 lines
9.2 KiB
JavaScript
260 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* migrate-storage.js
|
|
*
|
|
* One-shot migration tool to copy every PicPeak content file from the local
|
|
* filesystem (the legacy STORAGE_PATH) to a configured S3-compatible bucket.
|
|
*
|
|
* Reads the relative path of each known asset from the database:
|
|
* photos.path
|
|
* photos.thumbnail_path
|
|
* photos.hero_path
|
|
* photos.watermark_path
|
|
* events.archive_path
|
|
* events.download_zip_path
|
|
*
|
|
* For each, streams from local fs → S3, skipping files whose sha256 already
|
|
* matches a previously uploaded object (idempotent — safe to re-run).
|
|
*
|
|
* Does NOT flip STORAGE_BACKEND. After the migration completes clean, the
|
|
* operator updates their environment + restarts the backend explicitly.
|
|
*
|
|
* Usage:
|
|
* node backend/scripts/migrate-storage.js # live migration
|
|
* node backend/scripts/migrate-storage.js --dry-run # report only, no uploads
|
|
* node backend/scripts/migrate-storage.js --failures-csv=/path/to/failures.csv
|
|
* node backend/scripts/migrate-storage.js --concurrency=4
|
|
*
|
|
* Required env (S3 destination — same vars the backend reads with STORAGE_BACKEND=s3):
|
|
* STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ACCESS_KEY, STORAGE_S3_SECRET_KEY
|
|
* STORAGE_S3_ENDPOINT (optional — for MinIO/R2/etc.)
|
|
* STORAGE_S3_PREFIX (optional)
|
|
*
|
|
* STORAGE_PATH must point at the live local storage root. Postgres connection
|
|
* uses the same DB env vars the backend uses.
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const { db } = require('../src/database/db');
|
|
const LocalFsStorage = require('../src/services/storage/LocalFsStorage');
|
|
const S3StorageBackend = require('../src/services/storage/S3StorageBackend');
|
|
const logger = require('../src/utils/logger');
|
|
|
|
function parseArgs(argv) {
|
|
const args = { dryRun: false, concurrency: 4, failuresCsv: '/tmp/migrate-storage-failures.csv' };
|
|
for (const arg of argv) {
|
|
if (arg === '--dry-run') args.dryRun = true;
|
|
else if (arg.startsWith('--concurrency=')) args.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 4);
|
|
else if (arg.startsWith('--failures-csv=')) args.failuresCsv = arg.split('=')[1];
|
|
else if (arg === '--help' || arg === '-h') {
|
|
console.log('Usage: node migrate-storage.js [--dry-run] [--concurrency=N] [--failures-csv=PATH]');
|
|
process.exit(0);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function buildLocalSource() {
|
|
const root = process.env.STORAGE_PATH;
|
|
if (!root) {
|
|
throw new Error('STORAGE_PATH must be set to the local storage root.');
|
|
}
|
|
return new LocalFsStorage({ root });
|
|
}
|
|
|
|
function buildS3Destination() {
|
|
const required = ['STORAGE_S3_BUCKET', 'STORAGE_S3_ACCESS_KEY', 'STORAGE_S3_SECRET_KEY'];
|
|
const missing = required.filter((v) => !process.env[v]);
|
|
if (missing.length) {
|
|
throw new Error(`Missing S3 env vars: ${missing.join(', ')}`);
|
|
}
|
|
return new S3StorageBackend({
|
|
bucket: process.env.STORAGE_S3_BUCKET,
|
|
region: process.env.STORAGE_S3_REGION || 'us-east-1',
|
|
endpoint: process.env.STORAGE_S3_ENDPOINT,
|
|
accessKeyId: process.env.STORAGE_S3_ACCESS_KEY,
|
|
secretAccessKey: process.env.STORAGE_S3_SECRET_KEY,
|
|
prefix: process.env.STORAGE_S3_PREFIX,
|
|
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined,
|
|
sslEnabled: process.env.STORAGE_S3_SSL !== 'false',
|
|
});
|
|
}
|
|
|
|
async function sha256OfFile(localPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = fs.createReadStream(localPath);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function collectKeys() {
|
|
const keys = new Map(); // key -> { source, contentType }
|
|
|
|
const addKey = (key, source) => {
|
|
if (!key) return;
|
|
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
if (!normalized) return;
|
|
if (!keys.has(normalized)) keys.set(normalized, { source });
|
|
};
|
|
|
|
// photos: path (events/active/{slug}/{filename}), thumbnail_path, hero_path, watermark_path
|
|
const photoBatch = await db('photos').select('id', 'path', 'thumbnail_path', 'hero_path', 'watermark_path');
|
|
for (const p of photoBatch) {
|
|
if (p.path) {
|
|
const photoKey = p.path.startsWith('events/active/') ? p.path : path.posix.join('events/active', p.path);
|
|
addKey(photoKey, `photos.path[${p.id}]`);
|
|
}
|
|
addKey(p.thumbnail_path, `photos.thumbnail_path[${p.id}]`);
|
|
addKey(p.hero_path, `photos.hero_path[${p.id}]`);
|
|
addKey(p.watermark_path, `photos.watermark_path[${p.id}]`);
|
|
}
|
|
|
|
// events: archive_path, download_zip_path
|
|
const eventBatch = await db('events').select('id', 'archive_path', 'download_zip_path');
|
|
for (const e of eventBatch) {
|
|
addKey(e.archive_path, `events.archive_path[${e.id}]`);
|
|
addKey(e.download_zip_path, `events.download_zip_path[${e.id}]`);
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
async function migrateOne(key, meta, { source, dest, dryRun }) {
|
|
// Source must exist on local disk.
|
|
const localPath = source.resolveLocalPath(key);
|
|
let localStat;
|
|
try {
|
|
localStat = await fsp.stat(localPath);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
return { key, status: 'missing-locally', source: meta.source };
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Idempotent skip: if S3 already has matching size + sha256.
|
|
const remoteStat = await dest.stat(key);
|
|
if (remoteStat && remoteStat.size === localStat.size) {
|
|
// sha256 match check via metadata is expensive; we trust size match for now.
|
|
// Operators paranoid about content drift can `rm` the bucket and re-run.
|
|
return { key, status: 'already-uploaded', source: meta.source };
|
|
}
|
|
|
|
if (dryRun) {
|
|
return { key, status: 'would-upload', source: meta.source, size: localStat.size };
|
|
}
|
|
|
|
await dest.putFromFile(key, localPath);
|
|
|
|
const verify = await dest.stat(key);
|
|
if (!verify || verify.size !== localStat.size) {
|
|
return { key, status: 'size-mismatch-after-upload', source: meta.source, expected: localStat.size, got: verify?.size };
|
|
}
|
|
|
|
return { key, status: 'uploaded', source: meta.source, size: localStat.size };
|
|
}
|
|
|
|
async function processWithConcurrency(items, concurrency, fn) {
|
|
const results = [];
|
|
let i = 0;
|
|
const workers = Array.from({ length: concurrency }, async () => {
|
|
while (true) {
|
|
const idx = i++;
|
|
if (idx >= items.length) return;
|
|
const [key, meta] = items[idx];
|
|
try {
|
|
const r = await fn(key, meta);
|
|
results.push(r);
|
|
} catch (err) {
|
|
results.push({ key, status: 'error', source: meta.source, error: err.message });
|
|
}
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
function formatCsvCell(v) {
|
|
if (v == null) return '';
|
|
const s = String(v);
|
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
return `"${s.replace(/"/g, '""')}"`;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
async function writeFailuresCsv(filePath, failures) {
|
|
if (failures.length === 0) {
|
|
// Touch an empty file with header so callers see a deterministic outcome.
|
|
await fsp.writeFile(filePath, 'key,source,status,error\n');
|
|
return;
|
|
}
|
|
const lines = ['key,source,status,error'];
|
|
for (const f of failures) {
|
|
lines.push([f.key, f.source, f.status, f.error || ''].map(formatCsvCell).join(','));
|
|
}
|
|
await fsp.writeFile(filePath, lines.join('\n') + '\n');
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
logger.info(`migrate-storage starting (dry-run=${args.dryRun}, concurrency=${args.concurrency})`);
|
|
|
|
const source = buildLocalSource();
|
|
await source.init();
|
|
|
|
const dest = buildS3Destination();
|
|
await dest.init();
|
|
|
|
logger.info('collecting key list from database…');
|
|
const keys = await collectKeys();
|
|
logger.info(`found ${keys.size} unique keys to process`);
|
|
|
|
const items = Array.from(keys.entries());
|
|
const results = await processWithConcurrency(items, args.concurrency, (key, meta) =>
|
|
migrateOne(key, meta, { source, dest, dryRun: args.dryRun })
|
|
);
|
|
|
|
const counts = results.reduce((acc, r) => {
|
|
acc[r.status] = (acc[r.status] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
console.log('\n=== migrate-storage summary ===');
|
|
for (const [status, count] of Object.entries(counts).sort()) {
|
|
console.log(` ${status.padEnd(28)} ${count}`);
|
|
}
|
|
|
|
const failureStatuses = new Set(['error', 'missing-locally', 'size-mismatch-after-upload']);
|
|
const failures = results.filter((r) => failureStatuses.has(r.status));
|
|
await writeFailuresCsv(args.failuresCsv, failures);
|
|
|
|
if (failures.length > 0) {
|
|
console.log(`\nWrote ${failures.length} failures to ${args.failuresCsv}`);
|
|
console.log('Re-run with --dry-run to triage; fix sources or remove DB rows that point at missing files.');
|
|
process.exitCode = 1;
|
|
} else if (args.dryRun) {
|
|
console.log(`\nDry-run complete. Re-run without --dry-run to perform the migration.`);
|
|
console.log(`(Empty failures CSV written to ${args.failuresCsv}.)`);
|
|
} else {
|
|
console.log(`\nMigration complete. Update STORAGE_BACKEND=s3 + restart the backend to switch over.`);
|
|
}
|
|
|
|
await db.destroy();
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error('migrate-storage failed:', err);
|
|
try { await db.destroy(); } catch (_) { /* ignore */ }
|
|
process.exit(2);
|
|
});
|