From 1c583b22229332193a9361a0b968630d19295ab5 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 27 Aug 2025 10:21:42 -0700 Subject: [PATCH 1/4] Copy instead of move dependent files --- .../Console/Commands/TenantsCreate.php | 42 ++++++++--------- .../Console/Commands/TenantsEnable.php | 47 +------------------ .../Console/Commands/TenantsTransition.php | 9 +++- 3 files changed, 28 insertions(+), 70 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index da628c28c8..a6afb79a95 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 @@ -31,6 +32,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', $infoCallback)->throw(); + $requiredOptions = ['name', 'database', 'url']; foreach ($requiredOptions as $option) { @@ -82,21 +94,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 +109,15 @@ 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); + // rename($langFolderOption, $tenantLangPath); + $cmd = "rsync -avz --exclude='tenant_*' " . $langFolderOption . '/ ' . $tenantLangPath; + Process::run($cmd, $infoCallback)->throw(); } } else { $this->error('Lang folder does not exist: ' . $langFolderOption); @@ -135,12 +136,7 @@ public function handle() 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; - } + Process::run($cmd, $infoCallback)->throw(); $subfolders = [ 'app', diff --git a/ProcessMaker/Console/Commands/TenantsEnable.php b/ProcessMaker/Console/Commands/TenantsEnable.php index ded1179c8a..740a842211 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,28 +90,8 @@ 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'), @@ -134,22 +107,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..70fafb11fe 100644 --- a/ProcessMaker/Console/Commands/TenantsTransition.php +++ b/ProcessMaker/Console/Commands/TenantsTransition.php @@ -93,7 +93,7 @@ private function processClientFolder(string $clientFolder) } } - $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); @@ -116,7 +116,12 @@ private function processClientFolder(string $clientFolder) '--app-key' => $envVars['APP_KEY'], ]; - 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; + } // Find the newly created tenant $tenant = Tenant::where('domain', $domain)->first(); From f08b0f03a4b3692041206c6bd424c1af67a5bdf6 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 27 Aug 2025 10:53:45 -0700 Subject: [PATCH 2/4] Fix missing variable --- ProcessMaker/Console/Commands/TenantsCreate.php | 4 ++-- ProcessMaker/Console/Commands/TenantsEnable.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index a6afb79a95..50fe2e4d64 100644 --- a/ProcessMaker/Console/Commands/TenantsCreate.php +++ b/ProcessMaker/Console/Commands/TenantsCreate.php @@ -186,12 +186,12 @@ public function handle() // $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->info("Unless you ran `tenants:enable --migrate`, you will need to run the following using TENANT={$tenant->id} env prefix"); $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->line('- Install passport with artisan passport:install'); $this->info("For example, `TENANT={$tenant->id} php artisan migrate:fresh --seed`"); } diff --git a/ProcessMaker/Console/Commands/TenantsEnable.php b/ProcessMaker/Console/Commands/TenantsEnable.php index 740a842211..390bcc6127 100644 --- a/ProcessMaker/Console/Commands/TenantsEnable.php +++ b/ProcessMaker/Console/Commands/TenantsEnable.php @@ -95,8 +95,8 @@ public function handle() $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'), ], $this->output); From b1fd34793f274b193c479b3b5d97829f1baabd1d Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 5 Sep 2025 11:27:38 -0700 Subject: [PATCH 3/4] Update tenants transition command --- .../Console/Commands/TenantsCreate.php | 53 ++++++------- .../Console/Commands/TenantsEnable.php | 2 + .../Console/Commands/TenantsTransition.php | 79 ++++++++++++------- README.md | 52 +++++------- 4 files changed, 97 insertions(+), 89 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index 50fe2e4d64..931b465c8c 100644 --- a/ProcessMaker/Console/Commands/TenantsCreate.php +++ b/ProcessMaker/Console/Commands/TenantsCreate.php @@ -18,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-storage-folder} + {--skip-setup-notifications}'; /** * The console command description. @@ -41,7 +51,7 @@ public function handle() }; // Check if rsync exists - Process::run('which rsync', $infoCallback)->throw(); + Process::run('which rsync')->throw(); $requiredOptions = ['name', 'database', 'url']; @@ -115,7 +125,6 @@ public function handle() return 1; } else { - // rename($langFolderOption, $tenantLangPath); $cmd = "rsync -avz --exclude='tenant_*' " . $langFolderOption . '/ ' . $tenantLangPath; Process::run($cmd, $infoCallback)->throw(); } @@ -131,13 +140,6 @@ public function handle() } } - $cmd = sprintf( - "rsync -azv --ignore-existing --exclude='tenant_*' %s %s", - escapeshellarg($sourceLangPath . '/'), - escapeshellarg($tenantLangPath) - ); - Process::run($cmd, $infoCallback)->throw(); - $subfolders = [ 'app', 'app/private', @@ -159,9 +161,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-storage-folder')) { + foreach ($subfolders as $subfolder) { + if (!File::isDirectory($tenantStoragePath . '/' . $subfolder)) { + mkdir($tenantStoragePath . '/' . $subfolder, 0755, true); + } } } @@ -179,20 +183,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("Unless you ran `tenants:enable --migrate`, you will need to run the following using TENANT={$tenant->id} env prefix"); - $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('- Install passport with artisan passport:install'); - $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 390bcc6127..8a17aa4313 100644 --- a/ProcessMaker/Console/Commands/TenantsEnable.php +++ b/ProcessMaker/Console/Commands/TenantsEnable.php @@ -99,6 +99,8 @@ public function handle() '--lang-folder' => lang_path(), '--name' => config('app.name'), '--app-key' => config('app.key'), + '--skip-setup-notifications' => true, + '--skip-initialize-storage-folder' => true, ], $this->output); if ($exitCode !== 0) { diff --git a/ProcessMaker/Console/Commands/TenantsTransition.php b/ProcessMaker/Console/Commands/TenantsTransition.php index 70fafb11fe..5424d4ee0a 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'] ?? 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,22 +121,23 @@ 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-storage-folder' => true, ]; $exitCode = Artisan::call('tenants:create', $command, $this->output); if ($exitCode !== 0) { $this->error("Failed to create tenant for domain: {$domain}"); - return; + return 1; } // Find the newly created tenant @@ -128,13 +145,15 @@ private function processClientFolder(string $clientFolder) 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 From abd6f6286856a6d5dd28291a8d887de2f6ccca6e Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 5 Sep 2025 15:12:28 -0700 Subject: [PATCH 4/4] Copy original lang folder on tenants create --- ProcessMaker/Console/Commands/TenantsCreate.php | 13 +++++++++++-- ProcessMaker/Console/Commands/TenantsEnable.php | 2 +- ProcessMaker/Console/Commands/TenantsTransition.php | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index 931b465c8c..6048bc68bc 100644 --- a/ProcessMaker/Console/Commands/TenantsCreate.php +++ b/ProcessMaker/Console/Commands/TenantsCreate.php @@ -27,7 +27,7 @@ class TenantsCreate extends Command {--storage-folder=} {--lang-folder=} {--app-key=} - {--skip-initialize-storage-folder} + {--skip-initialize-folders} {--skip-setup-notifications}'; /** @@ -140,6 +140,15 @@ public function handle() } } + 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 = [ 'app', 'app/private', @@ -161,7 +170,7 @@ public function handle() 'api-docs', ]; - if (!$this->option('skip-initialize-storage-folder')) { + if (!$this->option('skip-initialize-folders')) { foreach ($subfolders as $subfolder) { if (!File::isDirectory($tenantStoragePath . '/' . $subfolder)) { mkdir($tenantStoragePath . '/' . $subfolder, 0755, true); diff --git a/ProcessMaker/Console/Commands/TenantsEnable.php b/ProcessMaker/Console/Commands/TenantsEnable.php index 8a17aa4313..02714a1ba3 100644 --- a/ProcessMaker/Console/Commands/TenantsEnable.php +++ b/ProcessMaker/Console/Commands/TenantsEnable.php @@ -100,7 +100,7 @@ public function handle() '--name' => config('app.name'), '--app-key' => config('app.key'), '--skip-setup-notifications' => true, - '--skip-initialize-storage-folder' => true, + '--skip-initialize-folders' => true, ], $this->output); if ($exitCode !== 0) { diff --git a/ProcessMaker/Console/Commands/TenantsTransition.php b/ProcessMaker/Console/Commands/TenantsTransition.php index 5424d4ee0a..9e58220111 100644 --- a/ProcessMaker/Console/Commands/TenantsTransition.php +++ b/ProcessMaker/Console/Commands/TenantsTransition.php @@ -130,7 +130,7 @@ private function processEnvFile(string $envFilePath) '--password' => $envVars['DB_PASSWORD'], '--app-key' => $envVars['APP_KEY'], '--skip-setup-notifications' => true, - '--skip-initialize-storage-folder' => true, + '--skip-initialize-folders' => true, ]; $exitCode = Artisan::call('tenants:create', $command, $this->output);