diff --git a/.env.example b/.env.example index 1a6fee08bd..c3a094135c 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,21 @@ CUSTOM_EXECUTORS=false CACHE_SETTING_DRIVER=cache_settings CACHE_SETTING_PREFIX=settings: AI_ENABLE_RAG_COLLECTIONS=false + +SCRIPT_MICROSERVICE_ENABLED=true +SCRIPT_MICROSERVICE_BASE_URL=https://localhost:8010 +SCRIPT_MICROSERVICE_CALLBACK="${APP_URL}/api/1.0/scripts/microservice/execution" +SCRIPT_MICROSERVICE_VERSION= + +SCRIPT_MICROSERVICE_PUSHER_APP_ID= +SCRIPT_MICROSERVICE_PUSHER_APP_KEY= +SCRIPT_MICROSERVICE_PUSHER_APP_SECRET= +SCRIPT_MICROSERVICE_PUSHER_SCHEME= +SCRIPT_MICROSERVICE_PUSHER_HOST= +SCRIPT_MICROSERVICE_PUSHER_PORT= + +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_BASE_URL= +KEYCLOAK_USERNAME= +KEYCLOAK_PASSWORD= diff --git a/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php index 76f2450ec2..230458ad89 100644 --- a/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Admin/ScriptExecutorController.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use ProcessMaker\Http\Controllers\Controller; +use ProcessMaker\Services\ScriptMicroserviceService; class ScriptExecutorController extends Controller { @@ -13,6 +14,9 @@ public function index(Request $request) abort(404); } - return view('admin.script-executors.index'); + return view('admin.script-executors.index', + [ + 'script_microservice_enabled' => config('script-runner-microservice.enabled'), + ]); } } diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index 2564272691..7627c9d7a4 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Events\ScriptExecutorCreated; use ProcessMaker\Events\ScriptExecutorDeleted; use ProcessMaker\Events\ScriptExecutorUpdated; @@ -15,6 +16,7 @@ use ProcessMaker\Jobs\BuildScriptExecutor; use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptExecutor; +use ProcessMaker\Services\ScriptMicroserviceService; class ScriptExecutorController extends Controller { @@ -26,7 +28,7 @@ class ScriptExecutorController extends Controller * @return ResponseFactory|Response * * - * @OA\Get( + * @OA\Get( * path="/script-executors", * summary="Returns all script executors that the user has access to", * operationId="getScriptExecutors", @@ -79,7 +81,7 @@ public function index(Request $request) * @return ResponseFactory|Response * * - * @OA\Post( + * @OA\Post( * path="/script-executors", * summary="Create a script executor", * operationId="createScriptExecutor", @@ -109,9 +111,8 @@ public function index(Request $request) * ), * ) */ - public function store(Request $request) + public function store(Request $request, ScriptMicroserviceService $service) { - $request->request->add(['type' => 'custom']); $this->checkAuth($request); $request->validate(ScriptExecutor::rules()); @@ -119,11 +120,14 @@ public function store(Request $request) $request->only((new ScriptExecutor())->getFillable()) ); - ScriptExecutorCreated::dispatch($scriptExecutor->getAttributes()); - - BuildScriptExecutor::dispatch($scriptExecutor->id, $request->user()->id); + if (!config('script-runner-microservice.enabled')) { + ScriptExecutorCreated::dispatch($scriptExecutor->getAttributes()); + BuildScriptExecutor::dispatch($scriptExecutor->id, $request->user()->id); + } else { + $service->createCustomExecutor($scriptExecutor); + } - return ['status'=>'started', 'id' => $scriptExecutor->id]; + return ['status' => 'started', 'uuid' => $scriptExecutor->uuid, 'id' => $scriptExecutor->id]; } /** @@ -135,7 +139,7 @@ public function store(Request $request) * @return ResponseFactory|Response * * - * @OA\Put( + * @OA\Put( * path="/script-executors/{script_executor}", * summary="Update script executor", * operationId="updateScriptExecutor", @@ -173,7 +177,7 @@ public function store(Request $request) * ) * ) */ - public function update(Request $request, ScriptExecutor $scriptExecutor) + public function update(Request $request, ScriptExecutor $scriptExecutor, ScriptMicroserviceService $service) { $this->checkAuth($request); $request->validate(ScriptExecutor::rules()); @@ -184,13 +188,16 @@ public function update(Request $request, ScriptExecutor $scriptExecutor) $request->only($scriptExecutor->getFillable()) ); - if (!empty($scriptExecutor->getChanges())) { - ScriptExecutorUpdated::dispatch($scriptExecutor->id, $original, $scriptExecutor->getChanges()); + if (config('script-runner-microservice.enabled') && $scriptExecutor->type == ScriptExecutorType::Custom) { + $service->updateCustomExecutor($scriptExecutor); + } else { + if (!empty($scriptExecutor->getChanges())) { + ScriptExecutorUpdated::dispatch($scriptExecutor->id, $original, $scriptExecutor->getChanges()); + } + BuildScriptExecutor::dispatch($scriptExecutor->id, $request->user()->id); } - BuildScriptExecutor::dispatch($scriptExecutor->id, $request->user()->id); - - return ['status'=>'started']; + return ['status' => 'started', 'uuid' => $scriptExecutor->uuid]; } /** @@ -202,7 +209,7 @@ public function update(Request $request, ScriptExecutor $scriptExecutor) * @return ResponseFactory|Response * * - * @OA\Delete( + * @OA\Delete( * path="/script-executors/{script_executor}", * summary="Delete a script executor", * operationId="deleteScriptExecutor", @@ -233,7 +240,7 @@ public function update(Request $request, ScriptExecutor $scriptExecutor) * ), * ) */ - public function delete(Request $request, ScriptExecutor $scriptExecutor) + public function delete(Request $request, ScriptExecutor $scriptExecutor, ScriptMicroserviceService $service) { if ($scriptExecutor->scripts()->count() > 0) { throw ValidationException::withMessages(['delete' => __('Can not delete executor when it is assigned to scripts.')]); @@ -254,9 +261,15 @@ public function delete(Request $request, ScriptExecutor $scriptExecutor) } } + $scriptExecutorUUID = $scriptExecutor->uuid; + ScriptExecutor::destroy($scriptExecutor->id); - ScriptExecutorDeleted::dispatch($scriptExecutor->getAttributes()); + if (!config('script-runner-microservice.enabled')) { + ScriptExecutorDeleted::dispatch($scriptExecutor->getAttributes()); + } else { + $service->deleteCustomExecutor($scriptExecutorUUID); + } return ['status' => 'done']; } @@ -280,7 +293,7 @@ private function checkAuth($request) * @return ResponseFactory|Response * * - * @OA\Post( + * @OA\Post( * path="/script-executors/cancel", * summary="Cancel a script executor", * operationId="cancelScriptExecutor", @@ -327,7 +340,7 @@ public function cancel(Request $request) * @return ResponseFactory|Response * * - * @OA\Get( + * @OA\Get( * path="/script-executors/available-languages", * summary="Returns all available languages", * operationId="getAvailableLanguages", @@ -360,7 +373,8 @@ public function availableLanguages() { $languages = []; foreach (Script::scriptFormats() as $key => $config) { - if (in_array($key, Script::deprecatedLanguages)) { + // ToDo remove $key === 'php-nayra' validation when php-nayra include in deprecatedLanguages + if (in_array($key, Script::deprecatedLanguages) || $key === 'php-nayra') { continue; } if (!array_key_exists('system', $config) || (array_key_exists('system', $config) && !$config['system'])) { diff --git a/ProcessMaker/Jobs/TestScript.php b/ProcessMaker/Jobs/TestScript.php index 4b70e1ac14..4a5050d2d0 100644 --- a/ProcessMaker/Jobs/TestScript.php +++ b/ProcessMaker/Jobs/TestScript.php @@ -68,8 +68,7 @@ public function handle() $response = $this->script->runScript($this->data, $this->configuration, '', null, 0, $metadata); \Log::debug('Response from runScript: ' . print_r($response, true)); - if (!config('script-runner-microservice.enabled') || - $this->script->scriptExecutor && $this->script->scriptExecutor->type === ScriptExecutorType::Custom) { + if (!config('script-runner-microservice.enabled')) { $this->sendResponse(200, $response); } } catch (Throwable $exception) { diff --git a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php index 7e243c104e..c61037b9e4 100644 --- a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php +++ b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php @@ -4,14 +4,15 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Exception\ConfigurationException; use ProcessMaker\GenerateAccessToken; use ProcessMaker\Jobs\ErrorHandling; use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Script; use ProcessMaker\Models\User; +use ProcessMaker\Services\ScriptMicroserviceService; use stdClass; class ScriptMicroserviceRunner @@ -20,42 +21,12 @@ class ScriptMicroserviceRunner private string $language; + private ScriptMicroserviceService $service; + public function __construct(protected Script $script) { $this->language = strtolower($script->language ?? $script->scriptExecutor->language); - } - - public function getAccessToken() - { - if (Cache::has('keycloak.access_token')) { - return Cache::get('keycloak.access_token'); - } - - $response = Http::asForm()->post(config('script-runner-microservice.keycloak.base_url') ?? '', [ - 'grant_type' => 'password', - 'client_id' => config('script-runner-microservice.keycloak.client_id'), - 'client_secret' => config('script-runner-microservice.keycloak.client_secret'), - 'username' => config('script-runner-microservice.keycloak.username'), - 'password' => config('script-runner-microservice.keycloak.password'), - ]); - - if ($response->successful()) { - Cache::put('keycloak.access_token', $response->json()['access_token'], $response->json()['expires_in'] - 60); - } - - return Cache::get('keycloak.access_token'); - } - - public function getScriptRunner() - { - $response = Cache::remember('script-runner-microservice.script-languages', now()->addDay(), function () { - return Http::withToken($this->getAccessToken()) - ->get(config('script-runner-microservice.base_url') . '/scripts')->collect(); - }); - - return $response->filter(function ($item) { - return $item['language'] == $this->language; - })->first(); + $this->service = new ScriptMicroserviceService(); } public function run($code, array $data, array $config, $timeout, $user, $sync, $metadata) @@ -63,8 +34,13 @@ public function run($code, array $data, array $config, $timeout, $user, $sync, $ Log::debug('Language: ' . $this->language); Log::debug('Sync: ' . $sync); Log::debug('Metadata: ' . print_r($metadata, true)); + Log::debug('Script type: ' . $this->script->scriptExecutor->type?->value); - $scriptRunner = $this->getScriptRunner(); + $scriptRunner = $this->service->getScriptRunner( + $this->language, + $this->script->scriptExecutor->uuid, + $this->script->scriptExecutor->type === ScriptExecutorType::Custom + ); if (!$scriptRunner) { throw new ConfigurationException('No exists script executor for this language: ' . $this->language); @@ -75,7 +51,7 @@ public function run($code, array $data, array $config, $timeout, $user, $sync, $ $payload = [ 'version' => config('script-runner-microservice.version') ?? $this->getProcessMakerVersion(), 'language' => $scriptRunner['language'], - 'metadata'=> $metadata, + 'metadata' => $metadata, 'data' => !empty($data) ? $this->sanitizeCss($data) : new stdClass(), 'config' => !empty($config) ? $config : new stdClass(), 'script' => base64_encode(str_replace("'", ''', $code)), @@ -90,14 +66,7 @@ public function run($code, array $data, array $config, $timeout, $user, $sync, $ Log::debug('Payload: ' . print_r($payload, true)); - // Set a theoretical maximum timeout of 1 day (86400 seconds) - // since the laravel client must have a timeout set. - // The actual script timeout will be handled by the microservice. - $clientTimeout = 86400; - - $response = Http::timeout($clientTimeout) - ->withToken($this->getAccessToken()) - ->post(config('script-runner-microservice.base_url') . '/requests/create', $payload); + $response = $this->service->sendScriptPayload($payload); $response->throw(); @@ -161,7 +130,10 @@ public function getMetadata($user) { return [ 'script_id' => $this->script->id, + 'executor_uuid' => $this->script->scriptExecutor->uuid, + 'executor_type' => $this->script->scriptExecutor->type?->value, 'instance' => config('app.url'), + 'instance_uuid' => $this->service->getInstanceUuid(), 'user_id' => $user->id, 'user_email' => $user->email, ]; diff --git a/ProcessMaker/ScriptRunners/ScriptRunner.php b/ProcessMaker/ScriptRunners/ScriptRunner.php index e40795192c..1516b3d5eb 100644 --- a/ProcessMaker/ScriptRunners/ScriptRunner.php +++ b/ProcessMaker/ScriptRunners/ScriptRunner.php @@ -50,8 +50,8 @@ public function run($code, array $data, array $config, $timeout, $user, $sync, $ */ private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserviceRunner|MockRunner { - if (!config('script-runner-microservice.enabled') || $executor->type === ScriptExecutorType::Custom) { - $language = strtolower($executor->language); + $language = strtolower($executor->language); + if (!config('script-runner-microservice.enabled') || $language === 'php-nayra') { $runner = config("script-runners.{$language}.runner"); if (!$runner) { throw new ScriptLanguageNotSupported($language); diff --git a/ProcessMaker/Services/ScriptMicroserviceService.php b/ProcessMaker/Services/ScriptMicroserviceService.php index 8401388af6..b3a8a114ce 100644 --- a/ProcessMaker/Services/ScriptMicroserviceService.php +++ b/ProcessMaker/Services/ScriptMicroserviceService.php @@ -2,19 +2,236 @@ namespace ProcessMaker\Services; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use ProcessMaker\Events\ScriptResponseEvent; +use ProcessMaker\Exception\ScriptException; use ProcessMaker\Jobs\CompleteActivity; use ProcessMaker\Models\Process as Definitions; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\Script; +use ProcessMaker\Models\ScriptExecutor; use ProcessMaker\Models\User; +use Ramsey\Uuid\Uuid; class ScriptMicroserviceService { - public function handle(Request $request) + private $client; + + private $tenantChecked = false; + + private function client() + { + if (!$this->client) { + $this->client = Http::withOptions([ + 'verify' => !App::environment('local'), + ])->baseUrl(config('script-runner-microservice.base_url')) + ->withToken($this->getAccessToken()) + ->accept('application/json') + ->contentType('application/json') + ->throw(); + } + + return $this->client; + } + + private function checkTenant(): void + { + if ($this->tenantChecked) { + return; + } + + $instanceUuid = $this->getInstanceUuid(); + $url = '/tenants/' . $instanceUuid; + $client = $this->client(); + + try { + $response = $client->get($url); + // Tenant exists, we're good + } catch (RequestException $e) { + // If we get a 404, create the tenant + if ($e->response && $e->response->status() === 404) { + Log::debug('Tenant not found, creating.', ['instanceUuid' => $instanceUuid]); + $client->post('/tenants', [ + 'name' => config('app.instance'), + 'id' => $instanceUuid, + ]); + } else { + // Re-throw if it's not a 404 + Log::error('Error checking tenant', ['error' => $e->getMessage()]); + throw $e; + } + } + + $this->tenantChecked = true; + } + + /** + * @throws RequestException + * @throws ConnectionException + */ + public function createCustomExecutor(ScriptExecutor $scriptExecutor) + { + $url = '/custom/' . $this->getInstanceUuid() . '/scripts'; + Log::debug('Creating custom script executor.', ['url' => $url]); + $payload = [ + 'id' => $scriptExecutor->uuid, + 'name' => $scriptExecutor->title, + 'description' => $scriptExecutor->description, + 'language' => strtolower($scriptExecutor->language), + 'version' => config('script-runner-microservice.version'), + 'config' => $scriptExecutor->config, + ]; + Log::debug('Payload: ', $payload); + + $this->checkTenant(); + $response = $this->client()->post($url, $payload); + + $jsonResponse = $response->json(); + Log::debug('Response', ['response' => $jsonResponse]); + + return $jsonResponse; + } + + /** + * @throws RequestException + * @throws ConnectionException + */ + public function updateCustomExecutor(ScriptExecutor $scriptExecutor) + { + $this->checkTenant(); + $url = '/custom/scripts/' . $scriptExecutor->uuid; + Log::debug('Updating custom script executor.', ['url' => $url]); + $payload = [ + 'name' => $scriptExecutor->title, + 'description' => $scriptExecutor->description, + 'language' => strtolower($scriptExecutor->language), + 'version' => config('script-runner-microservice.version'), + 'config' => $scriptExecutor->config, + ]; + Log::debug('Payload: ', $payload); + + try { + $response = $this->client() + ->put($url, $payload); + + $jsonResponse = $response->json(); + Log::debug('Response', ['response' => $jsonResponse]); + + return $jsonResponse; + } catch (RequestException $e) { + // If we get a 404, create the executor instead + if ($e->response && $e->response->status() === 404) { + Log::debug('Executor not found (404), creating instead...'); + + return $this->createCustomExecutor($scriptExecutor); + } else { + // Re-throw if it's not a 404 + throw $e; + } + } + } + + /** + * @throws ConnectionException + */ + public function deleteCustomExecutor($scriptExecutorUUID) + { + $url = '/custom/scripts/' . $scriptExecutorUUID; + Log::debug('Deleting custom script executor.', ['url' => $url]); + + $response = $this->client()->delete($url); + + $jsonResponse = $response->json(); + Log::debug('Response', ['response' => $jsonResponse]); + + return $jsonResponse; + } + + /** + * @throws ConnectionException + */ + public function getAccessToken(): string + { + if (Cache::has('keycloak.access_token')) { + return Cache::get('keycloak.access_token'); + } + + $response = Http::asForm()->post(config('script-runner-microservice.keycloak.base_url') ?? '', [ + 'grant_type' => 'password', + 'client_id' => config('script-runner-microservice.keycloak.client_id'), + 'client_secret' => config('script-runner-microservice.keycloak.client_secret'), + 'username' => config('script-runner-microservice.keycloak.username'), + 'password' => config('script-runner-microservice.keycloak.password'), + ]); + + $responseJson = $response->json(); + + if ($response->successful() && isset($responseJson['access_token'])) { + // store with a small buffer before expiration + Cache::put('keycloak.access_token', $responseJson['access_token'], max(60, ($responseJson['expires_in'] ?? 1800) - 60)); + + return $responseJson['access_token']; + } + + Log::error('Failed to obtain access token', ['status' => $response->status(), 'body' => $responseJson]); + throw new \RuntimeException('Unable to obtain access token from Keycloak'); + } + + /** + * @throws ConnectionException + */ + public function getScriptRunner(string $language, string $executorUuid, bool $custom = false): array|null + { + $cacheKey = $custom + ? ('script-runner-microservice.custom-script-runner.' . $executorUuid) + : ('script-runner-microservice.script-runner.' . $language); + + if (Cache::has($cacheKey)) { + Log::debug('Cache hit for script runner', ['cacheKey' => $cacheKey]); + return Cache::get($cacheKey); + } + + $uri = !$custom ? '/scripts' : '/custom/' . $this->getInstanceUuid() . '/scripts'; + + $result = $this->client() + ->get($uri) + ->collect() + ->filter(function ($item) use ($language, $executorUuid, $custom) { + if (!$custom) { + return isset($item['language']) && $item['language'] === $language; + } + + return isset($item['language'], $item['id']) && $item['language'] === $language && $item['id'] === $executorUuid; + })->first(); + + if (!empty($result)) Cache::put($cacheKey, $result, now()->addHour()); + + return $result; + } + + /** + * @throws ConnectionException + */ + public function sendScriptPayload($payload) + { + $uri = '/requests/create'; + // Set a theoretical maximum timeout of 1 day (86400 seconds) + // since the laravel client must have a timeout set. + // The actual script timeout will be handled by the microservice. + $clientTimeout = 86400; + + return $this->client()->timeout($clientTimeout) + ->post($uri, $payload); + } + + public function handle(Request $request): void { $response = $request->all(); Log::debug('Response microservice executor: ' . print_r($response, true)); @@ -64,40 +281,24 @@ private function formatPreviewResponse(array $response): array */ private function formatPreviewOutput(array $response): array { - // For successful responses, return just the output array + $output = $response; if (($response['status'] ?? '') === 'success') { - return [ - 'output' => $response['output'], + $output = ['output' => $response['output']]; + } elseif ($response['status'] === 'error') { + $output = [ + 'exception' => $response['exception'] ?? ScriptException::class, + 'message' => $response['error'], ]; } - // For error responses, include error details - $output = $response; - - if (($response['status'] ?? '') === 'error') { - $output['exception'] = $this->extractErrorDetails($response); - $output['status'] = 'error'; - } - return $output; } - /** - * Extract error details from response - * - * @param array $response - * @return string - */ - private function extractErrorDetails(array $response): string + public function getInstanceUuid(): string { - if (isset($response['output']['error'])) { - return $response['output']['error']; - } - - if (isset($response['message'])) { - return $response['message']; - } - - return 'Unknown error occurred'; + return Uuid::uuid5( + Uuid::fromString('817d1d4c-e05c-4244-bf36-445e117d431a'), + config('app.url') + )->toString(); } } diff --git a/config/script-runner-microservice.php b/config/script-runner-microservice.php index 570a73ace2..7391a541f2 100644 --- a/config/script-runner-microservice.php +++ b/config/script-runner-microservice.php @@ -13,4 +13,13 @@ 'username' => env('KEYCLOAK_USERNAME'), 'password' => env('KEYCLOAK_PASSWORD'), ], + 'broadcasting' => [ + 'app_id' => env('SCRIPT_MICROSERVICE_PUSHER_APP_ID'), + 'app_key' => env('SCRIPT_MICROSERVICE_PUSHER_APP_KEY'), + 'app_secret' => env('SCRIPT_MICROSERVICE_PUSHER_APP_SECRET'), + 'cluster' => env('SCRIPT_MICROSERVICE_PUSHER_CLUSTER', 'mt1'), + 'scheme' => env('SCRIPT_MICROSERVICE_PUSHER_SCHEME', 'https'), + 'host' => env('SCRIPT_MICROSERVICE_PUSHER_HOST'), + 'port' => env('SCRIPT_MICROSERVICE_PUSHER_PORT', 6001), + ], ]; diff --git a/resources/js/admin/script-executors/ScriptExecutors.vue b/resources/js/admin/script-executors/ScriptExecutors.vue index 3df2366df0..63970dc86c 100644 --- a/resources/js/admin/script-executors/ScriptExecutors.vue +++ b/resources/js/admin/script-executors/ScriptExecutors.vue @@ -31,6 +31,9 @@ +