From 38f33ba9bd3bf98e07bb42367e3fe070e8b40183 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 7 Jan 2026 18:27:48 +1100 Subject: [PATCH] Added provision script. --- .vortex/tooling/src/provision | 530 +++++++++++++ .vortex/tooling/tests/Unit/ProvisionTest.php | 784 +++++++++++++++++++ 2 files changed, 1314 insertions(+) create mode 100755 .vortex/tooling/src/provision create mode 100644 .vortex/tooling/tests/Unit/ProvisionTest.php diff --git a/.vortex/tooling/src/provision b/.vortex/tooling/src/provision new file mode 100755 index 000000000..d53f2c23a --- /dev/null +++ b/.vortex/tooling/src/provision @@ -0,0 +1,530 @@ +#!/usr/bin/env php + '0';\""); + pass('Updated username with user email.'); + } + + // Sanitize using additional SQL commands provided in file. + if (!empty($additional_file) && file_exists($additional_file)) { + // The file path is relative to the project root, but drush expects it to be + // relative to the Drupal root. + $relative_file = str_replace('./', '../', $additional_file); + drush('sql:query --file=' . escapeshellarg($relative_file)); + pass('Applied custom sanitization commands from file.'); + } + + // User mail and name for user 0 could have been sanitized - resetting it. + drush("sql:query \"UPDATE \\`users_field_data\\` SET mail = '', name = '' WHERE uid = '0';\""); + drush("sql:query \"UPDATE \\`users_field_data\\` SET name = '' WHERE uid = '0';\""); + pass('Reset user 0 username and email.'); + + // User email could have been sanitized - setting it back to a pre-defined email. + if (!empty($admin_email)) { + drush(sprintf("sql:query \"UPDATE \\`users_field_data\\` SET mail = '%s' WHERE uid = '1';\"", $admin_email)); + pass('Updated user 1 email.'); + } + + echo PHP_EOL; +} + +/** + * Run custom provision scripts. + * + * @param string $scripts_dir + * Directory containing custom provision scripts. + */ +function run_custom_scripts(string $scripts_dir): void { + if (!is_dir($scripts_dir)) { + return; + } + + $files = glob($scripts_dir . '/provision-*.sh'); + if ($files === FALSE || empty($files)) { + return; + } + + foreach ($files as $file) { + if (is_file($file)) { + task('Running custom post-install script \'%s\'.', $file); + echo PHP_EOL; + passthru($file, $script_exit_code); + echo PHP_EOL; + if ($script_exit_code === 0) { + pass('Completed running of custom post-install script \'%s\'.', $file); + } + else { + fail('Custom post-install script \'%s\' failed with exit code %d.', $file, $script_exit_code); + } + echo PHP_EOL; + } + } +} + +// ----------------------------------------------------------------------------- + +info('Started site provisioning.'); + +$start_time = time(); + +if ($provision_skip === '1') { + pass('Skipped site provisioning as VORTEX_PROVISION_SKIP is set to 1.'); + info('Finished site provisioning.'); + quit(0); +} + +// Convert DB dir starting with './' to a full path. +if (str_starts_with($db_dir, './')) { + $db_dir = getcwd() . substr($db_dir, 1); +} + +if (empty($provision_db)) { + $provision_db = $db_dir . '/' . $db_file; +} + +// Get drush and Drupal versions. +$drush_version_output = drush('--version', $drush_version_exit_code); +$drush_version = trim(preg_replace('/.*?(\d+\.\d+\.\d+).*/', '$1', $drush_version_output) ?? 'Unknown'); + +$drupal_version = trim(drush('status --field=drupal-version 2>/dev/null', $drupal_version_exit_code)) ?: 'Unknown'; + +// Check if site is installed. +$bootstrap_output = drush('status --fields=bootstrap 2>/dev/null', $bootstrap_exit_code); +$site_is_installed = str_contains($bootstrap_output, 'Successful'); + +// Discover the configuration directory path from the Drupal settings. +$config_path = trim(drush("php:eval \"print realpath(\\Drupal\\Core\\Site\\Settings::get('config_sync_directory'));\"")); +if (empty($config_path)) { + fail('Config directory was not found in the Drupal settings.'); + quit(1); +} +if (!is_dir($config_path)) { + fail('Config directory "%s" does not exist.', $config_path); + quit(1); +} + +// Check if config files exist. +$config_files = glob($config_path . '/*.yml'); +$site_has_config_files = !empty($config_files) && count($config_files) > 0; + +// Normalize the provision type. +if ($provision_type !== 'profile') { + $provision_type = 'database'; +} + +// Print provisioning information. +echo PHP_EOL; +note('Drupal core version : %s', $drupal_version); +note('Drush version : %s', $drush_version); +echo PHP_EOL; +note('Web root path : %s/%s', getcwd(), $webroot); +note('Public files path : %s', getenv('DRUPAL_PUBLIC_FILES') ?: ''); +note('Private files path : %s', getenv('DRUPAL_PRIVATE_FILES') ?: ''); +note('Temporary files path : %s', getenv('DRUPAL_TEMPORARY_FILES') ?: ''); +note('Config files path : %s', $config_path); +note('DB dump file path : %s (%s)', $provision_db, file_exists($provision_db) ? 'present' : 'absent'); +if (!empty($db_image)) { + note('DB dump container image : %s', $db_image); +} +echo PHP_EOL; +note('Profile : %s', $drupal_profile); +note('Configuration files present : %s', yesno($site_has_config_files ? '1' : '0')); +note('Existing site found : %s', yesno($site_is_installed ? '1' : '0')); +echo PHP_EOL; +note('Provision type : %s', $provision_type); +note('Overwrite existing DB : %s', yesno($provision_override_db)); +note('Skip DB sanitization : %s', yesno($provision_sanitize_db_skip)); +note('Skip post-provision operations : %s', yesno($provision_post_operations_skip)); +note('Use maintenance mode : %s', yesno($provision_use_maintenance_mode)); +echo PHP_EOL; + +// Provision site from DB dump or profile. +if ($provision_type === 'database') { + info('Provisioning site from the database dump file.'); + note('Dump file path: %s', $provision_db); + + if ($site_is_installed) { + note('Existing site was found.'); + + if (!empty($db_image)) { + note('Database is baked into the container image.'); + note('Site content will be preserved.'); + // Container image restarts with a fresh database. Let the downstream + // scripts know that the database is fresh. + $provision_override_db = '1'; + putenv('VORTEX_PROVISION_OVERRIDE_DB=1'); + } + elseif ($provision_override_db === '1') { + note('Existing site content will be removed and fresh content will be imported from the database dump file.'); + provision_from_db($provision_db); + } + else { + note('Site content will be preserved.'); + note('Sanitization will be skipped for an existing database.'); + $provision_sanitize_db_skip = '1'; + putenv('VORTEX_PROVISION_SANITIZE_DB_SKIP=1'); + } + } + else { + note('Existing site was not found.'); + + if (!empty($db_image)) { + note('Database is baked into the container image.'); + note('Looks like the database in the container image is corrupted.'); + note('Site content was not changed.'); + quit(1); + } + + note('Fresh site content will be imported from the database dump file.'); + provision_from_db($provision_db); + // Let the downstream scripts know that the database is fresh. + $provision_override_db = '1'; + putenv('VORTEX_PROVISION_OVERRIDE_DB=1'); + } +} +else { + info('Provisioning site from the profile.'); + note('Profile: %s.', $drupal_profile); + + if ($site_is_installed) { + note('Existing site was found.'); + + if ($provision_override_db === '1') { + note('Existing site content will be removed and new content will be created from the profile.'); + provision_from_profile($drupal_profile, $drupal_site_name, $drupal_site_email, $drupal_admin_email, $site_has_config_files); + // Let the downstream scripts know that the database is fresh. + $provision_override_db = '1'; + putenv('VORTEX_PROVISION_OVERRIDE_DB=1'); + } + else { + note('Site content will be preserved.'); + note('Sanitization will be skipped for an existing database.'); + $provision_sanitize_db_skip = '1'; + putenv('VORTEX_PROVISION_SANITIZE_DB_SKIP=1'); + } + } + else { + note('Existing site was not found.'); + note('Fresh site content will be created from the profile.'); + provision_from_profile($drupal_profile, $drupal_site_name, $drupal_site_email, $drupal_admin_email, $site_has_config_files); + $provision_override_db = '1'; + putenv('VORTEX_PROVISION_OVERRIDE_DB=1'); + } +} + +echo PHP_EOL; + +$environment = trim(drush("php:eval \"print \\Drupal\\core\\Site\\Settings::get('environment');\"")); +info('Current Drupal environment: %s', $environment); +echo PHP_EOL; + +if ($provision_post_operations_skip === '1') { + info('Skipped running of post-provision operations as VORTEX_PROVISION_POST_OPERATIONS_SKIP is set to 1.'); + echo PHP_EOL; + $duration = time() - $start_time; + info('Finished site provisioning (%dm %ds).', (int) floor($duration / 60), $duration % 60); + quit(0); +} + +if ($provision_use_maintenance_mode === '1') { + task('Enabling maintenance mode.'); + drush('maint:set 1'); + pass('Enabled maintenance mode.'); + echo PHP_EOL; +} + +// Use 'drush deploy' if configuration files are present or use standalone commands otherwise. +if ($site_has_config_files) { + $system_site_yml = $config_path . '/system.site.yml'; + if (file_exists($system_site_yml)) { + $system_site_content = file_get_contents($system_site_yml); + if ($system_site_content !== FALSE && preg_match('/uuid:\s*([a-f0-9-]{36})/', $system_site_content, $matches)) { + $config_uuid = $matches[1]; + drush(sprintf('config-set system.site uuid %s', escapeshellarg($config_uuid))); + pass('Updated site UUID from the configuration with %s.', $config_uuid); + echo PHP_EOL; + } + } + + task('Running deployment operations via \'drush deploy\'.'); + drush('deploy'); + pass('Completed deployment operations via \'drush deploy\'.'); + echo PHP_EOL; + + // Import config_split configuration if the module is installed. + // Drush deploy does not import config_split configuration on the first run. + // @see https://github.com/drush-ops/drush/issues/2449 + // @see https://www.drupal.org/project/drupal/issues/3241439 + $pm_list_output = drush('pm:list --status=enabled', $pm_list_exit_code); + if (str_contains($pm_list_output, 'config_split')) { + task('Importing config_split configuration.'); + drush('config:import'); + pass('Completed config_split configuration import.'); + echo PHP_EOL; + } +} +else { + task('Running database updates.'); + drush('updatedb --no-cache-clear'); + pass('Completed running database updates.'); + echo PHP_EOL; + + task('Rebuilding cache.'); + drush('cache:rebuild'); + pass('Cache was rebuilt.'); + echo PHP_EOL; + + task('Running deployment operations via \'drush deploy:hook\'.'); + drush('deploy:hook'); + pass('Completed deployment operations via \'drush deploy:hook\'.'); + echo PHP_EOL; +} + +// Sanitize database. +if ($provision_sanitize_db_skip !== '1') { + sanitize_db( + $sanitize_db_email, + $sanitize_db_password, + $sanitize_db_replace_username_with_email === '1', + $sanitize_db_additional_file, + $drupal_admin_email + ); +} +else { + pass('Skipped database sanitization as VORTEX_PROVISION_SANITIZE_DB_SKIP is set to 1.'); + echo PHP_EOL; +} + +// Run custom provision scripts. +run_custom_scripts($provision_scripts_dir); + +if ($provision_use_maintenance_mode === '1') { + task('Disabling maintenance mode.'); + drush('maint:set 0'); + pass('Disabled maintenance mode.'); + echo PHP_EOL; +} + +$duration = time() - $start_time; +info('Finished site provisioning (%dm %ds).', (int) floor($duration / 60), $duration % 60); + +quit(0); diff --git a/.vortex/tooling/tests/Unit/ProvisionTest.php b/.vortex/tooling/tests/Unit/ProvisionTest.php new file mode 100644 index 000000000..9898dfa7a --- /dev/null +++ b/.vortex/tooling/tests/Unit/ProvisionTest.php @@ -0,0 +1,784 @@ +configPath = self::$tmp . '/config/default'; + mkdir($this->configPath, 0755, TRUE); + + $this->dbDumpPath = self::$tmp . '/.data'; + mkdir($this->dbDumpPath, 0755, TRUE); + + // Create a valid config file. + file_put_contents($this->configPath . '/system.site.yml', "uuid: 12345678-1234-1234-1234-123456789012\nname: Test Site\n"); + + // Create a valid db dump file. + file_put_contents($this->dbDumpPath . '/db.sql', 'CREATE TABLE test;'); + + // Create vendor/bin/drush. + $drush_dir = self::$tmp . '/vendor/bin'; + mkdir($drush_dir, 0755, TRUE); + file_put_contents($drush_dir . '/drush', "#!/bin/bash\necho 'drush'\n"); + chmod($drush_dir . '/drush', 0755); + + // Set up default environment variables. + $this->envSetMultiple([ + 'VORTEX_PROVISION_SKIP' => '', + 'VORTEX_PROVISION_TYPE' => 'database', + 'VORTEX_PROVISION_OVERRIDE_DB' => '0', + 'VORTEX_PROVISION_SANITIZE_DB_SKIP' => '0', + 'VORTEX_PROVISION_USE_MAINTENANCE_MODE' => '0', + 'VORTEX_PROVISION_POST_OPERATIONS_SKIP' => '0', + 'VORTEX_PROVISION_DB' => '', + 'VORTEX_PROVISION_SCRIPTS_DIR' => self::$tmp . '/scripts/custom', + 'WEBROOT' => 'web', + 'DRUPAL_SITE_NAME' => 'Test Site', + 'DRUPAL_SITE_EMAIL' => 'test@example.com', + 'DRUPAL_PROFILE' => 'standard', + 'VORTEX_DB_DIR' => $this->dbDumpPath, + 'VORTEX_DB_FILE' => 'db.sql', + 'VORTEX_DB_IMAGE' => '', + 'DRUPAL_ADMIN_EMAIL' => '', + 'VORTEX_PROVISION_SANITIZE_DB_EMAIL' => 'user+%uid@localhost', + 'VORTEX_PROVISION_SANITIZE_DB_PASSWORD' => 'testpassword123', + 'VORTEX_PROVISION_SANITIZE_DB_REPLACE_USERNAME_WITH_EMAIL' => '0', + 'VORTEX_PROVISION_SANITIZE_DB_ADDITIONAL_FILE' => '', + 'DRUPAL_PUBLIC_FILES' => 'sites/default/files', + 'DRUPAL_PRIVATE_FILES' => 'sites/default/private', + 'DRUPAL_TEMPORARY_FILES' => '/tmp', + ]); + } + + public function testSkipProvisioning(): void { + $this->envSet('VORTEX_PROVISION_SKIP', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Started site provisioning.', $output); + $this->assertStringContainsString('Skipped site provisioning as VORTEX_PROVISION_SKIP is set to 1.', $output); + $this->assertStringContainsString('Finished site provisioning.', $output); + } + + public function testProvisionFromDbNewSite(): void { + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Started site provisioning.', $output); + $this->assertStringContainsString('Provisioning site from the database dump file.', $output); + $this->assertStringContainsString('Existing site was not found.', $output); + $this->assertStringContainsString('Fresh site content will be imported from the database dump file.', $output); + $this->assertStringContainsString('Imported database from the dump file.', $output); + $this->assertStringContainsString('Finished site provisioning', $output); + } + + public function testProvisionFromDbExistingSiteWithOverride(): void { + $this->envSet('VORTEX_PROVISION_OVERRIDE_DB', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: TRUE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Existing site was found.', $output); + $this->assertStringContainsString('Existing site content will be removed and fresh content will be imported from the database dump file.', $output); + $this->assertStringContainsString('Imported database from the dump file.', $output); + } + + public function testProvisionFromDbExistingSiteWithoutOverride(): void { + $this->envSet('VORTEX_PROVISION_OVERRIDE_DB', '0'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: TRUE, has_config_files: TRUE); + $this->mockPostProvisionOps(with_config: TRUE); + // No sanitization because it's skipped for existing DB. + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Existing site was found.', $output); + $this->assertStringContainsString('Site content will be preserved.', $output); + $this->assertStringContainsString('Sanitization will be skipped for an existing database.', $output); + $this->assertStringContainsString('Skipped database sanitization as VORTEX_PROVISION_SANITIZE_DB_SKIP is set to 1.', $output); + } + + public function testProvisionFromDbWithContainerImageExistingSite(): void { + $this->envSet('VORTEX_DB_IMAGE', 'myregistry/mydb:latest'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: TRUE, has_config_files: TRUE); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Database is baked into the container image.', $output); + $this->assertStringContainsString('Site content will be preserved.', $output); + $this->assertStringContainsString('DB dump container image : myregistry/mydb:latest', $output); + } + + public function testProvisionFromDbWithContainerImageNoSite(): void { + $this->envSet('VORTEX_DB_IMAGE', 'myregistry/mydb:latest'); + + $this->mockQuit(1); + $this->expectException(QuitErrorException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Database is baked into the container image.', $output); + $this->assertStringContainsString('Looks like the database in the container image is corrupted.', $output); + } + + public function testProvisionFromProfileNewSite(): void { + $this->envSet('VORTEX_PROVISION_TYPE', 'profile'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockProfileInstall(has_config_files: TRUE); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Provisioning site from the profile.', $output); + $this->assertStringContainsString('Existing site was not found.', $output); + $this->assertStringContainsString('Fresh site content will be created from the profile.', $output); + $this->assertStringContainsString('Installed a site from the profile.', $output); + } + + public function testProvisionFromProfileExistingSiteWithOverride(): void { + $this->envSet('VORTEX_PROVISION_TYPE', 'profile'); + $this->envSet('VORTEX_PROVISION_OVERRIDE_DB', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: TRUE, has_config_files: TRUE); + $this->mockProfileInstall(has_config_files: TRUE); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Provisioning site from the profile.', $output); + $this->assertStringContainsString('Existing site was found.', $output); + $this->assertStringContainsString('Existing site content will be removed and new content will be created from the profile.', $output); + $this->assertStringContainsString('Installed a site from the profile.', $output); + } + + public function testProvisionFromProfileExistingSiteWithoutOverride(): void { + $this->envSet('VORTEX_PROVISION_TYPE', 'profile'); + $this->envSet('VORTEX_PROVISION_OVERRIDE_DB', '0'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: TRUE, has_config_files: TRUE); + $this->mockPostProvisionOps(with_config: TRUE); + // No sanitization because it's skipped for existing DB. + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Provisioning site from the profile.', $output); + $this->assertStringContainsString('Existing site was found.', $output); + $this->assertStringContainsString('Site content will be preserved.', $output); + $this->assertStringContainsString('Sanitization will be skipped for an existing database.', $output); + } + + public function testPostOperationsSkip(): void { + $this->envSet('VORTEX_PROVISION_POST_OPERATIONS_SKIP', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + + // Mock environment output. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y php:eval \"print \\Drupal\\core\\Site\\Settings::get('environment');\"", + 'output' => 'development', + 'result_code' => 0, + ]); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Skipped running of post-provision operations as VORTEX_PROVISION_POST_OPERATIONS_SKIP is set to 1.', $output); + $this->assertStringContainsString('Finished site provisioning', $output); + } + + public function testMaintenanceModeEnabled(): void { + $this->envSet('VORTEX_PROVISION_USE_MAINTENANCE_MODE', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOpsWithMaintenance(with_config: TRUE); + $this->mockSanitization(); + $this->mockDisableMaintenanceMode(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Enabling maintenance mode.', $output); + $this->assertStringContainsString('Enabled maintenance mode.', $output); + $this->assertStringContainsString('Disabling maintenance mode.', $output); + $this->assertStringContainsString('Disabled maintenance mode.', $output); + } + + public function testNoConfigFiles(): void { + // Remove config files. + unlink($this->configPath . '/system.site.yml'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: FALSE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: FALSE); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Configuration files present : No', $output); + $this->assertStringContainsString('Running database updates.', $output); + $this->assertStringContainsString('Rebuilding cache.', $output); + $this->assertStringContainsString("Running deployment operations via 'drush deploy:hook'.", $output); + } + + public function testConfigSplitImport(): void { + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOpsWithConfigSplit(); + $this->mockSanitization(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Importing config_split configuration.', $output); + $this->assertStringContainsString('Completed config_split configuration import.', $output); + } + + public function testMissingDbDumpFile(): void { + // Remove the db dump file. + unlink($this->dbDumpPath . '/db.sql'); + + $this->mockQuit(1); + $this->expectException(QuitErrorException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Unable to import database from file.', $output); + $this->assertStringContainsString('does not exist.', $output); + } + + public function testSanitizationSkipped(): void { + $this->envSet('VORTEX_PROVISION_SANITIZE_DB_SKIP', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + // No sanitization mocks needed. + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Skipped database sanitization as VORTEX_PROVISION_SANITIZE_DB_SKIP is set to 1.', $output); + } + + public function testSanitizationWithUsernameReplacement(): void { + $this->envSet('VORTEX_PROVISION_SANITIZE_DB_REPLACE_USERNAME_WITH_EMAIL', '1'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitizationWithUsernameReplacement(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Sanitizing database.', $output); + $this->assertStringContainsString('Updated username with user email.', $output); + } + + public function testSanitizationWithAdminEmail(): void { + $this->envSet('DRUPAL_ADMIN_EMAIL', 'admin@example.com'); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitizationWithAdminEmail(); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Updated user 1 email.', $output); + } + + public function testSanitizationWithAdditionalFile(): void { + // Create additional sanitization file. + $sanitize_file = self::$tmp . '/scripts/sanitize.sql'; + mkdir(dirname($sanitize_file), 0755, TRUE); + file_put_contents($sanitize_file, 'UPDATE test SET value = NULL;'); + $this->envSet('VORTEX_PROVISION_SANITIZE_DB_ADDITIONAL_FILE', $sanitize_file); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitizationWithAdditionalFile($sanitize_file); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Applied custom sanitization commands from file.', $output); + } + + public function testCustomProvisionScripts(): void { + // Create custom scripts directory with a provision script. + $scripts_dir = self::$tmp . '/scripts/custom'; + mkdir($scripts_dir, 0755, TRUE); + $script_file = $scripts_dir . '/provision-10-test.sh'; + file_put_contents($script_file, "#!/bin/bash\necho 'Custom script executed'\n"); + chmod($script_file, 0755); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $this->setupDrushMocks(site_installed: FALSE, has_config_files: TRUE); + $this->mockDbImport(); + $this->mockPostProvisionOps(with_config: TRUE); + $this->mockSanitization(); + + // Mock the custom script execution. + $this->mockPassthru([ + 'cmd' => $script_file, + 'output' => 'Custom script executed', + 'result_code' => 0, + ]); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString("Running custom post-install script '" . $script_file . "'.", $output); + $this->assertStringContainsString("Completed running of custom post-install script '" . $script_file . "'.", $output); + } + + public function testMissingConfigDirectory(): void { + $this->mockQuit(1); + $this->expectException(QuitErrorException::class); + + // Mock drush version. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y --version', + 'output' => 'Drush Version: 12.0.0', + 'result_code' => 0, + ]); + + // Mock drupal version. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y status --field=drupal-version 2>/dev/null', + 'output' => '10.2.0', + 'result_code' => 0, + ]); + + // Mock bootstrap check. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y status --fields=bootstrap 2>/dev/null', + 'output' => '', + 'result_code' => 0, + ]); + + // Mock config path - return empty. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y php:eval \"print realpath(\\Drupal\\Core\\Site\\Settings::get('config_sync_directory'));\"", + 'output' => '', + 'result_code' => 0, + ]); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Config directory was not found in the Drupal settings.', $output); + } + + protected function setupDrushMocks(bool $site_installed, bool $has_config_files): void { + // Mock drush version. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y --version', + 'output' => 'Drush Version: 12.0.0', + 'result_code' => 0, + ]); + + // Mock drupal version. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y status --field=drupal-version 2>/dev/null', + 'output' => '10.2.0', + 'result_code' => 0, + ]); + + // Mock bootstrap check. + $bootstrap_output = $site_installed ? 'Successful' : ''; + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y status --fields=bootstrap 2>/dev/null', + 'output' => $bootstrap_output, + 'result_code' => 0, + ]); + + // Mock config path. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y php:eval \"print realpath(\\Drupal\\Core\\Site\\Settings::get('config_sync_directory'));\"", + 'output' => $this->configPath, + 'result_code' => 0, + ]); + } + + protected function mockDbImport(): void { + // Mock sql:drop. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y sql:drop', + 'output' => 'Tables dropped', + 'result_code' => 0, + ]); + + // Mock sql:connect. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y sql:connect', + 'output' => 'mysql -u root -h localhost drupal', + 'result_code' => 0, + ]); + + // Mock the actual import command. + $this->mockPassthru([ + 'cmd' => "mysql -u root -h localhost drupal <'" . $this->dbDumpPath . "/db.sql'", + 'output' => '', + 'result_code' => 0, + ]); + } + + protected function mockProfileInstall(bool $has_config_files): void { + // Mock sql:drop (may fail, that's OK). + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y sql:drop', + 'output' => '', + 'result_code' => 0, + ]); + + // Build expected site:install command. + $cmd = "./vendor/bin/drush -y site:install 'standard' --site-name='Test Site' --site-mail='test@example.com' --account-name=admin install_configure_form.enable_update_status_module=NULL install_configure_form.enable_update_status_emails=NULL"; + if ($has_config_files) { + $cmd .= ' --existing-config'; + } + + $this->mockPassthru([ + 'cmd' => $cmd, + 'output' => 'Site installed', + 'result_code' => 0, + ]); + } + + protected function mockPostProvisionOps(bool $with_config): void { + // Mock environment output. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y php:eval \"print \\Drupal\\core\\Site\\Settings::get('environment');\"", + 'output' => 'development', + 'result_code' => 0, + ]); + + if ($with_config) { + // Mock config-set for UUID. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y config-set system.site uuid '12345678-1234-1234-1234-123456789012'", + 'output' => '', + 'result_code' => 0, + ]); + + // Mock drush deploy. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y deploy', + 'output' => 'Deployment complete', + 'result_code' => 0, + ]); + + // Mock pm:list check for config_split. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y pm:list --status=enabled', + 'output' => 'some_module', + 'result_code' => 0, + ]); + } + else { + // Mock updatedb. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y updatedb --no-cache-clear', + 'output' => 'Updates complete', + 'result_code' => 0, + ]); + + // Mock cache:rebuild. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y cache:rebuild', + 'output' => 'Cache rebuilt', + 'result_code' => 0, + ]); + + // Mock deploy:hook. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y deploy:hook', + 'output' => 'Hooks executed', + 'result_code' => 0, + ]); + } + } + + protected function mockPostProvisionOpsWithMaintenance(bool $with_config): void { + // Mock environment output. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y php:eval \"print \\Drupal\\core\\Site\\Settings::get('environment');\"", + 'output' => 'development', + 'result_code' => 0, + ]); + + // Mock enable maintenance mode. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y maint:set 1', + 'output' => '', + 'result_code' => 0, + ]); + + if ($with_config) { + // Mock config-set for UUID. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y config-set system.site uuid '12345678-1234-1234-1234-123456789012'", + 'output' => '', + 'result_code' => 0, + ]); + + // Mock drush deploy. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y deploy', + 'output' => 'Deployment complete', + 'result_code' => 0, + ]); + + // Mock pm:list check for config_split. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y pm:list --status=enabled', + 'output' => 'some_module', + 'result_code' => 0, + ]); + } + // Note: Sanitization mocks should be called here by the test, then + // the disable maintenance mode mock should be added after. + } + + protected function mockDisableMaintenanceMode(): void { + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y maint:set 0', + 'output' => '', + 'result_code' => 0, + ]); + } + + protected function mockPostProvisionOpsWithConfigSplit(): void { + // Mock environment output. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y php:eval \"print \\Drupal\\core\\Site\\Settings::get('environment');\"", + 'output' => 'development', + 'result_code' => 0, + ]); + + // Mock config-set for UUID. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y config-set system.site uuid '12345678-1234-1234-1234-123456789012'", + 'output' => '', + 'result_code' => 0, + ]); + + // Mock drush deploy. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y deploy', + 'output' => 'Deployment complete', + 'result_code' => 0, + ]); + + // Mock pm:list check for config_split - returns config_split enabled. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y pm:list --status=enabled', + 'output' => 'config_split', + 'result_code' => 0, + ]); + + // Mock config:import. + $this->mockPassthru([ + 'cmd' => './vendor/bin/drush -y config:import', + 'output' => 'Config imported', + 'result_code' => 0, + ]); + } + + protected function mockSanitization(): void { + // Mock sql:sanitize. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:sanitize --sanitize-password='testpassword123' --sanitize-email='user+%uid@localhost'", + 'output' => 'Sanitized', + 'result_code' => 0, + ]); + + // Mock reset user 0. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET mail = '', name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + } + + protected function mockSanitizationWithUsernameReplacement(): void { + // Mock sql:sanitize. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:sanitize --sanitize-password='testpassword123' --sanitize-email='user+%uid@localhost'", + 'output' => 'Sanitized', + 'result_code' => 0, + ]); + + // Mock username replacement. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET users_field_data.name=users_field_data.mail WHERE uid <> '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + + // Mock reset user 0. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET mail = '', name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + } + + protected function mockSanitizationWithAdminEmail(): void { + // Mock sql:sanitize. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:sanitize --sanitize-password='testpassword123' --sanitize-email='user+%uid@localhost'", + 'output' => 'Sanitized', + 'result_code' => 0, + ]); + + // Mock reset user 0. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET mail = '', name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + + // Mock admin email update. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET mail = 'admin@example.com' WHERE uid = '1';\"", + 'output' => '', + 'result_code' => 0, + ]); + } + + protected function mockSanitizationWithAdditionalFile(string $file): void { + // Mock sql:sanitize. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:sanitize --sanitize-password='testpassword123' --sanitize-email='user+%uid@localhost'", + 'output' => 'Sanitized', + 'result_code' => 0, + ]); + + // Mock additional file import. + // Note: The file path is converted from ./ to ../ for drush. + $relative_file = str_replace('./', '../', $file); + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query --file='" . $relative_file . "'", + 'output' => '', + 'result_code' => 0, + ]); + + // Mock reset user 0. + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET mail = '', name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + + $this->mockPassthru([ + 'cmd' => "./vendor/bin/drush -y sql:query \"UPDATE \\`users_field_data\\` SET name = '' WHERE uid = '0';\"", + 'output' => '', + 'result_code' => 0, + ]); + } + +}