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 @@
{{ props.rowData.title }}
+
+ {{ (props.rowData.type === "custom") ? "Custom" : "Base" }}
+
@@ -46,6 +49,7 @@
@@ -131,6 +137,7 @@
v-model="formData.description"
class="flex-grow-1"
name="description"
+ :disabled="formData.type !== 'custom'"
>
@@ -170,7 +177,7 @@
@@ -208,7 +215,7 @@
this.$t("Type"),
+ name: "__slot:type",
+ sortField: "type",
+ },
{
title: () => this.$t("Modified"),
name: "updated_at",
@@ -310,7 +328,7 @@ export default {
document.querySelector('meta[name="user-id"]'),
"content"
);
- if (userId) {
+ if (userId && !this.script_microservice_enabled) {
window.Echo.private(`ProcessMaker.Models.User.${userId}`).listen(
".BuildScriptExecutor",
(event) => {
@@ -343,6 +361,11 @@ export default {
commandOutput() {
this.scrollToBottom();
},
+ script_microservice_broadcast_uui(newVal) {
+ if (newVal) {
+ this.subscribeToScriptMicroserviceChannel(newVal);
+ }
+ },
},
computed: {
modalTitle() {
@@ -455,6 +478,7 @@ export default {
.put(path, this.formData)
.then((result) => {
this.status = _.get(result, "data.status", "error");
+ this.script_microservice_broadcast_uui = result.data.uuid;
})
.catch((e) => {
this.setErrors(e);
@@ -465,6 +489,7 @@ export default {
.post(path, this.formData)
.then((result) => {
this.status = _.get(result, "data.status", "error");
+ this.script_microservice_broadcast_uui = result.data.uuid;
if (this.status === "started") {
this.formData.id = result.data.id;
this.fetch(); // refresh the table (beneath the modal)
@@ -479,7 +504,6 @@ export default {
this.$refs.edit.show();
},
edit(row) {
- console.log(row);
this.formData = _.cloneDeep(row);
this.$refs.edit.show();
},
@@ -526,6 +550,33 @@ export default {
onAddToBundle(data) {
this.$root.$emit('add-to-bundle', data);
},
+ subscribeToScriptMicroserviceChannel(name) {
+ const channel = `build-image-${name}`;
+ if (this.script_microservice_enabled) {
+ // Subscribe to new channel
+ window.ScriptMicroserviceEcho
+ .channel(channel)
+ .listenToAll((eventName, data) => {
+ this.status = this.status === "idle" ? "starting" : this.status;
+ switch (eventName) {
+ case ".build-image":
+ this.output(`${data}\n`);
+ break;
+ case ".build-finished":
+ this.pidFile = null;
+ this.exitCode = 0;
+ this.status = "done";
+ break;
+ case ".build-error":
+ this.output(data);
+ this.pidFile = null;
+ this.exitCode = 1;
+ this.status = "done";
+ break;
+ }
+ });
+ }
+ },
},
};
@@ -541,4 +592,4 @@ export default {
.error {
border-color: red !important;
}
-
+
\ No newline at end of file
diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js
index 8df87873b1..05f997b8c0 100644
--- a/resources/js/bootstrap.js
+++ b/resources/js/bootstrap.js
@@ -35,6 +35,8 @@ import FilterTable from "./components/shared/FilterTable.vue";
import PaginationTable from "./components/shared/PaginationTable.vue";
import PMDropdownSuggest from "./components/PMDropdownSuggest";
import "@processmaker/screen-builder/dist/vue-form-builder.css";
+import Echo from "laravel-echo";
+import Pusher from "pusher-js";
window.__ = translator;
window._ = require("lodash");
@@ -351,6 +353,15 @@ if (window.Processmaker && window.Processmaker.broadcasting) {
window.Echo = new TenantAwareEcho(config);
}
+if (window.Processmaker && window.Processmaker.script_microservice && window.Processmaker.script_microservice.enabled) {
+ const config = window.Processmaker.script_microservice.broadcasting;
+
+ window.ScriptMicroserviceEcho = new Echo({
+ ...config,
+ client: new Pusher(config.key, config),
+ });
+}
+
if (userID) {
const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content;
const accountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content));
diff --git a/resources/views/admin/script-executors/index.blade.php b/resources/views/admin/script-executors/index.blade.php
index 9e94f00610..4719d353ab 100644
--- a/resources/views/admin/script-executors/index.blade.php
+++ b/resources/views/admin/script-executors/index.blade.php
@@ -17,7 +17,9 @@
@section('content')
@endsection
diff --git a/resources/views/layouts/layout.blade.php b/resources/views/layouts/layout.blade.php
index 253d4343c4..e637776dd0 100644
--- a/resources/views/layouts/layout.blade.php
+++ b/resources/views/layouts/layout.blade.php
@@ -77,6 +77,24 @@
window.Processmaker.broadcasting.wssPort = "{{config('broadcasting.connections.pusher.options.port')}}";
@endif
@endif
+ @if(config('script-runner-microservice.enabled'))
+ window.Processmaker.script_microservice = {
+ enabled : {{ config('script-runner-microservice.enabled', false) }},
+ broadcasting : {
+ broadcaster: "pusher",
+ key: "{{config('script-runner-microservice.broadcasting.app_key')}}",
+ cluster: "{{config('script-runner-microservice.broadcasting.cluster')}}",
+ forceTLS: {{config('script-runner-microservice.broadcasting.scheme') === 'https' ? 'true' : 'false'}},
+ enabledTransports: ['ws', 'wss'],
+ disableStats: true,
+ }
+ };
+ @if(config('script-runner-microservice.broadcasting.host'))
+ window.Processmaker.script_microservice.broadcasting.wsHost = "{{config('script-runner-microservice.broadcasting.host')}}";
+ window.Processmaker.script_microservice.broadcasting.wsPort = "{{config('script-runner-microservice.broadcasting.port')}}";
+ window.Processmaker.script_microservice.broadcasting.wssPort = "{{config('script-runner-microservice.broadcasting.port')}}";
+ @endif
+ @endif
@endif
@isset($addons)
diff --git a/tests/unit/ProcessMaker/Services/ScriptMicroserviceServiceTest.php b/tests/unit/ProcessMaker/Services/ScriptMicroserviceServiceTest.php
new file mode 100644
index 0000000000..e5cebe89ac
--- /dev/null
+++ b/tests/unit/ProcessMaker/Services/ScriptMicroserviceServiceTest.php
@@ -0,0 +1,316 @@
+baseUrl = 'https://script-runner.test';
+ $this->accessToken = 'test-access-token';
+
+ // Set up config values
+ Config::set('script-runner-microservice.base_url', $this->baseUrl);
+ Config::set('script-runner-microservice.version', '1.0.0');
+ Config::set('script-runner-microservice.keycloak.base_url', 'https://keycloak.test/token');
+ Config::set('script-runner-microservice.keycloak.client_id', 'test-client');
+ Config::set('script-runner-microservice.keycloak.client_secret', 'test-secret');
+ Config::set('script-runner-microservice.keycloak.username', 'test-user');
+ Config::set('script-runner-microservice.keycloak.password', 'test-password');
+ Config::set('app.url', 'https://app.test');
+
+ $this->service = new ScriptMicroserviceService();
+
+ // Calculate the actual instance UUID that will be used
+ $this->instanceUuid = $this->service->getInstanceUuid();
+
+ // Get the actual instance name that will be used
+ $this->instanceName = config('app.instance');
+
+ // Clear cache before each test
+ Cache::flush();
+ }
+
+ public function testCreateCustomExecutor()
+ {
+ $scriptExecutor = ScriptExecutor::factory()->create([
+ 'title' => 'Test Executor',
+ 'description' => 'Test Description',
+ 'language' => 'php',
+ 'config' => '{"test": "config"}',
+ ]);
+
+ // Set uuid property (assuming it exists or can be set)
+ $scriptExecutor->uuid = 'test-executor-uuid';
+
+ Http::fake([
+ // Tenant check - tenant exists
+ $this->baseUrl . '/tenants/' . $this->instanceUuid => Http::response([], 200),
+ // Access token request
+ 'https://keycloak.test/token' => Http::response([
+ 'access_token' => $this->accessToken,
+ 'expires_in' => 3600,
+ ], 200),
+ // Create executor request
+ $this->baseUrl . '/custom/' . $this->instanceUuid . '/scripts' => Http::response([
+ 'id' => $scriptExecutor->uuid,
+ 'name' => $scriptExecutor->title,
+ 'status' => 'created',
+ ], 201),
+ ]);
+
+ $result = $this->service->createCustomExecutor($scriptExecutor);
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/custom/' . $this->instanceUuid . '/scripts')
+ && $request->method() === 'POST'
+ && $request->hasHeader('Authorization', 'Bearer ' . $this->accessToken)
+ && isset($request->data()['id'])
+ && $request->data()['name'] === 'Test Executor'
+ && $request->data()['language'] === 'php'
+ && $request->data()['version'] === '1.0.0';
+ });
+
+ $this->assertEquals('test-executor-uuid', $result['id']);
+ $this->assertEquals('Test Executor', $result['name']);
+ $this->assertEquals('created', $result['status']);
+ }
+
+ public function testUpdateCustomExecutor()
+ {
+ $scriptExecutor = ScriptExecutor::factory()->create([
+ 'title' => 'Updated Executor',
+ 'description' => 'Updated Description',
+ 'language' => 'javascript',
+ 'config' => '{"updated": "config"}',
+ ]);
+
+ $scriptExecutor->uuid = 'test-executor-uuid';
+
+ Http::fake([
+ // Tenant check - tenant exists
+ $this->baseUrl . '/tenants/' . $this->instanceUuid => Http::response([], 200),
+ // Access token request
+ 'https://keycloak.test/token' => Http::response([
+ 'access_token' => $this->accessToken,
+ 'expires_in' => 3600,
+ ], 200),
+ // Update executor request
+ $this->baseUrl . '/custom/scripts/' . $scriptExecutor->uuid => Http::response([
+ 'id' => $scriptExecutor->uuid,
+ 'name' => $scriptExecutor->title,
+ 'status' => 'updated',
+ ], 200),
+ ]);
+
+ $result = $this->service->updateCustomExecutor($scriptExecutor);
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/custom/scripts/test-executor-uuid')
+ && $request->method() === 'PUT'
+ && $request->hasHeader('Authorization', 'Bearer ' . $this->accessToken)
+ && $request->data()['name'] === 'Updated Executor'
+ && $request->data()['language'] === 'javascript'
+ && $request->data()['version'] === '1.0.0';
+ });
+
+ $this->assertEquals('test-executor-uuid', $result['id']);
+ $this->assertEquals('Updated Executor', $result['name']);
+ $this->assertEquals('updated', $result['status']);
+ }
+
+ public function testUpdateCustomExecutorCreatesWhenNotFound()
+ {
+ $scriptExecutor = ScriptExecutor::factory()->create([
+ 'title' => 'New Executor',
+ 'description' => 'New Description',
+ 'language' => 'php',
+ 'config' => '{"new": "config"}',
+ ]);
+
+ $scriptExecutor->uuid = 'test-executor-uuid';
+
+ Http::fake([
+ // Tenant check - tenant exists
+ $this->baseUrl . '/tenants/' . $this->instanceUuid => Http::response([], 200),
+ // Access token request
+ 'https://keycloak.test/token' => Http::response([
+ 'access_token' => $this->accessToken,
+ 'expires_in' => 3600,
+ ], 200),
+ // Update executor request - returns 404
+ $this->baseUrl . '/custom/scripts/' . $scriptExecutor->uuid => Http::response([], 404),
+ // Create executor request (should be called after 404)
+ $this->baseUrl . '/custom/' . $this->instanceUuid . '/scripts' => Http::response([
+ 'id' => $scriptExecutor->uuid,
+ 'name' => $scriptExecutor->title,
+ 'status' => 'created',
+ ], 201),
+ ]);
+
+ $result = $this->service->updateCustomExecutor($scriptExecutor);
+
+ // Verify update was attempted first
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/custom/scripts/test-executor-uuid')
+ && $request->method() === 'PUT';
+ });
+
+ // Verify create was called after 404
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/custom/' . $this->instanceUuid . '/scripts')
+ && $request->method() === 'POST'
+ && $request->hasHeader('Authorization', 'Bearer ' . $this->accessToken)
+ && isset($request->data()['id'])
+ && $request->data()['id'] === 'test-executor-uuid'
+ && $request->data()['name'] === 'New Executor'
+ && $request->data()['language'] === 'php'
+ && $request->data()['version'] === '1.0.0';
+ });
+
+ $this->assertEquals('test-executor-uuid', $result['id']);
+ $this->assertEquals('New Executor', $result['name']);
+ $this->assertEquals('created', $result['status']);
+ }
+
+ public function testDeleteCustomExecutor()
+ {
+ $executorUuid = 'test-executor-uuid';
+
+ Http::fake([
+ // Tenant check - tenant exists
+ $this->baseUrl . '/tenants/' . $this->instanceUuid => Http::response([], 200),
+ // Access token request
+ 'https://keycloak.test/token' => Http::response([
+ 'access_token' => $this->accessToken,
+ 'expires_in' => 3600,
+ ], 200),
+ // Delete executor request
+ $this->baseUrl . '/custom/scripts/' . $executorUuid => Http::response([
+ 'status' => 'deleted',
+ ], 200),
+ ]);
+
+ $result = $this->service->deleteCustomExecutor($executorUuid);
+
+ Http::assertSent(function ($request) use ($executorUuid) {
+ return str_contains($request->url(), '/custom/scripts/' . $executorUuid)
+ && $request->method() === 'DELETE'
+ && $request->hasHeader('Authorization', 'Bearer ' . $this->accessToken);
+ });
+
+ $this->assertEquals('deleted', $result['status']);
+ }
+
+ public function testCheckTenantCreatesTenantWhenNotFound()
+ {
+ $scriptExecutor = ScriptExecutor::factory()->create([
+ 'title' => 'Test Executor',
+ 'description' => 'Test Description',
+ 'language' => 'php',
+ 'config' => '{}',
+ ]);
+
+ $scriptExecutor->uuid = 'test-executor-uuid';
+
+ Http::fake([
+ // Tenant check - tenant does not exist (404)
+ $this->baseUrl . '/tenants/' . $this->instanceUuid => Http::response([], 404),
+ // Tenant creation
+ $this->baseUrl . '/tenants' => Http::response([
+ 'id' => $this->instanceUuid,
+ 'name' => $this->instanceName,
+ ], 201),
+ // Access token request
+ 'https://keycloak.test/token' => Http::response([
+ 'access_token' => $this->accessToken,
+ 'expires_in' => 3600,
+ ], 200),
+ // Create executor request
+ $this->baseUrl . '/custom/' . $this->instanceUuid . '/scripts' => Http::response([
+ 'id' => $scriptExecutor->uuid,
+ 'name' => $scriptExecutor->title,
+ 'status' => 'created',
+ ], 201),
+ ]);
+
+ $result = $this->service->createCustomExecutor($scriptExecutor);
+
+ // Verify tenant was created
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/tenants')
+ && $request->method() === 'POST'
+ && isset($request->data()['id'])
+ && $request->data()['id'] === $this->instanceUuid
+ && $request->data()['name'] === $this->instanceName;
+ });
+
+ // Verify executor was created after tenant creation
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/custom/' . $this->instanceUuid . '/scripts')
+ && $request->method() === 'POST';
+ });
+
+ $this->assertEquals('test-executor-uuid', $result['id']);
+ }
+
+ public function testAccessTokenIsCached()
+ {
+ $scriptExecutor = ScriptExecutor::factory()->create([
+ 'title' => 'Test Executor',
+ 'description' => 'Test Description',
+ 'language' => 'php',
+ 'config' => '{}',
+ ]);
+
+ $scriptExecutor->uuid = 'test-executor-uuid';
+
+ Http::fake([
+ // Tenant check
+ $this->baseUrl . '/tenants/' . $this->instanceUuid => Http::response([], 200),
+ // Access token request - should only be called once
+ 'https://keycloak.test/token' => Http::response([
+ 'access_token' => $this->accessToken,
+ 'expires_in' => 3600,
+ ], 200),
+ // Create executor request
+ $this->baseUrl . '/custom/' . $this->instanceUuid . '/scripts' => Http::response([
+ 'id' => $scriptExecutor->uuid,
+ 'status' => 'created',
+ ], 201),
+ ]);
+
+ // First call - should fetch token
+ $this->service->createCustomExecutor($scriptExecutor);
+
+ // Second call - should use cached token
+ $this->service->createCustomExecutor($scriptExecutor);
+
+ // Verify token was only requested once by checking recorded requests
+ $recorded = Http::recorded();
+ $tokenRequests = array_filter($recorded->toArray(), function ($record) {
+ return str_contains($record[0]->url(), 'keycloak.test/token');
+ });
+
+ $this->assertCount(1, $tokenRequests, 'Access token should only be requested once');
+ }
+}
diff --git a/tests/unit/ScriptMicroserviceServiceTest.php b/tests/unit/ScriptMicroserviceServiceTest.php
index 6aabaf3052..b2850ee79e 100644
--- a/tests/unit/ScriptMicroserviceServiceTest.php
+++ b/tests/unit/ScriptMicroserviceServiceTest.php
@@ -59,11 +59,18 @@ public function testHandlePreviewError()
$request = new Request();
$request->merge([
'status' => 'error',
- 'message' => 'Test error message',
'metadata' => [
'nonce' => 'test-nonce',
'current_user' => $this->user->id,
],
+ 'exception' => 'TestException',
+ 'error_message' => 'Test error message',
+ 'error' => [
+ 'code' => 'Test error code',
+ 'file' => 'Test error file',
+ 'line' => 'Test line number',
+ 'trace' => 'Test trace',
+ ]
]);
$this->service->handle($request);
@@ -71,7 +78,13 @@ public function testHandlePreviewError()
Event::assertDispatched(ScriptResponseEvent::class, function ($event) {
return $event->userId === $this->user->id
&& $event->status === 500
- && $event->response['exception'] === 'Test error message'
+ && $event->response['exception'] === 'TestException'
+ && $event->response['message'] === [
+ 'code' => 'Test error code',
+ 'file' => 'Test error file',
+ 'line' => 'Test line number',
+ 'trace' => 'Test trace',
+ ]
&& $event->nonce === 'test-nonce';
});
}