Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions ProcessMaker/Managers/PluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,7 @@ public function install(string $repoUrl, ?string $branch = null, ?string $tag =
$this->logRunning('Running plugin install command...', $repoName, $userId);
$this->runCommand($installCommand, $repoName, $userId);

// Rebuild route cache so we pick up the new routes from the plugin
$this->logRunning('Rebuilding route cache...', $repoName, $userId);
$this->runCommand('route:cache', $repoName, $userId);
$this->rebuildRouteCache($repoName, $userId);

$this->logDone('Plugin installed successfully', $repoName, $userId);
}
Expand Down Expand Up @@ -129,9 +127,7 @@ public function uninstall(string $pluginName, ?int $userId = null): void
$this->logRunning('Removing plugin directory...', $pluginName, $userId);
$this->deleteDirectory($pluginPath, $pluginName, $userId);

// Rebuild route cache so we remove the routes from the plugin
$this->logRunning('Rebuilding route cache...', $pluginName, $userId);
$this->runCommand('route:cache', $pluginName);
$this->rebuildRouteCache($pluginName, $userId);

$this->logDone('Plugin uninstalled successfully', $pluginName, $userId);
}
Expand All @@ -141,7 +137,7 @@ private function runCommand(string $command, string $pluginName, ?int $userId =
// Use absolute path: web SAPI (php-fpm) cwd is not the app root, so plain "artisan" fails.
//TODO: Change artisan_binary() for base_path('artisan')
$artisan = base_path('artisan');
$result = Process::run(array_filter([
$result = Process::env($this->artisanEnvironment())->run(array_filter([
php_binary(),
$artisan,
$command,
Expand All @@ -161,6 +157,52 @@ private function runCommand(string $command, string $pluginName, ?int $userId =
}
}

/**
* Rebuild the tenant's route cache in a fresh artisan process.
*
* The current request already booted without the plugin's providers, so
* Artisan::call('route:cache') would persist stale routes. A subprocess
* with TENANT set loads plugins from the tenant storage path and writes
* the cache for that tenant.
*/
private function rebuildRouteCache(string $pluginName, ?int $userId = null): void
{
$this->logRunning('Rebuilding route cache...', $pluginName, $userId);
$this->runCommand('route:cache', $pluginName, $userId);
$this->reloadCachedRoutes();
}

/**
* Environment variables for artisan subprocesses started from a web request.
*/
private function artisanEnvironment(): array
{
$environment = [];
$tenant = app()->bound('currentTenant') ? app('currentTenant') : null;

if (config('app.multitenancy') && $tenant) {
$environment['TENANT'] = (string) $tenant->id;
}

return $environment;
}

/**
* Load the freshly written route cache into the current process.
*
* Needed for Octane workers that keep the router in memory after
* `route:cache` finishes in a subprocess.
*/
private function reloadCachedRoutes(): void
{
$cachedRoutesPath = app()->getCachedRoutesPath();
if (!is_file($cachedRoutesPath)) {
return;
}

require $cachedRoutesPath;
}

/**
* Install a plugin from a zip file path.
*
Expand Down Expand Up @@ -231,9 +273,7 @@ public function installFromZip(string $zipPath, ?int $userId = null): void
$this->logRunning('Running plugin install command...', $repoName, $userId);
$this->runCommand($installCommand, $repoName, $userId);

// Rebuild route cache
$this->logRunning('Rebuilding route cache...', $repoName, $userId);
$this->runCommand('route:cache', $repoName, $userId);
$this->rebuildRouteCache($repoName, $userId);

$this->logDone('Plugin installed successfully', $repoName, $userId);
} finally {
Expand Down Expand Up @@ -271,6 +311,8 @@ public function toggle(string $pluginName, ?int $userId = null): bool
throw new RuntimeException("Failed to disable plugin: {$pluginName}");
}

$this->rebuildRouteCache($pluginName, $userId);

return false;
}

Expand All @@ -283,6 +325,8 @@ public function toggle(string $pluginName, ?int $userId = null): bool
throw new RuntimeException("Failed to enable plugin: {$pluginName}");
}

$this->rebuildRouteCache($enabledName, $userId);

return true;
}

Expand Down
33 changes: 29 additions & 4 deletions ProcessMaker/Multitenancy/SwitchTenant.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ public function makeCurrent(IsTenant $tenant): void
return new TenantAwareBroadcastManager($app, $tenant->id);
});

// Setup tenant-specific route cache. This is needed for plugins.
if ($app->routesAreCached()) {
Env::getRepository()->set('APP_ROUTES_CACHE', storage_path('routes-v7.php'));
}
// Configure the tenant-specific route cache before Laravel checks whether it
// exists. This lets `TENANT=<id> php artisan route:cache` create or refresh
// the cache for a tenant after a plugin is installed, enabled, or disabled.
Env::getRepository()->set('APP_ROUTES_CACHE', storage_path('routes-v7.php'));
$this->reloadRouteCache($app);
}

/**
Expand All @@ -61,6 +62,7 @@ public function forgetCurrent(): void
{
$app = app();
$app->useStoragePath(base_path('storage'));
Env::getRepository()->clear('APP_ROUTES_CACHE');

$this->setConfig('logging.channels.daily.path', storage_path('logs/processmaker.log'));
$app->make('log')->reset();
Expand Down Expand Up @@ -90,6 +92,29 @@ private function setEnvironmentVariable($key, $value)
$_ENV[$key] = $value;
}

/**
* Reload the tenant's cached routes when the router can persist in memory.
*
* Octane keeps the router in memory between requests. The console and test
* environments may also initialize the router before a tenant is made
* current. Updating APP_ROUTES_CACHE alone is insufficient in those cases.
*/
private function reloadRouteCache(Application $app): void
{
if (!$app->routesAreCached() || !$this->shouldReinitializeRouter()) {
return;
}

require $app->getCachedRoutesPath();
}

private function shouldReinitializeRouter(): bool
{
return isset($_SERVER['LARAVEL_OCTANE'])
|| app()->runningInConsole()
|| app()->runningUnitTests();
}

private function overrideConfigs(Application $app, IsTenant $tenant)
{
$this->setEnvironmentVariable('APP_URL', $tenant->config['app.url']);
Expand Down
13 changes: 8 additions & 5 deletions resources/js/admin/plugins/Plugins.vue
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ const save = () => {
data: { ...plugin },
})
.then(() => {
ProcessMaker.alert($t("The plugin was ") + verb + ".", "success");
reloadAfterPluginChange($t("The plugin was ") + verb + ".");
})
.catch((error) => {
disabled.value = false;
Expand All @@ -471,6 +471,11 @@ const save = () => {
});
};

const reloadAfterPluginChange = (message) => {
ProcessMaker.alert(message, "success", 5, true);
window.location.reload();
};

const resetValues = () => {
title.value = $t("Create Plugin");
Object.assign(plugin, {
Expand Down Expand Up @@ -526,8 +531,7 @@ const doToggle = (item) => {
ProcessMaker.apiClient
.patch(`/plugins/${item.name}/toggle`, { baseURL: "/api/1.0/" })
.then(() => {
ProcessMaker.alert($t("The plugin was toggled."), "success");
fetch();
reloadAfterPluginChange($t("The plugin was toggled."));
});
};

Expand All @@ -542,8 +546,7 @@ const doDelete = (item) => {
ProcessMaker.apiClient
.delete(`/plugins/${item.name}`, { baseURL: "/api/1.0/" })
.then(() => {
ProcessMaker.alert($t("The plugin was deleted."), "success");
fetch();
reloadAfterPluginChange($t("The plugin was deleted."));
})
.catch((error) => {
const msg = _.get(error, "response.data.errors.delete.0");
Expand Down
56 changes: 51 additions & 5 deletions tests/Managers/PluginManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function testInstallClonesRepositoryAndValidatesPlugin()
Event::fake([PluginLog::class]);

$manager = new PluginManager();
$manager->install($repoUrl);
$manager->install($repoUrl, null, null, 1);

Event::assertDispatched(PluginLog::class, function ($event) {
return str_contains($event->message, 'installed successfully');
Expand Down Expand Up @@ -133,8 +133,12 @@ public function testUninstallRemovesPluginDirectory()

Event::fake([PluginLog::class]);

Process::fake([
'*' => Process::result('', exitCode: 0),
]);

$manager = new PluginManager();
$manager->uninstall($pluginName);
$manager->uninstall($pluginName, 1);

$this->assertFalse(is_dir($pluginPath));
Event::assertDispatched(PluginLog::class, function ($event) {
Expand Down Expand Up @@ -210,7 +214,7 @@ public function testListReturnsInstalledPlugins()
$this->assertEquals('Second test plugin', $plugins[1]['description']);
}

public function testListIgnoresPluginsStartingWithUnderscore()
public function testListIncludesDisabledPlugins()
{
$this->setUpTestPluginManager();

Expand Down Expand Up @@ -244,9 +248,51 @@ public function testListIgnoresPluginsStartingWithUnderscore()

$manager = new PluginManager();
$plugins = $manager->list();
$pluginsByName = collect($plugins)->keyBy('name');

$this->assertCount(2, $plugins);
$this->assertEquals('Enabled', $pluginsByName['valid-plugin']['enabled']);
$this->assertEquals('Disabled', $pluginsByName['hidden-plugin']['enabled']);
}

public function testArtisanEnvironmentIncludesCurrentTenant()
{
config(['app.multitenancy' => true]);
$this->app->instance('currentTenant', (object) ['id' => 7]);

$method = new \ReflectionMethod(PluginManager::class, 'artisanEnvironment');
$environment = $method->invoke(new PluginManager());

$this->assertSame(['TENANT' => '7'], $environment);
}

public function testToggleRebuildsRouteCache()
{
$this->setUpTestPluginManager();

$pluginName = 'toggle-plugin';
$pluginPath = $this->pluginsDir . '/' . $pluginName;
$this->tempPluginDir = $this->pluginsDir . '/_' . $pluginName;
mkdir($pluginPath, 0755, true);

Process::fake([
'*' => Process::result('', exitCode: 0),
]);

$manager = new PluginManager();
$enabled = $manager->toggle($pluginName);

$this->assertCount(1, $plugins);
$this->assertEquals('valid-plugin', $plugins[0]['name']);
$this->assertFalse($enabled);
$this->assertFalse(is_dir($pluginPath));
$this->assertTrue(is_dir($this->pluginsDir . '/_' . $pluginName));

Process::assertRan(function ($process) {
$command = is_array($process->command)
? implode(' ', $process->command)
: (string) $process->command;

return str_contains($command, 'route:cache');
});
}

private function deleteDirectory($dir)
Expand Down
Loading