307 lines
10 KiB
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);
|
|
}
|
|
}
|
|
}
|