diff --git a/ProcessMaker/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index da628c28c8..6048bc68bc 100644 --- a/ProcessMaker/Console/Commands/TenantsCreate.php +++ b/ProcessMaker/Console/Commands/TenantsCreate.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; use ProcessMaker\Multitenancy\Tenant; class TenantsCreate extends Command @@ -17,7 +18,17 @@ class TenantsCreate extends Command * * @var string */ - protected $signature = 'tenants:create {--name=} {--url=} {--database=} {--username=} {--password=} {--storage-folder=} {--lang-folder=} {--app-key=}'; + protected $signature = 'tenants:create + {--name=} + {--url=} + {--database=} + {--username=} + {--password=} + {--storage-folder=} + {--lang-folder=} + {--app-key=} + {--skip-initialize-folders} + {--skip-setup-notifications}'; /** * The console command description. @@ -31,6 +42,17 @@ class TenantsCreate extends Command */ public function handle() { + $infoCallback = function ($type, $message) { + if ($type === 'out') { + $this->info($message); + } else { + $this->error($message); + } + }; + + // Check if rsync exists + Process::run('which rsync')->throw(); + $requiredOptions = ['name', 'database', 'url']; foreach ($requiredOptions as $option) { @@ -82,21 +104,8 @@ public function handle() if ($storageFolderOption) { if (File::isDirectory($storageFolderOption)) { $this->info('Moving storage folder to ' . $tenantStoragePath); - $subfoldersToExclude = '/^(tenant_\d+|logs|transitions)$/i'; - foreach (File::directories($storageFolderOption) as $subfolder) { - if (preg_match($subfoldersToExclude, basename($subfolder))) { - $this->info('Skipping ' . $subfolder); - continue; - } - $this->info('Moving ' . $subfolder . ' to ' . $tenantStoragePath); - rename($subfolder, $tenantStoragePath . '/' . basename($subfolder)); - } - - // Move all files from the root storage folder to the tenant storage folder - foreach (File::files($storageFolderOption) as $file) { - $this->info('Moving ' . $file . ' to ' . $tenantStoragePath); - rename($file, $tenantStoragePath . '/' . basename($file)); - } + $cmd = "rsync -avz --exclude='tenant_*' --exclude='logs' --exclude='transitions' " . $storageFolderOption . '/ ' . $tenantStoragePath; + Process::run($cmd, $infoCallback)->throw(); } else { $this->error('Storage folder does not exist: ' . $storageFolderOption); @@ -110,13 +119,14 @@ public function handle() $tenantLangPath = resource_path('lang/tenant_' . $tenant->id); if ($langFolderOption) { if (File::isDirectory($langFolderOption)) { - $this->info('Moving lang folder to ' . $tenantLangPath); + $this->info('Copying lang folder to ' . $tenantLangPath); if (File::isDirectory($tenantLangPath)) { $this->error('Tenant lang path already exists: ' . $tenantLangPath); return 1; } else { - rename($langFolderOption, $tenantLangPath); + $cmd = "rsync -avz --exclude='tenant_*' " . $langFolderOption . '/ ' . $tenantLangPath; + Process::run($cmd, $infoCallback)->throw(); } } else { $this->error('Lang folder does not exist: ' . $langFolderOption); @@ -130,16 +140,13 @@ public function handle() } } - $cmd = sprintf( - "rsync -azv --ignore-existing --exclude='tenant_*' %s %s", - escapeshellarg($sourceLangPath . '/'), - escapeshellarg($tenantLangPath) - ); - exec($cmd, $output, $returnVar); - if ($returnVar !== 0) { - $this->error('Failed to rsync lang folder to tenant: ' . implode(PHP_EOL, $output)); - - return 1; + if (!$this->option('skip-initialize-folders')) { + $cmd = sprintf( + "rsync -azv --ignore-existing --exclude='tenant_*' %s %s", + escapeshellarg($sourceLangPath . '/'), + escapeshellarg($tenantLangPath) + ); + Process::run($cmd, $infoCallback)->throw(); } $subfolders = [ @@ -163,9 +170,11 @@ public function handle() 'api-docs', ]; - foreach ($subfolders as $subfolder) { - if (!File::isDirectory($tenantStoragePath . '/' . $subfolder)) { - mkdir($tenantStoragePath . '/' . $subfolder, 0755, true); + if (!$this->option('skip-initialize-folders')) { + foreach ($subfolders as $subfolder) { + if (!File::isDirectory($tenantStoragePath . '/' . $subfolder)) { + mkdir($tenantStoragePath . '/' . $subfolder, 0755, true); + } } } @@ -183,20 +192,15 @@ public function handle() // Setup database DB::connection('landlord')->statement("CREATE DATABASE IF NOT EXISTS `{$this->option('database')}`"); - // Hold off on this for now. - // $this->tenantArtisan('tenant:storage-link', $tenant->id); - - // Must be run after migrations so skip it. (provider somewhere complains about a table missing) - // $this->tenantArtisan('passport:keys --force', $tenant->id); - - $this->info("Empty tenant created.\n"); - $this->info("With the tenant set (using TENANT={$tenant->id} env prefix) you must now:"); - $this->line('- Run migrations'); - $this->line('- Seed the database'); - $this->line('- Run the install command for each package'); - $this->line('- Run artisan upgrade'); - $this->line('- Generate passport keys with artisan passport:keys'); - $this->info("For example, `TENANT={$tenant->id} php artisan migrate:fresh --seed`"); + $this->info("Empty tenant created. ID: {$tenant->id}"); + if (!$this->option('skip-setup-notifications')) { + $this->info("You will need to do the following using TENANT={$tenant->id} env prefix"); + $this->line('- Run migrations and seed the database'); + $this->line('- Run the install command for each package'); + $this->line('- Run artisan upgrade'); + $this->line('- Install passport by calling passport:install'); + $this->info("For example, `TENANT={$tenant->id} php artisan migrate:fresh --seed`"); + } } private function tenantArtisan($command, $tenantId) diff --git a/ProcessMaker/Console/Commands/TenantsEnable.php b/ProcessMaker/Console/Commands/TenantsEnable.php index ded1179c8a..02714a1ba3 100644 --- a/ProcessMaker/Console/Commands/TenantsEnable.php +++ b/ProcessMaker/Console/Commands/TenantsEnable.php @@ -45,13 +45,6 @@ public function handle() return 1; } - - // Check if rsync exists - if (!exec('which rsync')) { - $this->error('rsync is not installed'); - - return 1; - } } // Get the landlord database name @@ -97,35 +90,17 @@ public function handle() * Begin migration if the --migrate option is provided. */ - // First, copy the existing storage folder to a temp location - $tempStorageFolder = base_path('storage-temp'); - exec("rsync -avz --exclude='tenant_*' " . base_path('storage') . '/ ' . $tempStorageFolder, $output, $returnVar); - if ($returnVar !== 0) { - $this->error('Failed to copy storage folder to temp location'); - $this->error(implode("\n", $output)); - - return 1; - } - $this->info(implode("\n", $output)); - - // Next, do the same thing for the lang folder - $tempLangFolder = base_path('lang-temp'); - exec('rsync -avz ' . resource_path('lang') . '/ ' . $tempLangFolder, $output, $returnVar); - if ($returnVar !== 0) { - $this->error('Failed to copy lang folder to temp location'); - $this->error(implode("\n", $output)); - } - $this->info(implode("\n", $output)); - - // Now, create the tenant. The folder will be moved to the new tenant after the creation - // and the $tempStorageFolder will no longer exist. + // Now, create the tenant. + // The contents of lang-folder and storage-folder will be copied to the new tenant. $exitCode = Artisan::call('tenants:create', [ '--database' => config('database.connections.processmaker.database'), '--url' => config('app.url'), - '--storage-folder' => $tempStorageFolder, - '--lang-folder' => $tempLangFolder, + '--storage-folder' => storage_path(), + '--lang-folder' => lang_path(), '--name' => config('app.name'), '--app-key' => config('app.key'), + '--skip-setup-notifications' => true, + '--skip-initialize-folders' => true, ], $this->output); if ($exitCode !== 0) { @@ -134,22 +109,6 @@ public function handle() return 1; } - // Remove temp storage folder - exec('rm -rf ' . $tempStorageFolder, $output, $returnVar); - if ($returnVar !== 0) { - $this->error('Failed to remove temp storage folder'); - $this->error(implode("\n", $output)); - } - $this->info(implode("\n", $output)); - - // Remove temp lang folder - exec('rm -rf ' . $tempLangFolder, $output, $returnVar); - if ($returnVar !== 0) { - $this->error('Failed to remove temp lang folder'); - $this->error(implode("\n", $output)); - } - $this->info(implode("\n", $output)); - // Add or update the MULTITENANCY env var $this->addOrUpdateEnvVar('MULTITENANCY', 'true'); diff --git a/ProcessMaker/Console/Commands/TenantsTransition.php b/ProcessMaker/Console/Commands/TenantsTransition.php index d6287a907c..9e58220111 100644 --- a/ProcessMaker/Console/Commands/TenantsTransition.php +++ b/ProcessMaker/Console/Commands/TenantsTransition.php @@ -22,7 +22,14 @@ class TenantsTransition extends Command * * @var string */ - protected $description = 'Transition clients to tenants from storage/transitions folder'; + protected $description = 'Transition clients to tenants from .env files in storage/transitions folder'; + + /** + * The success messages. + * + * @var array + */ + protected $success = []; /** * Execute the console command. @@ -44,59 +51,68 @@ public function handle() return 1; } - $clientFolders = File::directories($transitionsPath); + $envFiles = File::files($transitionsPath, true); + $envFiles = array_filter($envFiles, function ($file) { + return str_starts_with(basename($file), '.env'); + }); - if (empty($clientFolders)) { - $this->error('No client folders found in storage/transitions.'); + if (empty($envFiles)) { + $this->error('No .env files found in storage/transitions.'); return 1; } - foreach ($clientFolders as $clientFolder) { - $this->processClientFolder($clientFolder); + foreach ($envFiles as $envFile) { + $exitCode = $this->processEnvFile($envFile); + if ($exitCode !== 0) { + $this->outputSuccessMessages(); + + return $exitCode; + } } $this->info('All clients have been transitioned to tenants.'); + $this->outputSuccessMessages(); return 0; } + private function outputSuccessMessages() + { + foreach ($this->success as $message) { + $this->info($message); + } + } + /** - * Process a single client folder + * Process a single .env file * - * @param string $clientFolder + * @param string $envFilePath * @return void */ - private function processClientFolder(string $clientFolder) + private function processEnvFile(string $envFilePath) { - $clientName = basename($clientFolder); - $this->info("Processing client: {$clientName}"); - - $envFile = $clientFolder . '/.env'; - if (!File::exists($envFile)) { - $this->error("No .env file found in {$clientName}"); - - return; - } + $fileName = basename($envFilePath); + $this->info("Processing .env file: {$fileName}"); // Read the .env file - $envContents = File::get($envFile); + $envContents = File::get($envFilePath); $envVars = $this->parseEnvFile($envContents); // Required environment variables $requiredVars = ['APP_NAME', 'APP_URL', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD']; foreach ($requiredVars as $var) { if (!isset($envVars[$var])) { - $this->error("Missing required environment variable: {$var} in {$clientName}"); + $this->error("Missing required environment variable: {$var} in {$fileName}"); - return; + return 1; } } - $appName = $envVars['PROCESS_INTELLIGENCE_COMPANY_NAME']; + $appName = $envVars['PROCESS_INTELLIGENCE_COMPANY_NAME'] ?? null; if (!$appName) { - // Get the app name from the folder name - $appName = basename($clientFolder); + // Get the app name from the .env file suffix (e.g., .env.my-app -> my-app) + $appName = str_replace('.env.', '', $fileName); } // Create the tenant @@ -105,31 +121,39 @@ private function processClientFolder(string $clientFolder) $this->info("Creating tenant for domain: {$domain}"); // Call tenants:create command + // NOTE: Storage and lang folders will be moved manually after the tenant is created. $command = [ '--name' => $appName, '--url' => $envVars['APP_URL'], '--database' => $envVars['DB_DATABASE'], '--username' => $envVars['DB_USERNAME'], '--password' => $envVars['DB_PASSWORD'], - '--storage-folder' => $clientFolder . '/storage', - '--lang-folder' => $clientFolder . '/lang', '--app-key' => $envVars['APP_KEY'], + '--skip-setup-notifications' => true, + '--skip-initialize-folders' => true, ]; - Artisan::call('tenants:create', $command); + $exitCode = Artisan::call('tenants:create', $command, $this->output); + if ($exitCode !== 0) { + $this->error("Failed to create tenant for domain: {$domain}"); + + return 1; + } // Find the newly created tenant $tenant = Tenant::where('domain', $domain)->first(); if (!$tenant) { $this->error("Failed to find tenant after creation for domain: {$domain}"); - return; + return 1; } - // Delete the client folder - File::deleteDirectory($clientFolder); + // Delete the .env file + File::delete($envFilePath); - $this->info("Successfully transitioned client {$clientName} to tenant."); + $this->success[] = "Success: {$fileName} -> Tenant ID: {$tenant->id}"; + + return 0; } /** diff --git a/README.md b/README.md index be5d4456ea..17789a6e1a 100644 --- a/README.md +++ b/README.md @@ -540,15 +540,16 @@ ProcessMaker can now be set up as a multitenant application. ## Transition your dev instnace to multitenancy -1. Run the following command to enable multitenancy - ``` - php artisan tenants:enable --migrate - ``` - This command will - - Set your existing database as the tenant database - - Move your existing `storage` folder to `storage/tenant_1` - - Move your existing `resources/lang` folder to `resources/lang/tenant_1` - - Enable multitenancy in your .env +Run the following command to enable multitenancy +``` +php artisan tenants:enable --migrate +``` +This command will +- Setup the landlord database. Make sure you create the empty landlord database first. +- Set your existing database as the tenant database +- Copy your existing `storage` folder to `storage/tenant_1` +- Copy your existing `resources/lang` folder to `resources/lang/tenant_1` +- Enable multitenancy in your .env ## Using `valet share` for the script microservice @@ -571,42 +572,29 @@ php artisan tenants:create --domain="another-tenant.test" --name="Another Tenant This command will - Create the required folder structure - Create the tenant database -- Seed the tenant database -- Generate new passport keys -- Run the upgrade commands - -You will need to run the package install commands for all installed packages. - -## Migrate an existing instnace to a tenant -### Option 1: Using the TenantsCreate command +You will need to run migrations, seeders, and package installers with the environment variable prefix TENANT={id} -Provide the --storage-folder option with the path to the storage folder of the instance you want to migrate. Use the existing instances database name. +## `tenants:transition` command -Optionally, provide the --username and --password options of the db for the existing instance. +Move multiple instnaces in bulk to a single multitenancy instance. -The storage folder will be **moved** to the new tenant's storage folder. -``` -php artisan tenants:create --domain="some-tenant.test" --name="Some Tenant" --database="some_tenant" --storage-folder="/path/to/storage/folder" -``` - -### Option 2: Using the TenantsTransition command +If this is your local development environment, it's easier to use `tenants:enable --migrate` above. Create a folder `storage/transitions` if it doesn't exist. -In that folder create a new folder for each instance you want to migrate. - -The folder should contain exactly 3 things: -- the .env from the instance you want to migrate -- the storage folder from the instance you want to migrate, named `storage` -- the resources/lang folder, named `lang` +For each instance you want to transition into this multitenancy instance, +copy the `.env` file into the transitions folder and add the instnace name to the file name. +For example `.env.my-instance` Run the following command to migrate the instance(s) to a tenant: ``` php artisan tenants:transition ``` -This command will: +This command will create a new tenant for each .env file in the storage/transitions folder. + +You must move the tenants storage folder and the resources/lang folder manually # License