Skip to content

Commit 0ac5c82

Browse files
authored
Merge pull request #8993 from ProcessMaker/FOUR-32722
FOUR-32722 Some adjustments in plugins installation
2 parents 9f07823 + 34ae7ca commit 0ac5c82

4 files changed

Lines changed: 142 additions & 24 deletions

File tree

‎ProcessMaker/Managers/PluginManager.php‎

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,7 @@ public function install(string $repoUrl, ?string $branch = null, ?string $tag =
9292
$this->logRunning('Running plugin install command...', $repoName, $userId);
9393
$this->runCommand($installCommand, $repoName, $userId);
9494

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

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

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

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

160+
/**
161+
* Rebuild the tenant's route cache in a fresh artisan process.
162+
*
163+
* The current request already booted without the plugin's providers, so
164+
* Artisan::call('route:cache') would persist stale routes. A subprocess
165+
* with TENANT set loads plugins from the tenant storage path and writes
166+
* the cache for that tenant.
167+
*/
168+
private function rebuildRouteCache(string $pluginName, ?int $userId = null): void
169+
{
170+
$this->logRunning('Rebuilding route cache...', $pluginName, $userId);
171+
$this->runCommand('route:cache', $pluginName, $userId);
172+
$this->reloadCachedRoutes();
173+
}
174+
175+
/**
176+
* Environment variables for artisan subprocesses started from a web request.
177+
*/
178+
private function artisanEnvironment(): array
179+
{
180+
$environment = [];
181+
$tenant = app()->bound('currentTenant') ? app('currentTenant') : null;
182+
183+
if (config('app.multitenancy') && $tenant) {
184+
$environment['TENANT'] = (string) $tenant->id;
185+
}
186+
187+
return $environment;
188+
}
189+
190+
/**
191+
* Load the freshly written route cache into the current process.
192+
*
193+
* Needed for Octane workers that keep the router in memory after
194+
* `route:cache` finishes in a subprocess.
195+
*/
196+
private function reloadCachedRoutes(): void
197+
{
198+
$cachedRoutesPath = app()->getCachedRoutesPath();
199+
if (!is_file($cachedRoutesPath)) {
200+
return;
201+
}
202+
203+
require $cachedRoutesPath;
204+
}
205+
164206
/**
165207
* Install a plugin from a zip file path.
166208
*
@@ -231,9 +273,7 @@ public function installFromZip(string $zipPath, ?int $userId = null): void
231273
$this->logRunning('Running plugin install command...', $repoName, $userId);
232274
$this->runCommand($installCommand, $repoName, $userId);
233275

234-
// Rebuild route cache
235-
$this->logRunning('Rebuilding route cache...', $repoName, $userId);
236-
$this->runCommand('route:cache', $repoName, $userId);
276+
$this->rebuildRouteCache($repoName, $userId);
237277

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

314+
$this->rebuildRouteCache($pluginName, $userId);
315+
274316
return false;
275317
}
276318

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

328+
$this->rebuildRouteCache($enabledName, $userId);
329+
286330
return true;
287331
}
288332

‎ProcessMaker/Multitenancy/SwitchTenant.php‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ public function makeCurrent(IsTenant $tenant): void
4646
return new TenantAwareBroadcastManager($app, $tenant->id);
4747
});
4848

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

5556
/**
@@ -61,6 +62,7 @@ public function forgetCurrent(): void
6162
{
6263
$app = app();
6364
$app->useStoragePath(base_path('storage'));
65+
Env::getRepository()->clear('APP_ROUTES_CACHE');
6466

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

95+
/**
96+
* Reload the tenant's cached routes when the router can persist in memory.
97+
*
98+
* Octane keeps the router in memory between requests. The console and test
99+
* environments may also initialize the router before a tenant is made
100+
* current. Updating APP_ROUTES_CACHE alone is insufficient in those cases.
101+
*/
102+
private function reloadRouteCache(Application $app): void
103+
{
104+
if (!$app->routesAreCached() || !$this->shouldReinitializeRouter()) {
105+
return;
106+
}
107+
108+
require $app->getCachedRoutesPath();
109+
}
110+
111+
private function shouldReinitializeRouter(): bool
112+
{
113+
return isset($_SERVER['LARAVEL_OCTANE'])
114+
|| app()->runningInConsole()
115+
|| app()->runningUnitTests();
116+
}
117+
93118
private function overrideConfigs(Application $app, IsTenant $tenant)
94119
{
95120
$this->setEnvironmentVariable('APP_URL', $tenant->config['app.url']);

‎resources/js/admin/plugins/Plugins.vue‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ const save = () => {
459459
data: { ...plugin },
460460
})
461461
.then(() => {
462-
ProcessMaker.alert($t("The plugin was ") + verb + ".", "success");
462+
reloadAfterPluginChange($t("The plugin was ") + verb + ".");
463463
})
464464
.catch((error) => {
465465
disabled.value = false;
@@ -471,6 +471,11 @@ const save = () => {
471471
});
472472
};
473473
474+
const reloadAfterPluginChange = (message) => {
475+
ProcessMaker.alert(message, "success", 5, true);
476+
window.location.reload();
477+
};
478+
474479
const resetValues = () => {
475480
title.value = $t("Create Plugin");
476481
Object.assign(plugin, {
@@ -526,8 +531,7 @@ const doToggle = (item) => {
526531
ProcessMaker.apiClient
527532
.patch(`/plugins/${item.name}/toggle`, { baseURL: "/api/1.0/" })
528533
.then(() => {
529-
ProcessMaker.alert($t("The plugin was toggled."), "success");
530-
fetch();
534+
reloadAfterPluginChange($t("The plugin was toggled."));
531535
});
532536
};
533537
@@ -542,8 +546,7 @@ const doDelete = (item) => {
542546
ProcessMaker.apiClient
543547
.delete(`/plugins/${item.name}`, { baseURL: "/api/1.0/" })
544548
.then(() => {
545-
ProcessMaker.alert($t("The plugin was deleted."), "success");
546-
fetch();
549+
reloadAfterPluginChange($t("The plugin was deleted."));
547550
})
548551
.catch((error) => {
549552
const msg = _.get(error, "response.data.errors.delete.0");

‎tests/Managers/PluginManagerTest.php‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public function testInstallClonesRepositoryAndValidatesPlugin()
6969
Event::fake([PluginLog::class]);
7070

7171
$manager = new PluginManager();
72-
$manager->install($repoUrl);
72+
$manager->install($repoUrl, null, null, 1);
7373

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

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

136+
Process::fake([
137+
'*' => Process::result('', exitCode: 0),
138+
]);
139+
136140
$manager = new PluginManager();
137-
$manager->uninstall($pluginName);
141+
$manager->uninstall($pluginName, 1);
138142

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

213-
public function testListIgnoresPluginsStartingWithUnderscore()
217+
public function testListIncludesDisabledPlugins()
214218
{
215219
$this->setUpTestPluginManager();
216220

@@ -244,9 +248,51 @@ public function testListIgnoresPluginsStartingWithUnderscore()
244248

245249
$manager = new PluginManager();
246250
$plugins = $manager->list();
251+
$pluginsByName = collect($plugins)->keyBy('name');
252+
253+
$this->assertCount(2, $plugins);
254+
$this->assertEquals('Enabled', $pluginsByName['valid-plugin']['enabled']);
255+
$this->assertEquals('Disabled', $pluginsByName['hidden-plugin']['enabled']);
256+
}
257+
258+
public function testArtisanEnvironmentIncludesCurrentTenant()
259+
{
260+
config(['app.multitenancy' => true]);
261+
$this->app->instance('currentTenant', (object) ['id' => 7]);
262+
263+
$method = new \ReflectionMethod(PluginManager::class, 'artisanEnvironment');
264+
$environment = $method->invoke(new PluginManager());
265+
266+
$this->assertSame(['TENANT' => '7'], $environment);
267+
}
268+
269+
public function testToggleRebuildsRouteCache()
270+
{
271+
$this->setUpTestPluginManager();
272+
273+
$pluginName = 'toggle-plugin';
274+
$pluginPath = $this->pluginsDir . '/' . $pluginName;
275+
$this->tempPluginDir = $this->pluginsDir . '/_' . $pluginName;
276+
mkdir($pluginPath, 0755, true);
277+
278+
Process::fake([
279+
'*' => Process::result('', exitCode: 0),
280+
]);
281+
282+
$manager = new PluginManager();
283+
$enabled = $manager->toggle($pluginName);
247284

248-
$this->assertCount(1, $plugins);
249-
$this->assertEquals('valid-plugin', $plugins[0]['name']);
285+
$this->assertFalse($enabled);
286+
$this->assertFalse(is_dir($pluginPath));
287+
$this->assertTrue(is_dir($this->pluginsDir . '/_' . $pluginName));
288+
289+
Process::assertRan(function ($process) {
290+
$command = is_array($process->command)
291+
? implode(' ', $process->command)
292+
: (string) $process->command;
293+
294+
return str_contains($command, 'route:cache');
295+
});
250296
}
251297

252298
private function deleteDirectory($dir)

0 commit comments

Comments
 (0)