diff --git a/ProcessMaker/Enums/ScriptExecutorType.php b/ProcessMaker/Enums/ScriptExecutorType.php new file mode 100644 index 0000000000..c59fe1e168 --- /dev/null +++ b/ProcessMaker/Enums/ScriptExecutorType.php @@ -0,0 +1,9 @@ +cacheResponse($this->response); + $response = $this->cacheResponse(); return [ 'type' => '.' . \get_class($this), diff --git a/ProcessMaker/Http/Controllers/Api/ScriptController.php b/ProcessMaker/Http/Controllers/Api/ScriptController.php index 7580b33bff..2e7fc5c5dc 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptController.php @@ -18,6 +18,7 @@ use ProcessMaker\Models\ScriptCategory; use ProcessMaker\Models\User; use ProcessMaker\Query\SyntaxError; +use ProcessMaker\Services\ScriptMicroserviceService; use ProcessMaker\Traits\ProjectAssetTrait; class ScriptController extends Controller @@ -286,6 +287,12 @@ public function execution($key) return response()->json(Cache::get("srn.{$key}")); } + public function microserviceExecution(Request $request) + { + $scriptMicroserviceService = new ScriptMicroserviceService(); + $scriptMicroserviceService->handle($request); + } + /** * Get a single script in a process. * diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index e625ac01d8..118e1301e7 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -103,6 +103,7 @@ public function index(Request $request) */ public function store(Request $request) { + $request->request->add(['type' => 'custom']); $this->checkAuth($request); $request->validate(ScriptExecutor::rules()); diff --git a/ProcessMaker/Jobs/RunScriptTask.php b/ProcessMaker/Jobs/RunScriptTask.php index 1d4c7a632c..5558c23fa5 100644 --- a/ProcessMaker/Jobs/RunScriptTask.php +++ b/ProcessMaker/Jobs/RunScriptTask.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Log; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Exception\ConfigurationException; use ProcessMaker\Exception\ScriptException; use ProcessMaker\Facades\WorkflowManager; @@ -32,9 +33,9 @@ class RunScriptTask extends BpmnAction implements ShouldQueue /** * Create a new job instance. * - * @param \ProcessMaker\Models\Process $definitions - * @param \ProcessMaker\Models\ProcessRequest $instance - * @param \ProcessMaker\Models\ProcessRequestToken $token + * @param Definitions $definitions + * @param ProcessRequest $instance + * @param ProcessRequestToken $token * @param array $data */ public function __construct(Definitions $definitions, ProcessRequest $instance, ProcessRequestToken $token, array $data, $attemptNum = 1) @@ -68,6 +69,7 @@ public function action(ProcessRequestToken $token = null, ScriptTaskInterface $e } $errorHandling = null; + $scriptExecutor = null; try { if (empty($scriptRef)) { $code = $element->getScript(); @@ -86,6 +88,7 @@ public function action(ProcessRequestToken $token = null, ScriptTaskInterface $e if (!$script) { throw new ConfigurationException(__('Script ":id" not found', ['id' => $scriptRef])); } + $scriptExecutor = $script->scriptExecutor; $script = $script->versionFor($instance); } @@ -95,9 +98,22 @@ public function action(ProcessRequestToken $token = null, ScriptTaskInterface $e $this->unlock(); $dataManager = new DataManager(); $data = $dataManager->getData($token); - $response = $script->runScript($data, $configuration, $token->getId(), $errorHandling->timeout()); - - $this->updateData($response); + $metadata = [ + 'script_task' => [ + 'script_id' => $scriptRef, + 'definition_id' => $this->definitionsId, + 'instance_id' => $this->instanceId, + 'token_id' => $this->tokenId, + 'data' => $data, + 'attempts' => $this->attemptNum, + ], + ]; + $response = $script->runScript($data, $configuration, $token->getId(), $errorHandling->timeout(), 0, $metadata); + + if (!config('script-runner-microservice.enabled') || + ($scriptExecutor && $scriptExecutor->type === ScriptExecutorType::Custom)) { + $this->updateData($response); + } } catch (ConfigurationException $exception) { $this->unlock(); $this->updateData(['output' => $exception->getMessageForData($token)]); diff --git a/ProcessMaker/Jobs/TestScript.php b/ProcessMaker/Jobs/TestScript.php index 0e9b2b3fe9..23c56de091 100644 --- a/ProcessMaker/Jobs/TestScript.php +++ b/ProcessMaker/Jobs/TestScript.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Events\ScriptResponseEvent; use ProcessMaker\Models\Script; use ProcessMaker\Models\User; @@ -60,8 +61,17 @@ public function handle() try { // Just set the code but do not save the object (preview only) $this->script->code = $this->code; - $response = $this->script->runScript($this->data, $this->configuration); - $this->sendResponse(200, $response); + $metadata = [ + 'nonce' => $this->nonce, + 'current_user' => $this->current_user?->id, + ]; + $response = $this->script->runScript($this->data, $this->configuration, '', null, 0, $metadata); + \Log::debug('Response api microservice: ' . print_r($response, true)); + + if (!config('script-runner-microservice.enabled') || + $this->script->scriptExecutor && $this->script->scriptExecutor->type === ScriptExecutorType::Custom) { + $this->sendResponse(200, $response); + } } catch (Throwable $exception) { $this->sendResponse(500, [ 'exception' => get_class($exception), diff --git a/ProcessMaker/Models/Script.php b/ProcessMaker/Models/Script.php index 8b900db30e..31c71ddaae 100644 --- a/ProcessMaker/Models/Script.php +++ b/ProcessMaker/Models/Script.php @@ -149,7 +149,7 @@ public static function rules($existing = null) * @param array $data * @param array $config */ - public function runScript(array $data, array $config, $tokenId = '', $timeout = null) + public function runScript(array $data, array $config, $tokenId = '', $timeout = null, $sync = 1, $metadata = []) { if (!$timeout) { $timeout = $this->timeout; @@ -158,14 +158,14 @@ public function runScript(array $data, array $config, $tokenId = '', $timeout = if (!$this->scriptExecutor) { throw new ScriptLanguageNotSupported($this->language); } - $runner = new ScriptRunner($this->scriptExecutor); + $runner = new ScriptRunner($this); $runner->setTokenId($tokenId); $user = User::find($this->run_as_user_id); if (!$user) { throw new ConfigurationException('A user is required to run scripts'); } - return $runner->run($this->code, $data, $config, $timeout, $user); + return $runner->run($this->code, $data, $config, $timeout, $user, $sync, $metadata); } /** diff --git a/ProcessMaker/Models/ScriptExecutor.php b/ProcessMaker/Models/ScriptExecutor.php index f5d8b34346..64ad0cee7f 100644 --- a/ProcessMaker/Models/ScriptExecutor.php +++ b/ProcessMaker/Models/ScriptExecutor.php @@ -4,6 +4,8 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Enum; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Exception\ScriptLanguageNotSupported; use ProcessMaker\Facades\Docker; use ProcessMaker\Traits\Exportable; @@ -56,7 +58,7 @@ class ScriptExecutor extends ProcessMakerModel use HideSystemResources; protected $fillable = [ - 'title', 'description', 'language', 'config', 'is_system', + 'title', 'description', 'language', 'config', 'is_system', 'type', ]; public static function install($params) @@ -146,6 +148,11 @@ public static function rules($existing = null) 'required', Rule::in(Script::scriptFormatValues()), ], + 'type' => [ + 'sometimes', + new Enum(ScriptExecutorType::class), + 'nullable', + ], ]; } diff --git a/ProcessMaker/Models/ScriptExecutorVersion.php b/ProcessMaker/Models/ScriptExecutorVersion.php index e3ae78b721..076952bb6e 100644 --- a/ProcessMaker/Models/ScriptExecutorVersion.php +++ b/ProcessMaker/Models/ScriptExecutorVersion.php @@ -7,7 +7,7 @@ class ScriptExecutorVersion extends ProcessMakerModel { protected $fillable = [ - 'title', 'description', 'language', 'config', 'draft', 'is_system', + 'title', 'description', 'language', 'config', 'draft', 'is_system', 'type', ]; /** diff --git a/ProcessMaker/Models/ScriptVersion.php b/ProcessMaker/Models/ScriptVersion.php index d1c946b24c..d6b8e9e838 100644 --- a/ProcessMaker/Models/ScriptVersion.php +++ b/ProcessMaker/Models/ScriptVersion.php @@ -50,7 +50,7 @@ public function parent() * @param array $data * @param array $config */ - public function runScript(array $data, array $config, $tokenId = '', $timeout = null) + public function runScript(array $data, array $config, $tokenId = '', $timeout = null, $sync = 1, $metadata = []) { $script = $this->parent->replicate(); $except = $script->getGuarded(); @@ -58,7 +58,7 @@ public function runScript(array $data, array $config, $tokenId = '', $timeout = $script->$prop = $this->$prop; } - return $script->runScript($data, $config, $tokenId, $timeout); + return $script->runScript($data, $config, $tokenId, $timeout, $sync, $metadata); } /** diff --git a/ProcessMaker/ScriptRunners/Base.php b/ProcessMaker/ScriptRunners/Base.php index 0db1bb0511..c609f3a0d6 100644 --- a/ProcessMaker/ScriptRunners/Base.php +++ b/ProcessMaker/ScriptRunners/Base.php @@ -65,7 +65,7 @@ public function __construct(ScriptExecutor $scriptExecutor) * @return array * @throws RuntimeException */ - public function run($code, array $data, array $config, $timeout, ?User $user) + public function run($code, array $data, array $config, $timeout, ?User $user, $sync, $metadata) { $isNayra = $this->scriptExecutor->language === self::NAYRA_LANG; diff --git a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php new file mode 100644 index 0000000000..26b3caa32f --- /dev/null +++ b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php @@ -0,0 +1,167 @@ +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(); + } + + public function run($code, array $data, array $config, $timeout, $user, $sync, $metadata) + { + Log::debug('Language: ' . $this->language); + Log::debug('Sync: ' . $sync); + Log::debug('Metadata: ' . print_r($metadata, true)); + + $scriptRunner = $this->getScriptRunner(); + + if (!$scriptRunner) { + throw new ConfigurationException('No exists script executor for this language: ' . $this->language); + } + $metadata = array_merge($this->getMetadata($user), $metadata); + $environmentVariables = $this->getEnvironmentVariables($user); + + $payload = [ + 'version' => config('script-runner-microservice.version') ?? $this->getProcessMakerVersion(), + 'language' => $scriptRunner['language'], + 'metadata'=> $metadata, + 'data' => !empty($data) ? $this->sanitizeCss($data) : new stdClass(), + 'config' => !empty($config) ? $config : new stdClass(), + 'script' => base64_encode(str_replace("'", ''', $code)), + 'secrets' => $environmentVariables, + 'callback' => config('script-runner-microservice.callback'), + 'callback_secure' => true, + 'callback_token' => $environmentVariables['API_TOKEN'], + 'debug' => true, + 'timeout' => $timeout, + 'sync' => $sync, + ]; + + Log::debug('Payload: ' . print_r($payload, true)); + + $response = Http::withToken($this->getAccessToken()) + ->post(config('script-runner-microservice.base_url') . '/requests/create', $payload); + + $response->throw(); + + return $response->json(); + } + + private function getEnvironmentVariables(User $user) + { + $variablesParameter = []; + EnvironmentVariable::chunk(50, function (Collection $variables) use (&$variablesParameter) { + foreach ($variables as $variable) { + // Fix variables that have spaces + $variablesParameter[str_replace(' ', '_', $variable->name)] = $variable->value; + } + }); + + // Add the url to the host + $variablesParameter['HOST_URL'] = config('app.docker_host_url'); + + // Create tokens for the SDK if a user is set + $token = null; + if ($user) { + $accessToken = Cache::remember('script-runner-' . $user->id, now()->addWeek(), function () use ($user) { + $user->removeOldRunScriptTokens(); + $token = new GenerateAccessToken($user); + + return $token->getToken(); + }); + $variablesParameter['API_TOKEN'] = $accessToken; + $variablesParameter['API_HOST'] = config('app.docker_host_url') . '/api/1.0'; + $variablesParameter['APP_URL'] = config('app.docker_host_url'); + $variablesParameter['API_SSL_VERIFY'] = (config('app.api_ssl_verify') ? '1' : '0'); + } + + return $variablesParameter; + } + + public function setTokenId($tokenId) + { + $this->tokenId = $tokenId; + } + + public function getProcessMakerVersion() + { + return Cache::remember('script-runner-microservice.processmaker-version', now()->addDay(), function () { + $composer_json_path = json_decode(file_get_contents(base_path() . '/composer.json')); + + return $composer_json_path->version; + }); + } + + public function getMetadata($user) + { + return [ + 'script_id' => $this->script->id, + 'instance' => config('app.url'), + 'user_id' => $user->id, + 'user_email' => $user->email, + ]; + } + + public function sanitizeCss($data) + { + if ($this->language !== 'javascript-ssr') { + return $data; + } + if (array_key_exists('css', $data)) { + $data['css'] = false; + } + + return $data; + } +} diff --git a/ProcessMaker/ScriptRunners/ScriptRunner.php b/ProcessMaker/ScriptRunners/ScriptRunner.php index 97b4ae878d..e40795192c 100644 --- a/ProcessMaker/ScriptRunners/ScriptRunner.php +++ b/ProcessMaker/ScriptRunners/ScriptRunner.php @@ -2,7 +2,10 @@ namespace ProcessMaker\ScriptRunners; +use Illuminate\Contracts\Container\BindingResolutionException; +use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Exception\ScriptLanguageNotSupported; +use ProcessMaker\Models\Script; use ProcessMaker\Models\ScriptExecutor; class ScriptRunner @@ -10,13 +13,13 @@ class ScriptRunner /** * Concrete script runner * - * @var \ProcessMaker\ScriptRunners\Base + * @var Base */ private $runner; - public function __construct(ScriptExecutor $executor) + public function __construct(protected Script $script) { - $this->runner = $this->getScriptRunner($executor); + $this->runner = $this->getScriptRunner($this->script->scriptExecutor); } /** @@ -31,9 +34,9 @@ public function __construct(ScriptExecutor $executor) * @return array * @throws \RuntimeException */ - public function run($code, array $data, array $config, $timeout, $user) + public function run($code, array $data, array $config, $timeout, $user, $sync, $metadata) { - return $this->runner->run($code, $data, $config, $timeout, $user); + return $this->runner->run($code, $data, $config, $timeout, $user, $sync, $metadata); } /** @@ -41,19 +44,24 @@ public function run($code, array $data, array $config, $timeout, $user) * * @param ScriptExecutor $executor * - * @return \ProcessMaker\ScriptRunners\Base - * @throws \ProcessMaker\Exception\ScriptLanguageNotSupported + * @return Base|ScriptMicroserviceRunner|MockRunner + * @throws ScriptLanguageNotSupported + * @throws BindingResolutionException */ - private function getScriptRunner(ScriptExecutor $executor) + private function getScriptRunner(ScriptExecutor $executor): Base|ScriptMicroserviceRunner|MockRunner { - $language = strtolower($executor->language); - $runner = config("script-runners.{$language}.runner"); - if (!$runner) { - throw new ScriptLanguageNotSupported($language); - } else { - $class = "ProcessMaker\\ScriptRunners\\{$runner}"; + if (!config('script-runner-microservice.enabled') || $executor->type === ScriptExecutorType::Custom) { + $language = strtolower($executor->language); + $runner = config("script-runners.{$language}.runner"); + if (!$runner) { + throw new ScriptLanguageNotSupported($language); + } else { + $class = "ProcessMaker\\ScriptRunners\\{$runner}"; - return app()->make($class, ['scriptExecutor' => $executor]); + return app()->make($class, ['scriptExecutor' => $executor]); + } + } else { + return new ScriptMicroserviceRunner($this->script); } } diff --git a/ProcessMaker/Services/ScriptMicroserviceService.php b/ProcessMaker/Services/ScriptMicroserviceService.php new file mode 100644 index 0000000000..0cb7328bd0 --- /dev/null +++ b/ProcessMaker/Services/ScriptMicroserviceService.php @@ -0,0 +1,45 @@ +all(); + Log::debug('Response microservice executor: ' . print_r($response, true)); + // If the call is from preview + if ($response['metadata']['nonce']) { + $status = $response['status'] === 'success' ? 200 : 500; + $output = $response['status'] === 'success' + ? ['output' => $response['output']] + : $response; + + event(new ScriptResponseEvent( + User::find($response['metadata']['current_user']), + $status, + $output, + null, + $response['metadata']['nonce'])); + } + if (!empty($response['metadata']['script_task'])) { + $script = Script::find($response['metadata']['script_task']['script_id']); + $definitions = Definitions::find($response['metadata']['script_task']['definition_id']); + $instance = ProcessRequest::find($response['metadata']['script_task']['instance_id']); + $token = ProcessRequestToken::find($response['metadata']['script_task']['token_id']); + if ($response['status'] === 'success') { + CompleteActivity::dispatch($definitions, $instance, $token, $response['output'])->onQueue('bpmn'); + } + } + } +} diff --git a/config/script-runner-microservice.php b/config/script-runner-microservice.php new file mode 100644 index 0000000000..570a73ace2 --- /dev/null +++ b/config/script-runner-microservice.php @@ -0,0 +1,16 @@ + env('SCRIPT_MICROSERVICE_ENABLED', true), + 'base_url' => env('SCRIPT_MICROSERVICE_BASE_URL'), + 'callback' => env('SCRIPT_MICROSERVICE_CALLBACK'), + 'version' => env('SCRIPT_MICROSERVICE_VERSION'), + 'keycloak' => [ + 'client_id' => env('KEYCLOAK_CLIENT_ID'), + 'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), + 'redirect' => env('KEYCLOAK_REDIRECT_URI'), + 'base_url' => env('KEYCLOAK_BASE_URL'), + 'username' => env('KEYCLOAK_USERNAME'), + 'password' => env('KEYCLOAK_PASSWORD'), + ], +]; diff --git a/database/migrations/2024_10_15_182703_add_type_column_to_script_executor_versions_table.php b/database/migrations/2024_10_15_182703_add_type_column_to_script_executor_versions_table.php new file mode 100644 index 0000000000..8654582762 --- /dev/null +++ b/database/migrations/2024_10_15_182703_add_type_column_to_script_executor_versions_table.php @@ -0,0 +1,27 @@ +string('type')->nullable()->after('config'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('script_executor_versions', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/database/migrations/2024_10_15_182703_add_type_column_to_script_executors_table.php b/database/migrations/2024_10_15_182703_add_type_column_to_script_executors_table.php new file mode 100644 index 0000000000..37ec329a7d --- /dev/null +++ b/database/migrations/2024_10_15_182703_add_type_column_to_script_executors_table.php @@ -0,0 +1,27 @@ +string('type')->nullable()->after('is_system'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('script_executors', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 231376cc93..fade58e673 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -76,5 +76,8 @@ + + + diff --git a/routes/api.php b/routes/api.php index 4bc4f33a5f..56151c5420 100644 --- a/routes/api.php +++ b/routes/api.php @@ -128,6 +128,7 @@ Route::post('scripts/{script}/preview', [ScriptController::class, 'preview'])->name('scripts.preview')->middleware('can:view-scripts,script'); Route::post('scripts/execute/{script_id}/{script_key?}', [ScriptController::class, 'execute'])->name('scripts.execute'); Route::get('scripts/execution/{key}', [ScriptController::class, 'execution'])->name('scripts.execution'); + Route::post('scripts/microservice/execution', [ScriptController::class, 'microserviceExecution']); // Script Categories Route::get('script_categories', [ScriptCategoryController::class, 'index'])->name('script_categories.index')->middleware('can:view-script-categories'); diff --git a/tests/Feature/Api/ScriptsTest.php b/tests/Feature/Api/ScriptsTest.php index e14f14a78e..bb76dad7db 100644 --- a/tests/Feature/Api/ScriptsTest.php +++ b/tests/Feature/Api/ScriptsTest.php @@ -379,16 +379,6 @@ public function testPreviewScript() ScriptResponseEvent::class, ]); - $url = route('api.scripts.preview', $this->getScript('lua')->id); - $response = $this->apiCall('POST', $url, ['data' => '{}', 'code' => 'return {response=1}']); - $response->assertStatus(200); - Event::assertDispatched(ScriptResponseEvent::class, function ($event) { - $response = $event->response; - $nonce = $event->nonce; - - return $response['output'] === ['response' => 1]; - }); - $url = route('api.scripts.preview', $this->getScript('php')->id); $response = $this->apiCall('POST', $url, [ 'data' => '{}',