nhance/app/Commands/MigrateLocalFilesToS3.php
2026-07-15 15:49:13 +05:30

218 lines
7.4 KiB
PHP

<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class MigrateLocalFilesToS3 extends BaseCommand
{
protected $group = 'Storage';
protected $name = 'storage:migrate-local-to-s3';
protected $description = 'One-time phased migration of local uploaded files to S3 using FileStorageService.';
protected $usage = 'storage:migrate-local-to-s3 [--module writable_uploads|<folder_name>|insurer_claim_form|all] [--limit 500] [--offset 0] [--execute]';
protected $options = [
'--module' => 'Target module/folder (default: writable_uploads). Use writable_uploads for all writable/uploads/* folders.',
'--limit' => 'Max files per run (default: 500).',
'--offset' => 'Start offset for phased batches (default: 0).',
'--execute' => 'Actually upload files. Without this flag, command runs in dry-run mode.',
];
/**
* @var array<string, string>
*/
private array $staticPaths = [
'insurer_claim_form' => WRITEPATH . 'insurer_claim_form',
];
public function run(array $params)
{
$moduleOpt = strtolower((string) (CLI::getOption('module') ?? 'writable_uploads'));
$execute = CLI::getOption('execute') !== null;
$dryRun = !$execute;
$limit = max(1, (int) (CLI::getOption('limit') ?? 500));
$offset = max(0, (int) (CLI::getOption('offset') ?? 0));
$modulePaths = $this->buildModulePaths();
$selectedModules = $this->resolveModules($moduleOpt, $modulePaths);
if ($selectedModules === []) {
CLI::error('Invalid --module value. Allowed: writable_uploads, ' . implode(', ', array_keys($modulePaths)) . ', all');
return;
}
$storage = \Config\Services::getFileStorageService(false);
if (!$storage->usesS3()) {
CLI::error('FileStorageService is not in S3 mode. Set FILE_STORAGE_DRIVER=s3 before migration.');
return;
}
CLI::write($dryRun ? 'Running in DRY-RUN mode (no uploads).' : 'Running in EXECUTE mode (uploads enabled).', 'yellow');
CLI::write('Target bucket: ' . ($storage->getBucket() ?? 'N/A'), 'cyan');
CLI::newLine();
$totals = [
'seen' => 0,
'uploaded' => 0,
'skipped_exists' => 0,
'skipped_missing' => 0,
'failed' => 0,
];
foreach ($selectedModules as $module) {
$path = $modulePaths[$module];
CLI::write("Module: {$module}", 'green');
CLI::write("Path: {$path}");
if (!is_dir($path)) {
CLI::write(' - Skipped: folder does not exist', 'yellow');
$totals['skipped_missing']++;
CLI::newLine();
continue;
}
$files = $this->listFiles($path);
$files = array_slice($files, $offset, $limit);
CLI::write(' - Files in batch: ' . count($files));
foreach ($files as $fullPath) {
$totals['seen']++;
$fileName = basename($fullPath);
if (!is_file($fullPath)) {
$totals['skipped_missing']++;
CLI::write(" - Missing: {$fileName}", 'yellow');
continue;
}
$alreadyExists = $storage->exists($path, $fileName);
if ($alreadyExists) {
$totals['skipped_exists']++;
CLI::write(" - Exists in S3, skipped: {$fileName}", 'yellow');
continue;
}
if ($dryRun) {
CLI::write(" - [dry-run] Would upload: {$fileName}");
continue;
}
$result = $storage->upload($fullPath, $path, $fileName);
if ($result['success'] ?? false) {
$totals['uploaded']++;
CLI::write(" - Uploaded: {$fileName}", 'green');
} else {
$totals['failed']++;
CLI::write(" - Failed: {$fileName} | " . ($result['message'] ?? 'unknown error'), 'red');
}
}
CLI::newLine();
}
CLI::write('Migration summary', 'cyan');
CLI::write(' Seen: ' . $totals['seen']);
CLI::write(' Uploaded: ' . $totals['uploaded'], $totals['uploaded'] > 0 ? 'green' : 'white');
CLI::write(' Skipped (already in S3): ' . $totals['skipped_exists'], 'yellow');
CLI::write(' Skipped (missing/invalid): ' . $totals['skipped_missing'], 'yellow');
CLI::write(' Failed: ' . $totals['failed'], $totals['failed'] > 0 ? 'red' : 'white');
if ($dryRun) {
CLI::newLine();
CLI::write('Dry-run complete. Re-run with --execute to perform uploads.', 'yellow');
}
}
/**
* @return array<int, string>
*/
private function resolveModules(string $moduleOpt, array $modulePaths): array
{
if ($moduleOpt === 'all') {
return array_keys($modulePaths);
}
if ($moduleOpt === 'writable_uploads') {
$uploadRoot = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR;
$modules = [];
foreach ($modulePaths as $module => $path) {
$normalized = str_replace('\\', '/', $path);
$normalizedRoot = str_replace('\\', '/', $uploadRoot);
if (strpos($normalized, rtrim($normalizedRoot, '/')) === 0) {
$modules[] = $module;
}
}
return $modules;
}
$raw = array_filter(array_map('trim', explode(',', $moduleOpt)));
$valid = [];
foreach ($raw as $module) {
if (array_key_exists($module, $modulePaths)) {
$valid[] = $module;
}
}
return array_values(array_unique($valid));
}
/**
* Build migratable paths:
* - all direct subfolders in writable/uploads
* - additional static paths (outside writable/uploads)
*
* @return array<string, string>
*/
private function buildModulePaths(): array
{
$paths = [];
$uploadRoot = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads';
$entries = @scandir($uploadRoot);
if (is_array($entries)) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = $uploadRoot . DIRECTORY_SEPARATOR . $entry;
if (is_dir($fullPath)) {
$paths[strtolower($entry)] = $fullPath;
}
}
}
foreach ($this->staticPaths as $key => $path) {
$paths[$key] = $path;
}
ksort($paths);
return $paths;
}
/**
* @return array<int, string>
*/
private function listFiles(string $path): array
{
$entries = @scandir($path);
if (!is_array($entries)) {
return [];
}
$files = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = rtrim($path, '/\\') . DIRECTORY_SEPARATOR . $entry;
if (is_file($fullPath)) {
$files[] = $fullPath;
}
}
sort($files);
return $files;
}
}