nhance_partner_be/app/Commands/StorageMigrateLocalToS3.php

307 lines
10 KiB
PHP

<?php
namespace App\Commands;
use App\Services\FileStorageService;
use App\Services\Storage\FileStorageException;
use App\Services\Storage\S3StorageDriver;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Storage;
class StorageMigrateLocalToS3 extends BaseCommand
{
protected $group = 'Storage';
protected $name = 'storage:migrate-local-to-s3';
protected $description = 'Migrate existing local writable/uploads files to S3 (policy & endorsement folders).';
protected $usage = 'storage:migrate-local-to-s3 [options]';
protected $options = [
'--dry-run' => '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<string, int> */
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<array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool}>
*/
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<string> 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);
}
}
}