diff --git a/ProcessMaker/Console/Commands/TransitionExecutors.php b/ProcessMaker/Console/Commands/TransitionExecutors.php new file mode 100644 index 0000000000..e74572c3ff --- /dev/null +++ b/ProcessMaker/Console/Commands/TransitionExecutors.php @@ -0,0 +1,172 @@ +resolveExecutors($this->argument('uuid')); + + if ($executors === null) { + return 1; + } + + if ($executors->isEmpty()) { + $this->warn('No script executors found to transition.'); + + return 0; + } + + $isAll = $this->argument('uuid') === 'all'; + $remaining = $executors->count(); + $processed = 0; + + foreach ($executors as $executor) { + $remaining--; + $this->info("Transitioning executor {$executor->uuid} ({$executor->language}) to the microservice..."); + + try { + $response = $this->scriptMicroserviceService->updateCustomExecutor($executor); + Log::debug('Response', ['response' => $response]); + $status = strtolower((string) ($response['status'] ?? '')); + + if (in_array($status, ['error', 'failed', 'failure'], true) && !isset($response['executor_id'])) { + throw new \RuntimeException(json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: 'Transition failed'); + } + } catch (RequestException $e) { + $this->error("Transition failed for executor {$executor->uuid}"); + $this->line($e->response?->body() ?: $e->getMessage()); + if ($isAll && $remaining > 0) { + $this->warn("Stopping: {$remaining} remaining executor(s) were not processed."); + } + + return 1; + } catch (\Throwable $e) { + $this->error("Transition failed for executor {$executor->uuid}"); + $this->line($e->getMessage()); + if ($isAll && $remaining > 0) { + $this->warn("Stopping: {$remaining} remaining executor(s) were not processed."); + } + + return 1; + } + + $this->info("Executor {$executor->uuid} transitioned successfully."); + $processed++; + } + + if ($isAll) { + $this->info("All script executors transitioned successfully. ({$processed} processed)"); + } + + return 0; + } + + /** + * Resolve executors that should be transitioned. + * + * Includes: + * - type = custom + * - type null (or unset) that are NOT the default/first executor for their language + * + * Excludes: + * - default package executors (first row per language) + * + * @return Collection|null Null when the request is invalid. + */ + private function resolveExecutors(string $uuid): ?Collection + { + if ($uuid === 'all') { + return ScriptExecutor::query() + ->orderBy('id') + ->get() + ->filter(fn (ScriptExecutor $executor) => $this->shouldTransition($executor)) + ->values(); + } + + if (!$this->isValidUuid($uuid)) { + $this->error('Invalid uuid. Provide a script executor UUID or "all".'); + + return null; + } + + $executor = ScriptExecutor::where('uuid', $uuid)->first(); + + if (!$executor) { + $this->error("Script executor [{$uuid}] not found."); + + return null; + } + + if (!$this->shouldTransition($executor)) { + $this->error("Script executor [{$uuid}] is a default/system executor and cannot be transitioned."); + + return null; + } + + return new Collection([$executor]); + } + + /** + * Whether this executor should be migrated to the microservice. + * + * Custom executors always qualify. Others qualify only when they are not + * the default (first installed) executor for their language. + */ + private function shouldTransition(ScriptExecutor $executor): bool + { + if ($executor->type === ScriptExecutorType::Custom) { + return true; + } + + $initial = ScriptExecutor::query() + ->where('language', $executor->language) + ->orderBy('created_at') + ->orderBy('id') + ->first(); + + return !$initial || (int) $initial->id !== (int) $executor->id; + } + + private function isValidUuid(string $uuid): bool + { + return (bool) preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $uuid + ); + } +} diff --git a/resources/views/admin/script-executors/index.blade.php b/resources/views/admin/script-executors/index.blade.php index 4719d353ab..cb3146fabe 100644 --- a/resources/views/admin/script-executors/index.blade.php +++ b/resources/views/admin/script-executors/index.blade.php @@ -18,7 +18,7 @@
diff --git a/tests/Feature/Console/TransitionExecutorsTest.php b/tests/Feature/Console/TransitionExecutorsTest.php new file mode 100644 index 0000000000..43edb367b1 --- /dev/null +++ b/tests/Feature/Console/TransitionExecutorsTest.php @@ -0,0 +1,291 @@ +', 0)->delete(); + ScriptExecutor::where('id', '>', 0)->delete(); + + // Command must work even when the microservice feature flag is off. + config(['script-runner-microservice.enabled' => false]); + } + + public function testInvalidUuidFails(): void + { + $this->mock(ScriptMicroserviceService::class, function ($mock) { + $mock->shouldNotReceive('updateCustomExecutor'); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => 'not-a-uuid']) + ->expectsOutput('Invalid uuid. Provide a script executor UUID or "all".') + ->assertFailed(); + } + + public function testNumericIdIsRejected(): void + { + $this->mock(ScriptMicroserviceService::class, function ($mock) { + $mock->shouldNotReceive('updateCustomExecutor'); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => '5']) + ->expectsOutput('Invalid uuid. Provide a script executor UUID or "all".') + ->assertFailed(); + } + + public function testMissingExecutorFails(): void + { + $this->mock(ScriptMicroserviceService::class, function ($mock) { + $mock->shouldNotReceive('updateCustomExecutor'); + }); + + $missingUuid = '00000000-0000-4000-8000-000000000099'; + + $this->artisan('processmaker:transition-executors', ['uuid' => $missingUuid]) + ->expectsOutput("Script executor [{$missingUuid}] not found.") + ->assertFailed(); + } + + public function testDefaultExecutorFails(): void + { + $default = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'PHP Executor', + 'type' => null, + ]); + + $this->mock(ScriptMicroserviceService::class, function ($mock) { + $mock->shouldNotReceive('updateCustomExecutor'); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => $default->uuid]) + ->expectsOutput("Script executor [{$default->uuid}] is a default/system executor and cannot be transitioned.") + ->assertFailed(); + } + + public function testAllSkipsDefaultsAndIncludesCustomAndNonDefaultNullType(): void + { + $defaultPhp = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'Default PHP', + 'type' => null, + 'created_at' => now()->subDay(), + ]); + $defaultJs = ScriptExecutor::factory()->create([ + 'language' => 'javascript', + 'title' => 'Default JS', + 'type' => null, + 'created_at' => now()->subDay(), + ]); + $custom = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'Custom PHP', + 'type' => ScriptExecutorType::Custom, + 'created_at' => now(), + ]); + $extraNullPhp = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'Extra null PHP', + 'type' => null, + 'created_at' => now()->addMinute(), + ]); + ScriptExecutor::factory()->create([ + 'language' => 'python', + 'title' => 'System Python', + 'type' => ScriptExecutorType::System, + ]); + + $this->mock(ScriptMicroserviceService::class, function ($mock) use ($custom, $extraNullPhp, $defaultPhp, $defaultJs) { + $mock->shouldReceive('updateCustomExecutor') + ->twice() + ->andReturnUsing(function (ScriptExecutor $executor) use ($custom, $extraNullPhp, $defaultPhp, $defaultJs) { + if (in_array($executor->uuid, [$defaultPhp->uuid, $defaultJs->uuid], true)) { + $this->fail('Default executors should not be transitioned'); + } + + if (!in_array($executor->uuid, [$custom->uuid, $extraNullPhp->uuid], true)) { + $this->fail('Unexpected executor uuid: ' . $executor->uuid); + } + + return ['status' => 'success']; + }); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => 'all']) + ->expectsOutput("Executor {$custom->uuid} transitioned successfully.") + ->expectsOutput("Executor {$extraNullPhp->uuid} transitioned successfully.") + ->expectsOutput('All script executors transitioned successfully. (2 processed)') + ->assertSuccessful(); + } + + public function testSingleCustomExecutorSuccessByUuid(): void + { + // Seed a default first so custom is not treated as the language default. + ScriptExecutor::factory()->create([ + 'language' => 'php', + 'type' => null, + 'created_at' => now()->subDay(), + ]); + + $executor = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'PHP Executor', + 'config' => 'RUN echo custom', + 'type' => ScriptExecutorType::Custom, + 'created_at' => now(), + ]); + + $this->mock(ScriptMicroserviceService::class, function ($mock) use ($executor) { + $mock->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andReturn(['status' => 'success']); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => $executor->uuid]) + ->expectsOutput("Transitioning executor {$executor->uuid} (php) to the microservice...") + ->expectsOutput("Executor {$executor->uuid} transitioned successfully.") + ->assertSuccessful(); + } + + public function testSingleNonDefaultNullTypeSucceeds(): void + { + ScriptExecutor::factory()->create([ + 'language' => 'php', + 'type' => null, + 'created_at' => now()->subDay(), + ]); + + $executor = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'Extra PHP', + 'type' => null, + 'created_at' => now(), + ]); + + $this->mock(ScriptMicroserviceService::class, function ($mock) use ($executor) { + $mock->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andReturn(['status' => 'success']); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => $executor->uuid]) + ->expectsOutput("Executor {$executor->uuid} transitioned successfully.") + ->assertSuccessful(); + } + + public function testAllStopsOnStatusError(): void + { + $first = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'First', + 'config' => '', + 'type' => ScriptExecutorType::Custom, + ]); + $second = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'Second', + 'config' => '', + 'type' => ScriptExecutorType::Custom, + ]); + $third = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'title' => 'Third', + 'config' => '', + 'type' => ScriptExecutorType::Custom, + ]); + + $this->mock(ScriptMicroserviceService::class, function ($mock) use ($first, $second, $third) { + $mock->shouldReceive('updateCustomExecutor') + ->times(2) + ->andReturnUsing(function (ScriptExecutor $executor) use ($first, $second, $third) { + if ($executor->uuid === $third->uuid) { + $this->fail('Third executor should not be transitioned after a failure'); + } + + if ($executor->uuid === $first->uuid) { + return ['status' => 'success']; + } + + if ($executor->uuid === $second->uuid) { + return [ + 'status' => 'error', + 'sdk_output' => 'SDK generation failed: boom', + 'docker_output' => 'Docker build failed: boom', + ]; + } + + $this->fail('Unexpected executor uuid: ' . $executor->uuid); + }); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => 'all']) + ->expectsOutput("Executor {$first->uuid} transitioned successfully.") + ->expectsOutput("Transition failed for executor {$second->uuid}") + ->expectsOutput('Stopping: 1 remaining executor(s) were not processed.') + ->assertFailed(); + } + + public function testAllStopsOnRequestException(): void + { + $first = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'config' => '', + 'type' => ScriptExecutorType::Custom, + ]); + $second = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'config' => '', + 'type' => ScriptExecutorType::Custom, + ]); + $third = ScriptExecutor::factory()->create([ + 'language' => 'php', + 'config' => '', + 'type' => ScriptExecutorType::Custom, + ]); + + $this->mock(ScriptMicroserviceService::class, function ($mock) use ($first, $second, $third) { + $mock->shouldReceive('updateCustomExecutor') + ->times(2) + ->andReturnUsing(function (ScriptExecutor $executor) use ($first, $second, $third) { + if ($executor->uuid === $third->uuid) { + $this->fail('Third executor should not be transitioned after a failure'); + } + + if ($executor->uuid === $first->uuid) { + return ['status' => 'success']; + } + + if ($executor->uuid === $second->uuid) { + $response = new Response( + new \GuzzleHttp\Psr7\Response(500, [], 'SDK build failed hard') + ); + + throw new RequestException($response); + } + + $this->fail('Unexpected executor uuid: ' . $executor->uuid); + }); + }); + + $this->artisan('processmaker:transition-executors', ['uuid' => 'all']) + ->expectsOutput("Executor {$first->uuid} transitioned successfully.") + ->expectsOutput("Transition failed for executor {$second->uuid}") + ->expectsOutput('SDK build failed hard') + ->expectsOutput('Stopping: 1 remaining executor(s) were not processed.') + ->assertFailed(); + } +}