const path = require('path'); const { createStorage } = require('../storage'); const SKIP_PREFIXES = ['.thumbs/', '.cache/']; function parseArgs(argv) { const opts = { dryRun: false, deleteLocal: false, verifyOnly: false, concurrency: 3, prefix: null, help: false }; for (const arg of argv.slice(2)) { if (arg === '--dry-run') opts.dryRun = true; else if (arg === '--delete-local') opts.deleteLocal = true; else if (arg === '--keep-local') opts.deleteLocal = false; else if (arg === '--verify-only') opts.verifyOnly = true; else if (arg.startsWith('--concurrency=')) opts.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 1); else if (arg.startsWith('--prefix=')) opts.prefix = arg.split('=')[1].replace(/^\/+|\/+$/g, ''); else if (arg === '--help' || arg === '-h') opts.help = true; } return opts; } function usage() { console.log(`Миграция uploads/ -> S3 (MinIO). Использование: node scripts/migrate-to-s3.js [ключи] Ключи: --dry-run показать, что будет загружено, ничего не менять --verify-only только проверить наличие объектов в S3 --delete-local удалить локальные файлы после успешной проверки --keep-local оставить локальные файлы (по умолчанию) --concurrency=N параллельных загрузок (по умолчанию 3) --prefix=PREFIX префикс ключей внутри бакета Переменные окружения: STORAGE_DRIVER=s3, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.`); } async function runPool(items, limit, worker) { const queue = items.slice(); const results = { ok: 0, skip: 0, fail: 0 }; const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => { while (queue.length) { const item = queue.shift(); const res = await worker(item); if (res === 'ok') results.ok++; else if (res === 'skip') results.skip++; else results.fail++; } }); await Promise.all(runners); return results; } async function main() { const opts = parseArgs(process.argv); if (opts.help) return usage(); const dir = path.join(__dirname, '..', 'uploads'); const source = createStorage({ dir, driver: 'local' }); const target = createStorage({ driver: 's3', prefix: opts.prefix || undefined }); if (!target.isRemote()) { console.error('STORAGE_DRIVER не s3: задайте STORAGE_DRIVER=s3 и переменные S3_*.'); process.exit(2); } if (!target.bucket) { console.error('Не задан S3_BUCKET.'); process.exit(2); } const all = await source.listAll(''); const objects = all.filter(o => !SKIP_PREFIXES.some(p => o.key.startsWith(p))); const totalBytes = objects.reduce((sum, o) => sum + (o.size || 0), 0); if (!opts.dryRun) await target.ensureBucket(); console.log(`Локально: ${objects.length} файлов, ${(totalBytes / 1048576).toFixed(2)} МБ`); console.log(`Бакет: ${target.bucket}${opts.prefix ? ' префикс ' + opts.prefix : ''} (${process.env.S3_ENDPOINT || 'endpoint по умолчанию'})`); if (opts.dryRun) console.log('Режим --dry-run: изменения не применяются.'); const failures = []; const result = await runPool(objects, opts.concurrency, async (obj) => { const localPath = source.localFile(obj.key); if (!localPath) { failures.push(`${obj.key}: некорректный путь`); return 'fail'; } try { const remote = await target.head(obj.key); if (remote && remote.size === obj.size) { if (opts.deleteLocal && !opts.dryRun) source.unlinkLocalOnly(obj.key); return 'skip'; } if (opts.verifyOnly) { failures.push(`${obj.key}: нет объекта в S3`); return 'fail'; } if (opts.dryRun) { console.log(` + ${obj.key} (${obj.size} Б)`); return 'ok'; } await target.putFile(obj.key, localPath); const check = await target.head(obj.key); if (!check || check.size !== obj.size) { failures.push(`${obj.key}: размер не совпал после загрузки`); return 'fail'; } if (opts.deleteLocal) source.unlinkLocalOnly(obj.key); return 'ok'; } catch (err) { failures.push(`${obj.key}: ${err.message}`); return 'fail'; } }); const usage = await target.usage().catch(() => null); console.log(`Загружено: ${result.ok}, уже было: ${result.skip}, ошибок: ${result.fail}`); if (usage) console.log(`В бакете: ${usage.count} объектов, ${(usage.size_bytes / 1048576).toFixed(2)} МБ`); if (failures.length) { console.error('Ошибки:'); for (const f of failures.slice(0, 50)) console.error(' - ' + f); if (failures.length > 50) console.error(` ... ещё ${failures.length - 50}`); process.exit(1); } if (!opts.deleteLocal && !opts.dryRun) { console.log('Локальные файлы оставлены. Удалить после проверки: node scripts/migrate-to-s3.js --delete-local'); } } main().catch(err => { console.error('Миграция не выполнена:', err.message); process.exit(1); });