'List files that would be migrated without uploading.', '--force' => 'Re-upload even if the object already exists on S3.', '--delete-local' => 'Delete local file after successful S3 upload + verify.', '--verify' => 'Compare MD5 of local file vs S3 read-back after upload (default: on).', '--no-verify' => 'Skip read-back verification after upload.', '--module' => 'Migrate only one module: policy or endorsement.', ]; /** @var array */ private array $stats = [ 'scanned' => 0, 'uploaded' => 0, 'skipped' => 0, 'failed' => 0, 'deleted' => 0, ]; public function run(array $params) { $config = config('Storage'); if ($config->s3Bucket === '' || $config->s3AccessKey === '' || $config->s3SecretKey === '') { CLI::error('S3 is not configured. Set AWS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in .env'); return EXIT_ERROR; } $dryRun = (bool) CLI::getOption('dry-run'); $force = (bool) CLI::getOption('force'); $deleteLocal = (bool) CLI::getOption('delete-local'); $verify = ! CLI::getOption('no-verify'); $moduleFilter = CLI::getOption('module'); if (is_string($moduleFilter) && $moduleFilter !== '' && ! in_array($moduleFilter, ['policy', 'endorsement'], true)) { CLI::error('Invalid --module value. Allowed: policy, endorsement'); return EXIT_ERROR; } try { $s3Driver = new S3StorageDriver($config); } catch (FileStorageException $e) { CLI::error($e->getMessage()); return EXIT_ERROR; } $storage = new FileStorageService($config); CLI::write('Local to S3 migration', 'green'); CLI::write('Local root : ' . $config->localRoot, 'cyan'); CLI::write('S3 bucket : ' . $config->s3Bucket, 'cyan'); CLI::write('S3 region : ' . $config->s3Region, 'cyan'); CLI::write('S3 prefix : ' . ($config->s3Prefix !== '' ? $config->s3Prefix : '(none)'), 'cyan'); CLI::write('Mode : ' . ($dryRun ? 'DRY RUN' : 'LIVE'), $dryRun ? 'yellow' : 'green'); CLI::newLine(); $targets = $this->migrationTargets(); if (is_string($moduleFilter) && $moduleFilter !== '') { $targets = array_values(array_filter( $targets, static fn (array $target) => $target['module'] === $moduleFilter )); } foreach ($targets as $target) { $this->migrateTarget($target, $s3Driver, $storage, $dryRun, $force, $deleteLocal, $verify); } CLI::newLine(); CLI::write('Migration summary', 'yellow'); CLI::write(' Scanned : ' . $this->stats['scanned'], 'white'); CLI::write(' Uploaded: ' . $this->stats['uploaded'], 'green'); CLI::write(' Skipped : ' . $this->stats['skipped'], 'yellow'); CLI::write(' Failed : ' . $this->stats['failed'], $this->stats['failed'] > 0 ? 'red' : 'white'); CLI::write(' Deleted : ' . $this->stats['deleted'], 'white'); if ($dryRun) { CLI::newLine(); CLI::write('Dry run complete. Re-run without --dry-run to upload.', 'yellow'); } return $this->stats['failed'] === 0 ? EXIT_SUCCESS : EXIT_ERROR; } /** * @return list */ private function migrationTargets(): array { return [ [ 'label' => 'policy_pdf', 'module' => 'policy', 'subFolder' => 'policy_pdf', 'localRelative' => 'uploads/policy/policy_pdf', 'topLevelOnly' => true, ], [ 'label' => 'policy_payment_receipt', 'module' => 'policy', 'subFolder' => 'policy_payment_receipt', 'localRelative' => 'uploads/policy/policy_payment_receipt', 'topLevelOnly' => true, ], [ 'label' => 'policy_md', 'module' => 'policy', 'subFolder' => 'policy_md', 'localRelative' => 'uploads/policy/policy_md', 'topLevelOnly' => true, ], [ 'label' => 'endorsement_original', 'module' => 'endorsement', 'subFolder' => '', 'localRelative' => 'uploads/endorsement', 'topLevelOnly' => true, ], [ 'label' => 'endorsement_completion', 'module' => 'endorsement', 'subFolder' => 'endorsement_pdf', 'localRelative' => 'uploads/endorsement/endorsement_pdf', 'topLevelOnly' => true, ], ]; } /** * @param array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool} $target */ private function migrateTarget( array $target, S3StorageDriver $s3Driver, FileStorageService $storage, bool $dryRun, bool $force, bool $deleteLocal, bool $verify ): void { $localDir = rtrim(config('Storage')->localRoot, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $target['localRelative']); CLI::write('[' . $target['label'] . '] ' . $target['localRelative'], 'yellow'); if (! is_dir($localDir)) { CLI::write(' Directory not found, skipping.', 'dark_gray'); CLI::newLine(); return; } $files = $this->listLocalFiles($localDir, $target['topLevelOnly']); if ($files === []) { CLI::write(' No files found.', 'dark_gray'); CLI::newLine(); return; } foreach ($files as $localPath) { $this->stats['scanned']++; $fileName = basename($localPath); $key = $storage->resolveKey($target['module'], $target['subFolder'], $fileName); $size = filesize($localPath) ?: 0; try { if (! $force && $s3Driver->exists($key)) { $this->stats['skipped']++; CLI::write(" skip (exists on S3): {$key}", 'dark_gray'); continue; } if ($dryRun) { $this->stats['uploaded']++; CLI::write(" would upload: {$key} ({$size} bytes)", 'cyan'); continue; } $s3Driver->upload($localPath, $key); if ($verify) { $this->verifyUpload($localPath, $s3Driver, $key); } $this->stats['uploaded']++; CLI::write(" uploaded: {$key} ({$size} bytes)", 'green'); if ($deleteLocal) { if (@unlink($localPath)) { $this->stats['deleted']++; CLI::write(' deleted local: ' . $localPath, 'dark_gray'); } else { throw new FileStorageException('Uploaded to S3 but failed to delete local file: ' . $localPath); } } } catch (\Throwable $e) { $this->stats['failed']++; CLI::error(" failed: {$key} -> " . $e->getMessage()); } } CLI::newLine(); } /** * @return list absolute local file paths */ private function listLocalFiles(string $directory, bool $topLevelOnly): array { $files = []; if ($topLevelOnly) { foreach (scandir($directory) ?: [] as $item) { if ($item === '.' || $item === '..') { continue; } $path = $directory . DIRECTORY_SEPARATOR . $item; if (! is_file($path)) { continue; } if ($this->shouldSkipFile($item)) { continue; } $files[] = $path; } sort($files); return $files; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $fileInfo) { if (! $fileInfo->isFile()) { continue; } $name = $fileInfo->getFilename(); if ($this->shouldSkipFile($name)) { continue; } $files[] = $fileInfo->getPathname(); } sort($files); return $files; } private function shouldSkipFile(string $fileName): bool { return in_array(strtolower($fileName), ['index.html', '.htaccess', '.gitkeep'], true); } private function verifyUpload(string $localPath, S3StorageDriver $s3Driver, string $key): void { $localHash = md5_file($localPath); if ($localHash === false) { throw new FileStorageException('Unable to hash local file: ' . $localPath); } $remoteContents = $s3Driver->read($key); $remoteHash = md5($remoteContents); if ($localHash !== $remoteHash) { throw new FileStorageException('Verification failed: S3 content hash mismatch for ' . $key); } } }