From a0c0b6884c54509914c95890729a1c9818d7d041 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Fri, 8 May 2026 10:11:23 -0600 Subject: [PATCH 1/5] Fix Nayra REST host resolution --- .../Models/ScriptDockerNayraTrait.php | 33 ++- .../Models/ScriptDockerNayraTraitTest.php | 235 ++++++++++++++++++ 2 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php diff --git a/ProcessMaker/Models/ScriptDockerNayraTrait.php b/ProcessMaker/Models/ScriptDockerNayraTrait.php index 8a2aa83625..e5c6e5fed9 100644 --- a/ProcessMaker/Models/ScriptDockerNayraTrait.php +++ b/ProcessMaker/Models/ScriptDockerNayraTrait.php @@ -45,11 +45,7 @@ public function handleNayraDocker(string $code, array $data, array $config, $tim 'timeout' => $timeout, ]; $body = json_encode($params); - $servers = self::getNayraAddresses(); - if (!$servers) { - $this->bringUpNayra(); - } - $baseUrl = $this->getNayraInstanceUrl(); + $baseUrl = $this->resolveNayraBaseUrl(); $url = $baseUrl . '/run_script'; $this->ensureNayraServerIsRunning($baseUrl); @@ -80,10 +76,27 @@ public function handleNayraDocker(string $code, array $data, array $config, $tim private function getNayraInstanceUrl() { + if (config('app.nayra_rest_api_host')) { + return config('app.nayra_rest_api_host'); + } + $servers = self::getNayraAddresses(); return $this->schema . '://' . $servers[0] . ':' . $this->getNayraPort(); } + private function resolveNayraBaseUrl() + { + if (config('app.nayra_rest_api_host')) { + return config('app.nayra_rest_api_host'); + } + + if (!self::getNayraAddresses()) { + $this->bringUpNayra(); + } + + return $this->getNayraInstanceUrl(); + } + private function getDockerLogs($instanceName) { $docker = Docker::command(); @@ -105,9 +118,15 @@ private function getDockerLogs($instanceName) private function ensureNayraServerIsRunning(string $url) { $header = @get_headers($url); - if (!$header) { - $this->bringUpNayra(true); + if ($header) { + return; + } + + if (config('app.nayra_rest_api_host')) { + throw new ScriptException('Could not connect to the configured Nayra REST API host: ' . $url); } + + $this->bringUpNayra(true); } /** diff --git a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php new file mode 100644 index 0000000000..d87359d6c3 --- /dev/null +++ b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php @@ -0,0 +1,235 @@ + $url]; + } +} + +if (!function_exists(__NAMESPACE__ . '\curl_setopt')) { + function curl_setopt($handle, $option, $value) + { + if (!ScriptDockerNayraTraitFunctionState::$enabled) { + return \curl_setopt($handle, $option, $value); + } + + ScriptDockerNayraTraitFunctionState::$curlOptions[$option] = $value; + + return true; + } +} + +if (!function_exists(__NAMESPACE__ . '\curl_exec')) { + function curl_exec($handle) + { + if (!ScriptDockerNayraTraitFunctionState::$enabled) { + return \curl_exec($handle); + } + + return ScriptDockerNayraTraitFunctionState::$curlResult; + } +} + +if (!function_exists(__NAMESPACE__ . '\curl_getinfo')) { + function curl_getinfo($handle, $option = null) + { + if (!ScriptDockerNayraTraitFunctionState::$enabled) { + return \curl_getinfo($handle, $option); + } + + if ($option === CURLINFO_HTTP_CODE) { + return ScriptDockerNayraTraitFunctionState::$curlHttpStatus; + } + + return ['http_code' => ScriptDockerNayraTraitFunctionState::$curlHttpStatus]; + } +} + +if (!function_exists(__NAMESPACE__ . '\curl_close')) { + function curl_close($handle) + { + if (!ScriptDockerNayraTraitFunctionState::$enabled) { + return \curl_close($handle); + } + + ScriptDockerNayraTraitFunctionState::$curlClosed = true; + } +} + +class ScriptDockerNayraTraitTestHarness +{ + use ScriptDockerNayraTrait { + getNayraInstanceUrl as public exposedGetNayraInstanceUrl; + resolveNayraBaseUrl as public exposedResolveNayraBaseUrl; + } + + public int $bringUpNayraCalls = 0; + + public array $bringUpNayraRestartValues = []; + + public function bringUpNayra($restart = false) + { + $this->bringUpNayraCalls++; + $this->bringUpNayraRestartValues[] = $restart; + + if (!$restart) { + self::setNayraAddresses(['172.18.0.9']); + } + } +} + +class ScriptDockerNayraTraitTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + ScriptDockerNayraTraitFunctionState::reset(); + ScriptDockerNayraTraitTestHarness::clearNayraAddresses(); + + config([ + 'app.nayra_rest_api_host' => '', + 'app.nayra_port' => 8081, + ]); + } + + protected function tearDown(): void + { + ScriptDockerNayraTraitFunctionState::reset(); + ScriptDockerNayraTraitTestHarness::clearNayraAddresses(); + + parent::tearDown(); + } + + public function testHandleNayraDockerUsesConfiguredRestApiHostWithoutStartingDocker() + { + config(['app.nayra_rest_api_host' => 'http://127.0.0.1:8081']); + ScriptDockerNayraTraitFunctionState::$enabled = true; + ScriptDockerNayraTraitFunctionState::$headers = [ + 'http://127.0.0.1:8081' => ['HTTP/1.1 200 OK'], + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + $result = $runner->handleNayraDocker(' 'bar'], [], 30, ['API_TOKEN=test-token']); + + $this->assertSame('{"status":"ok"}', $result); + $this->assertSame('http://127.0.0.1:8081/run_script', ScriptDockerNayraTraitFunctionState::$curlUrl); + $this->assertSame(['http://127.0.0.1:8081'], ScriptDockerNayraTraitFunctionState::$requestedHeaders); + $this->assertSame(0, $runner->bringUpNayraCalls); + } + + public function testConfiguredRestApiHostTakesPriorityOverCachedDockerAddress() + { + config(['app.nayra_rest_api_host' => 'http://127.0.0.1:8081']); + ScriptDockerNayraTraitTestHarness::setNayraAddresses(['172.18.0.5']); + ScriptDockerNayraTraitFunctionState::$enabled = true; + ScriptDockerNayraTraitFunctionState::$headers = [ + 'http://127.0.0.1:8081' => ['HTTP/1.1 200 OK'], + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + $runner->handleNayraDocker('assertSame('http://127.0.0.1:8081/run_script', ScriptDockerNayraTraitFunctionState::$curlUrl); + $this->assertSame(0, $runner->bringUpNayraCalls); + } + + public function testNayraBaseUrlFallsBackToCachedDockerAddressWithoutRestApiHost() + { + ScriptDockerNayraTraitTestHarness::setNayraAddresses(['172.18.0.5']); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame('http://172.18.0.5:8081', $runner->exposedResolveNayraBaseUrl()); + $this->assertSame(0, $runner->bringUpNayraCalls); + } + + public function testNayraBaseUrlStartsDockerBeforeResolvingUrlWhenNoRestApiHostOrCachedAddressExists() + { + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame('http://172.18.0.9:8081', $runner->exposedResolveNayraBaseUrl()); + $this->assertSame(1, $runner->bringUpNayraCalls); + $this->assertSame([false], $runner->bringUpNayraRestartValues); + } + + public function testConfiguredRestApiHostConnectionFailureDoesNotStartTenantDockerContainer() + { + config(['app.nayra_rest_api_host' => 'http://127.0.0.1:8081']); + ScriptDockerNayraTraitFunctionState::$enabled = true; + ScriptDockerNayraTraitFunctionState::$headers = [ + 'http://127.0.0.1:8081' => false, + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + + try { + $runner->handleNayraDocker('fail('Expected a ScriptException when the configured Nayra REST API host is unavailable.'); + } catch (ScriptException $exception) { + $this->assertStringContainsString( + 'Could not connect to the configured Nayra REST API host: http://127.0.0.1:8081', + $exception->getMessage() + ); + } + + $this->assertSame(0, $runner->bringUpNayraCalls); + $this->assertNull(ScriptDockerNayraTraitFunctionState::$curlUrl); + } +} From 989e3a1e656ef3a7b4654b1e5486526b24e341fa Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 12 May 2026 10:54:35 -0600 Subject: [PATCH 2/5] FOUR-30789: Fix Nayra resolution for real multitenant config --- .../Models/ScriptDockerNayraTrait.php | 325 ++++++++++++++---- ProcessMaker/ScriptRunners/Base.php | 5 + .../Models/ScriptDockerNayraTraitTest.php | 259 +++++++++----- 3 files changed, 432 insertions(+), 157 deletions(-) diff --git a/ProcessMaker/Models/ScriptDockerNayraTrait.php b/ProcessMaker/Models/ScriptDockerNayraTrait.php index e5c6e5fed9..2ca665e945 100644 --- a/ProcessMaker/Models/ScriptDockerNayraTrait.php +++ b/ProcessMaker/Models/ScriptDockerNayraTrait.php @@ -3,7 +3,6 @@ namespace ProcessMaker\Models; use Illuminate\Cache\ArrayStore; -use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -11,9 +10,7 @@ use ProcessMaker\Exception\ScriptException; use ProcessMaker\Facades\Docker; use ProcessMaker\ScriptRunners\Base; -use RuntimeException; -use Psr\Container\NotFoundExceptionInterface; -use Psr\Container\ContainerExceptionInterface; +use ProcessMaker\Models\ScriptExecutor; use UnexpectedValueException; /** @@ -24,6 +21,8 @@ trait ScriptDockerNayraTrait private $schema = 'http'; + abstract protected function getScriptExecutor(): ScriptExecutor; + /** * Execute the script task using Nayra Docker. * @@ -45,21 +44,20 @@ public function handleNayraDocker(string $code, array $data, array $config, $tim 'timeout' => $timeout, ]; $body = json_encode($params); - $baseUrl = $this->resolveNayraBaseUrl(); + $baseUrl = $this->ensureNayraServerIsRunning($this->resolveNayraBaseUrl()); $url = $baseUrl . '/run_script'; - $this->ensureNayraServerIsRunning($baseUrl); - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ + $ch = $this->curlInit($url); + $this->curlSetOpt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); + $this->curlSetOpt($ch, CURLOPT_POSTFIELDS, $body); + $this->curlSetOpt($ch, CURLOPT_RETURNTRANSFER, true); + $this->curlSetOpt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Content-Length: ' . strlen($body), ]); - $result = curl_exec($ch); - curl_close($ch); - $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $result = $this->curlExec($ch); + $httpStatus = $this->curlGetInfo($ch, CURLINFO_HTTP_CODE); + $this->curlClose($ch); if ($httpStatus !== 200) { $result .= ' HTTP Status: ' . $httpStatus; $result .= ' URL: ' . $url; @@ -77,23 +75,33 @@ public function handleNayraDocker(string $code, array $data, array $config, $tim private function getNayraInstanceUrl() { if (config('app.nayra_rest_api_host')) { - return config('app.nayra_rest_api_host'); + return $this->normalizeNayraUrl(config('app.nayra_rest_api_host')); + } + + if ($endpoint = self::getNayraEndpoint()) { + return $endpoint; } - $servers = self::getNayraAddresses(); - return $this->schema . '://' . $servers[0] . ':' . $this->getNayraPort(); + return $this->buildNayraEndpoint(); } private function resolveNayraBaseUrl() { if (config('app.nayra_rest_api_host')) { - return config('app.nayra_rest_api_host'); + return $this->normalizeNayraUrl(config('app.nayra_rest_api_host')); } - if (!self::getNayraAddresses()) { - $this->bringUpNayra(); + $endpoint = self::getNayraEndpoint(); + if ($endpoint) { + if ($this->isNayraServiceReachable($endpoint)) { + return $endpoint; + } + + self::clearNayraEndpoint(); } + $this->bringUpNayra(); + return $this->getNayraInstanceUrl(); } @@ -112,21 +120,26 @@ private function getDockerLogs($instanceName) * Ensure that the Nayra server is running. * * @param string $url URL of the Nayra server - * @return void + * @return string * @throws ScriptException If cannot connect to Nayra Service */ - private function ensureNayraServerIsRunning(string $url) + private function ensureNayraServerIsRunning(string $url): string { - $header = @get_headers($url); - if ($header) { - return; + if ($this->isNayraServiceReachable($url)) { + return $url; } if (config('app.nayra_rest_api_host')) { throw new ScriptException('Could not connect to the configured Nayra REST API host: ' . $url); } + self::clearNayraEndpoint(); $this->bringUpNayra(true); + + $url = $this->getNayraInstanceUrl(); + $this->nayraServiceIsRunning($url); + + return $url; } /** @@ -137,44 +150,44 @@ private function ensureNayraServerIsRunning(string $url) private function bringUpNayra($restart = false) { $docker = Docker::command(); - $instanceName = config('app.instance'); - if (!$restart && self::findNayraAddresses($docker, $instanceName, 3)) { - // The container is already running + $instanceName = self::getNayraContainerName(); + $endpoint = $this->buildNayraEndpoint(); + + if (!$restart && $this->isNayraServiceReachable($endpoint)) { + self::setNayraEndpoint($endpoint); return; } - $image = $this->scriptExecutor->dockerImageName(); - //check if image exists - exec($docker . " inspect {$image} 2>&1", $output, $status); + $image = $this->getNayraDockerImage($docker); + $portMapping = $this->getNayraDockerPortMapping(); + $network = config('app.nayra_docker_network'); + + $output = []; + exec($docker . " stop {$instanceName}_nayra 2>&1 || true", $output); + + $output = []; + exec($docker . " rm {$instanceName}_nayra 2>&1 || true", $output); + + $output = []; + exec( + $docker . ' run -d ' + . $portMapping + . '--name ' . $instanceName . '_nayra ' + . ($network ? '--network=' . $network . ' ' : '') + . $image, + $output, + $status + ); if ($status) { - $this->bringUpNayraContainer(); - } else { - $isHost = config('app.nayra_docker_network') === 'host'; - $portMapping = $isHost ? '-e PORT=' . $this->getNayraPort() . ' ' : '-p ' . $this->getNayraPort() . ':8080 '; - exec($docker . " stop {$instanceName}_nayra 2>&1 || true"); - exec($docker . " rm {$instanceName}_nayra 2>&1 || true"); - exec( - $docker . ' run -d ' - . ($this->getNayraPort() !== 8080 ? $portMapping : '') - . '--name ' . $instanceName . '_nayra ' - . (config('app.nayra_docker_network') - ? '--network=' . config('app.nayra_docker_network') . ' ' - : '') - . $image, - $output, - $status - ); - if ($status) { - Log::error('Error starting Nayra Docker', [ - 'output' => $output, - 'status' => $status, - ]); - throw new ScriptException('Error starting Nayra Docker'); - } + Log::error('Error starting Nayra Docker', [ + 'output' => $output, + 'status' => $status, + ]); + throw new ScriptException('Error starting Nayra Docker'); } - $this->waitContainerNetwork($docker, $instanceName); - $url = $this->getNayraInstanceUrl(); - $this->nayraServiceIsRunning($url); + + self::setNayraEndpoint($endpoint); + $this->nayraServiceIsRunning($endpoint); } private function bringUpNayraContainer() @@ -183,6 +196,40 @@ private function bringUpNayraContainer() Artisan::call("processmaker:build-script-executor {$lang} --rebuild"); } + private function getNayraDockerImage(string $docker): string + { + $image = $this->getScriptExecutor()->dockerImageName(); + $sharedImage = $this->sharedNayraDockerImageName($image); + + foreach (array_unique([$sharedImage, $image]) as $candidate) { + $output = []; + exec($docker . " inspect {$candidate} 2>&1", $output, $status); + if (!$status) { + return $candidate; + } + } + + $this->bringUpNayraContainer(); + + return $image; + } + + private function sharedNayraDockerImageName(string $image): string + { + $currentInstance = config('app.instance'); + $sharedInstance = self::getNayraInstanceName(); + + if ($currentInstance === $sharedInstance) { + return $image; + } + + return str_replace( + 'executor-' . $currentInstance . '-', + 'executor-' . $sharedInstance . '-', + $image + ); + } + /** * Waits for the container network to be ready. * @@ -255,7 +302,7 @@ private function nayraServiceIsRunning($url): bool if ($i > 0) { sleep(1); } - $status = @get_headers($url); + $status = $this->getHeaders($url); if ($status) { return true; } @@ -263,6 +310,140 @@ private function nayraServiceIsRunning($url): bool throw new ScriptException('Could not connect to the nayra container'); } + private function isNayraServiceReachable(string $url): bool + { + return (bool) $this->getHeaders($url); + } + + protected function getHeaders(string $url): array|false + { + return @get_headers($url); + } + + protected function curlInit(string $url): mixed + { + return curl_init($url); + } + + protected function curlSetOpt(mixed $handle, int $option, mixed $value): bool + { + return curl_setopt($handle, $option, $value); + } + + protected function curlExec(mixed $handle): string|bool + { + return curl_exec($handle); + } + + protected function curlGetInfo(mixed $handle, int $option): mixed + { + return curl_getinfo($handle, $option); + } + + protected function curlClose(mixed $handle): void + { + curl_close($handle); + } + + private function normalizeNayraUrl(string $url): string + { + return rtrim($url, '/'); + } + + private function buildNayraEndpoint(): string + { + return self::buildNayraEndpointUrl($this->schema); + } + + private static function buildNayraEndpointUrl(string $schema = 'http'): string + { + return $schema . '://' . self::getNayraEndpointHost() . ':' . self::getNayraPortValue(); + } + + private static function getNayraEndpointHost(): string + { + $dockerHost = config('app.processmaker_scripts_docker_host'); + if ($dockerHost) { + $parsed = parse_url($dockerHost); + if (isset($parsed['host'])) { + return $parsed['host']; + } + + return explode(':', $dockerHost)[0]; + } + + return '127.0.0.1'; + } + + private function getNayraDockerPortMapping(): string + { + return self::getNayraDockerPortMappingValue(); + } + + private static function getNayraDockerPortMappingValue(): string + { + $port = self::getNayraPortValue(); + + return config('app.nayra_docker_network') === 'host' + ? '-e PORT=' . $port . ' ' + : '-p ' . $port . ':8080 '; + } + + private static function getNayraInstanceName(): string + { + $instance = config('app.instance'); + + if (!config('app.multitenancy')) { + return $instance; + } + + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; + + if ($tenant && str_ends_with($instance, '_' . $tenant->id)) { + return substr($instance, 0, -strlen('_' . $tenant->id)); + } + + return $instance; + } + + private static function getNayraContainerName(): string + { + return self::getNayraInstanceName(); + } + + public static function getNayraEndpoint() + { + // Check if it is running in unit test mode with Cache ArrayStore + $isArrayDriver = self::isCacheArrayStore(); + if ($isArrayDriver) { + return Cache::store('file')->get('nayra_endpoint'); + } + + return Cache::get('nayra_endpoint'); + } + + public static function setNayraEndpoint(string $endpoint) + { + // Check if it is running in unit test mode with Cache ArrayStore + $isArrayDriver = self::isCacheArrayStore(); + if ($isArrayDriver) { + return Cache::store('file')->forever('nayra_endpoint', $endpoint); + } + + Cache::forever('nayra_endpoint', $endpoint); + } + + public static function clearNayraEndpoint() + { + // Check if it is running in unit test mode with Cache ArrayStore + $isArrayDriver = self::isCacheArrayStore(); + if ($isArrayDriver) { + return Cache::store('file')->forget('nayra_endpoint'); + } + + Cache::forget('nayra_endpoint'); + } + public static function getNayraAddresses() { // Check if it is running in unit test mode with Cache ArrayStore @@ -304,27 +485,25 @@ private static function isCacheArrayStore(): bool public static function bringUpNayraExecutor(BuildScriptExecutors $builder, string $image) { - $instanceName = config('app.instance'); + $instanceName = self::getNayraContainerName(); $docker = Docker::command(); $builder->info('Stop existing nayra container'); $builder->execCommand("{$docker} stop {$instanceName}_nayra 2>&1 || true"); $builder->execCommand("{$docker} rm {$instanceName}_nayra 2>&1 || true"); $builder->info('Bring up the nayra container'); $builder->execCommand( - $docker . ' run -d --name ' . $instanceName . '_nayra ' + $docker . ' run -d ' + . self::getNayraDockerPortMappingValue() + . '--name ' . $instanceName . '_nayra ' . (config('app.nayra_docker_network') ? '--network=' . config('app.nayra_docker_network') . ' ' : '') . $image ); - $builder->info('Get IP address of the nayra container'); - $found = self::findNayraAddresses($docker, $instanceName, 30); - if ($found) { - $builder->info('Nayra container IP: ' . self::getNayraAddresses()[0]); - $builder->sendEvent(0, 'done'); - } else { - throw new UnexpectedValueException('Could not get IP address of the nayra container'); - } + $endpoint = self::buildNayraEndpointUrl(); + self::setNayraEndpoint($endpoint); + $builder->info('Nayra endpoint: ' . $endpoint); + $builder->sendEvent(0, 'done'); } /** @@ -333,6 +512,7 @@ public static function bringUpNayraExecutor(BuildScriptExecutors $builder, strin public static function initNayraPhpUnitTest() { Base::clearNayraAddresses(); + Base::clearNayraEndpoint(); $network = config('app.nayra_docker_network'); // Check if docker network exists, if not create it exec(Docker::command() . " network inspect {$network} 2>&1", $output, $status); @@ -346,6 +526,11 @@ public static function initNayraPhpUnitTest() private function getNayraPort() { - return config('app.nayra_port', 8080); + return self::getNayraPortValue(); + } + + private static function getNayraPortValue(): int + { + return (int) config('app.nayra_port', 8080) ?: 8080; } } diff --git a/ProcessMaker/ScriptRunners/Base.php b/ProcessMaker/ScriptRunners/Base.php index c609f3a0d6..d20d57ebb4 100644 --- a/ProcessMaker/ScriptRunners/Base.php +++ b/ProcessMaker/ScriptRunners/Base.php @@ -53,6 +53,11 @@ public function __construct(ScriptExecutor $scriptExecutor) $this->scriptExecutor = $scriptExecutor; } + protected function getScriptExecutor(): ScriptExecutor + { + return $this->scriptExecutor; + } + /** * Run a script code. * diff --git a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php index d87359d6c3..54c8279812 100644 --- a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php +++ b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php @@ -7,7 +7,13 @@ class ScriptDockerNayraTraitFunctionState { - public static bool $enabled = false; + public const SUCCESS_RESPONSE = '{"status":"ok"}'; + + public const LOCAL_NAYRA_HOST = 'http://127.0.0.1:8081'; + + public const OK_HEADER = 'HTTP/1.1 200 OK'; + + public const SCRIPT_CODE = 'bringUpNayraCalls++; + $this->bringUpNayraRestartValues[] = $restart; + + self::setNayraEndpoint(ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST); + } + protected function getScriptExecutor(): ScriptExecutor + { + throw new ScriptExecutorResolutionNotExercisedException( + 'The test harness does not exercise script executor resolution.' + ); + } + + protected function getHeaders(string $url): array|false + { ScriptDockerNayraTraitFunctionState::$requestedHeaders[] = $url; return ScriptDockerNayraTraitFunctionState::$headers[$url] ?? false; } -} -if (!function_exists(__NAMESPACE__ . '\curl_init')) { - function curl_init($url = null) + protected function curlInit(string $url): object { - if (!ScriptDockerNayraTraitFunctionState::$enabled) { - return \curl_init($url); - } - ScriptDockerNayraTraitFunctionState::$curlUrl = $url; return (object) ['url' => $url]; } -} -if (!function_exists(__NAMESPACE__ . '\curl_setopt')) { - function curl_setopt($handle, $option, $value) + protected function curlSetOpt(mixed $handle, int $option, mixed $value): bool { - if (!ScriptDockerNayraTraitFunctionState::$enabled) { - return \curl_setopt($handle, $option, $value); - } - + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; ScriptDockerNayraTraitFunctionState::$curlOptions[$option] = $value; return true; } -} -if (!function_exists(__NAMESPACE__ . '\curl_exec')) { - function curl_exec($handle) + protected function curlExec(mixed $handle): string|bool { - if (!ScriptDockerNayraTraitFunctionState::$enabled) { - return \curl_exec($handle); - } + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; return ScriptDockerNayraTraitFunctionState::$curlResult; } -} -if (!function_exists(__NAMESPACE__ . '\curl_getinfo')) { - function curl_getinfo($handle, $option = null) + protected function curlGetInfo(mixed $handle, int $option): mixed { - if (!ScriptDockerNayraTraitFunctionState::$enabled) { - return \curl_getinfo($handle, $option); - } + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; if ($option === CURLINFO_HTTP_CODE) { return ScriptDockerNayraTraitFunctionState::$curlHttpStatus; @@ -99,41 +115,14 @@ function curl_getinfo($handle, $option = null) return ['http_code' => ScriptDockerNayraTraitFunctionState::$curlHttpStatus]; } -} -if (!function_exists(__NAMESPACE__ . '\curl_close')) { - function curl_close($handle) + protected function curlClose(mixed $handle): void { - if (!ScriptDockerNayraTraitFunctionState::$enabled) { - return \curl_close($handle); - } - + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; ScriptDockerNayraTraitFunctionState::$curlClosed = true; } } -class ScriptDockerNayraTraitTestHarness -{ - use ScriptDockerNayraTrait { - getNayraInstanceUrl as public exposedGetNayraInstanceUrl; - resolveNayraBaseUrl as public exposedResolveNayraBaseUrl; - } - - public int $bringUpNayraCalls = 0; - - public array $bringUpNayraRestartValues = []; - - public function bringUpNayra($restart = false) - { - $this->bringUpNayraCalls++; - $this->bringUpNayraRestartValues[] = $restart; - - if (!$restart) { - self::setNayraAddresses(['172.18.0.9']); - } - } -} - class ScriptDockerNayraTraitTest extends TestCase { protected function setUp(): void @@ -142,10 +131,16 @@ protected function setUp(): void ScriptDockerNayraTraitFunctionState::reset(); ScriptDockerNayraTraitTestHarness::clearNayraAddresses(); + ScriptDockerNayraTraitTestHarness::clearNayraEndpoint(); + app()->forgetInstance('currentTenant'); config([ + 'app.instance' => 'processmaker', + 'app.multitenancy' => false, 'app.nayra_rest_api_host' => '', 'app.nayra_port' => 8081, + 'app.nayra_docker_network' => '', + 'app.processmaker_scripts_docker_host' => '', ]); } @@ -153,50 +148,77 @@ protected function tearDown(): void { ScriptDockerNayraTraitFunctionState::reset(); ScriptDockerNayraTraitTestHarness::clearNayraAddresses(); + ScriptDockerNayraTraitTestHarness::clearNayraEndpoint(); + app()->forgetInstance('currentTenant'); parent::tearDown(); } public function testHandleNayraDockerUsesConfiguredRestApiHostWithoutStartingDocker() { - config(['app.nayra_rest_api_host' => 'http://127.0.0.1:8081']); - ScriptDockerNayraTraitFunctionState::$enabled = true; + config(['app.nayra_rest_api_host' => ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST]); ScriptDockerNayraTraitFunctionState::$headers = [ - 'http://127.0.0.1:8081' => ['HTTP/1.1 200 OK'], + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], ]; $runner = new ScriptDockerNayraTraitTestHarness(); - $result = $runner->handleNayraDocker(' 'bar'], [], 30, ['API_TOKEN=test-token']); - - $this->assertSame('{"status":"ok"}', $result); - $this->assertSame('http://127.0.0.1:8081/run_script', ScriptDockerNayraTraitFunctionState::$curlUrl); - $this->assertSame(['http://127.0.0.1:8081'], ScriptDockerNayraTraitFunctionState::$requestedHeaders); + $result = $runner->handleNayraDocker( + ScriptDockerNayraTraitFunctionState::SCRIPT_CODE, + ['foo' => 'bar'], + [], + 30, + ['API_TOKEN=test-token'] + ); + + $this->assertSame(ScriptDockerNayraTraitFunctionState::SUCCESS_RESPONSE, $result); + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST . '/run_script', + ScriptDockerNayraTraitFunctionState::$curlUrl + ); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); $this->assertSame(0, $runner->bringUpNayraCalls); } public function testConfiguredRestApiHostTakesPriorityOverCachedDockerAddress() { - config(['app.nayra_rest_api_host' => 'http://127.0.0.1:8081']); - ScriptDockerNayraTraitTestHarness::setNayraAddresses(['172.18.0.5']); - ScriptDockerNayraTraitFunctionState::$enabled = true; + config(['app.nayra_rest_api_host' => ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST]); + ScriptDockerNayraTraitTestHarness::setNayraEndpoint('http://127.0.0.1:9090'); ScriptDockerNayraTraitFunctionState::$headers = [ - 'http://127.0.0.1:8081' => ['HTTP/1.1 200 OK'], + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], ]; $runner = new ScriptDockerNayraTraitTestHarness(); - $runner->handleNayraDocker('handleNayraDocker(ScriptDockerNayraTraitFunctionState::SCRIPT_CODE, [], [], 30, []); - $this->assertSame('http://127.0.0.1:8081/run_script', ScriptDockerNayraTraitFunctionState::$curlUrl); + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST . '/run_script', + ScriptDockerNayraTraitFunctionState::$curlUrl + ); $this->assertSame(0, $runner->bringUpNayraCalls); } - public function testNayraBaseUrlFallsBackToCachedDockerAddressWithoutRestApiHost() + public function testNayraBaseUrlFallsBackToReachableCachedEndpointWithoutRestApiHost() { - ScriptDockerNayraTraitTestHarness::setNayraAddresses(['172.18.0.5']); + ScriptDockerNayraTraitTestHarness::setNayraEndpoint(ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST); + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; $runner = new ScriptDockerNayraTraitTestHarness(); - $this->assertSame('http://172.18.0.5:8081', $runner->exposedResolveNayraBaseUrl()); + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + $runner->exposedResolveNayraBaseUrl() + ); $this->assertSame(0, $runner->bringUpNayraCalls); } @@ -204,27 +226,90 @@ public function testNayraBaseUrlStartsDockerBeforeResolvingUrlWhenNoRestApiHostO { $runner = new ScriptDockerNayraTraitTestHarness(); - $this->assertSame('http://172.18.0.9:8081', $runner->exposedResolveNayraBaseUrl()); + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + $runner->exposedResolveNayraBaseUrl() + ); $this->assertSame(1, $runner->bringUpNayraCalls); $this->assertSame([false], $runner->bringUpNayraRestartValues); } + public function testEmptyNayraPortFallsBackToDefaultPort() + { + config(['app.nayra_port' => '']); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame(8080, $runner->exposedGetNayraPort()); + } + + public function testRemoteDockerHostIsUsedForFallbackNayraEndpoint() + { + config([ + 'app.nayra_port' => '', + 'app.processmaker_scripts_docker_host' => 'tcp://qa-remotedocker:2375', + ]); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame('http://qa-remotedocker:8080', $runner->exposedGetNayraInstanceUrl()); + } + + public function testMultitenantNayraUsesStableContainerNameAcrossTenants() + { + config([ + 'app.instance' => 'processmaker_1', + 'app.multitenancy' => true, + ]); + app()->instance('currentTenant', (object) ['id' => 1]); + + $tenantOneRunner = new ScriptDockerNayraTraitTestHarness(); + + config(['app.instance' => 'processmaker_3']); + app()->instance('currentTenant', (object) ['id' => 3]); + + $tenantThreeRunner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame('processmaker', $tenantOneRunner->exposedGetNayraContainerName()); + $this->assertSame('processmaker', $tenantThreeRunner->exposedGetNayraContainerName()); + } + + public function testStaleCachedEndpointIsClearedBeforeRebuildingNayraEndpoint() + { + ScriptDockerNayraTraitTestHarness::setNayraEndpoint('http://stale-nayra:8080'); + ScriptDockerNayraTraitFunctionState::$headers = [ + 'http://stale-nayra:8080' => false, + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + $runner->exposedResolveNayraBaseUrl() + ); + $this->assertSame(1, $runner->bringUpNayraCalls); + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + ScriptDockerNayraTraitTestHarness::getNayraEndpoint() + ); + } + public function testConfiguredRestApiHostConnectionFailureDoesNotStartTenantDockerContainer() { - config(['app.nayra_rest_api_host' => 'http://127.0.0.1:8081']); - ScriptDockerNayraTraitFunctionState::$enabled = true; + config(['app.nayra_rest_api_host' => ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST]); ScriptDockerNayraTraitFunctionState::$headers = [ - 'http://127.0.0.1:8081' => false, + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => false, ]; $runner = new ScriptDockerNayraTraitTestHarness(); try { - $runner->handleNayraDocker('handleNayraDocker(ScriptDockerNayraTraitFunctionState::SCRIPT_CODE, [], [], 30, []); $this->fail('Expected a ScriptException when the configured Nayra REST API host is unavailable.'); } catch (ScriptException $exception) { $this->assertStringContainsString( - 'Could not connect to the configured Nayra REST API host: http://127.0.0.1:8081', + 'Could not connect to the configured Nayra REST API host: ' + . ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, $exception->getMessage() ); } From 7a795586ac7488c5ebef56f31ba820bf07f24436 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 12 May 2026 11:43:40 -0600 Subject: [PATCH 3/5] FOUR-30789: Verify Nayra readiness before caching endpoint --- .../Models/ScriptDockerNayraTrait.php | 39 ++++- .../Models/ScriptDockerNayraTraitTest.php | 140 ++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/Models/ScriptDockerNayraTrait.php b/ProcessMaker/Models/ScriptDockerNayraTrait.php index 2ca665e945..ace1d3015f 100644 --- a/ProcessMaker/Models/ScriptDockerNayraTrait.php +++ b/ProcessMaker/Models/ScriptDockerNayraTrait.php @@ -186,8 +186,13 @@ private function bringUpNayra($restart = false) throw new ScriptException('Error starting Nayra Docker'); } - self::setNayraEndpoint($endpoint); + $this->cacheNayraEndpointAfterReadiness($endpoint); + } + + private function cacheNayraEndpointAfterReadiness(string $endpoint): void + { $this->nayraServiceIsRunning($endpoint); + self::setNayraEndpoint($endpoint); } private function bringUpNayraContainer() @@ -298,7 +303,7 @@ private static function findNayraAddresses($docker, $instanceName, $times): bool */ private function nayraServiceIsRunning($url): bool { - for ($i = 0; $i < 30; $i++) { + for ($i = 0; $i < static::getNayraEndpointReadinessAttempts(); $i++) { if ($i > 0) { sleep(1); } @@ -317,7 +322,7 @@ private function isNayraServiceReachable(string $url): bool protected function getHeaders(string $url): array|false { - return @get_headers($url); + return static::getNayraEndpointHeaders($url); } protected function curlInit(string $url): mixed @@ -501,11 +506,39 @@ public static function bringUpNayraExecutor(BuildScriptExecutors $builder, strin . $image ); $endpoint = self::buildNayraEndpointUrl(); + if (!static::nayraEndpointIsRunning($endpoint)) { + throw new UnexpectedValueException('Could not connect to the nayra container'); + } + self::setNayraEndpoint($endpoint); $builder->info('Nayra endpoint: ' . $endpoint); $builder->sendEvent(0, 'done'); } + private static function nayraEndpointIsRunning(string $url): bool + { + for ($i = 0; $i < static::getNayraEndpointReadinessAttempts(); $i++) { + if ($i > 0) { + sleep(1); + } + if (static::getNayraEndpointHeaders($url)) { + return true; + } + } + + return false; + } + + protected static function getNayraEndpointHeaders(string $url): array|false + { + return @get_headers($url); + } + + protected static function getNayraEndpointReadinessAttempts(): int + { + return 30; + } + /** * Initialize the phpunit test network for Nayra. */ diff --git a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php index 54c8279812..e3263a36d7 100644 --- a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php +++ b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php @@ -2,7 +2,9 @@ namespace ProcessMaker\Models; +use ProcessMaker\Console\Commands\BuildScriptExecutors; use ProcessMaker\Exception\ScriptException; +use UnexpectedValueException; use Tests\TestCase; class ScriptDockerNayraTraitFunctionState @@ -55,6 +57,7 @@ class ScriptDockerNayraTraitTestHarness resolveNayraBaseUrl as public exposedResolveNayraBaseUrl; getNayraPort as public exposedGetNayraPort; getNayraContainerName as public exposedGetNayraContainerName; + cacheNayraEndpointAfterReadiness as public exposedCacheNayraEndpointAfterReadiness; } public int $bringUpNayraCalls = 0; @@ -77,12 +80,22 @@ protected function getScriptExecutor(): ScriptExecutor } protected function getHeaders(string $url): array|false + { + return static::getNayraEndpointHeaders($url); + } + + protected static function getNayraEndpointHeaders(string $url): array|false { ScriptDockerNayraTraitFunctionState::$requestedHeaders[] = $url; return ScriptDockerNayraTraitFunctionState::$headers[$url] ?? false; } + protected static function getNayraEndpointReadinessAttempts(): int + { + return 1; + } + protected function curlInit(string $url): object { ScriptDockerNayraTraitFunctionState::$curlUrl = $url; @@ -123,6 +136,37 @@ protected function curlClose(mixed $handle): void } } +class ScriptDockerNayraTraitBuildScriptExecutorsHarness extends BuildScriptExecutors +{ + public array $commands = []; + + public array $events = []; + + public array $infoMessages = []; + + public function execCommand(string $command) + { + $this->commands[] = $command; + } + + public function info($text, $verbosity = null) + { + $this->infoMessages[] = $text; + } + + /** + * @param mixed $output + * @param mixed $status + */ + public function sendEvent($output, $status) + { + $this->events[] = [ + 'output' => $output, + 'status' => $status, + ]; + } +} + class ScriptDockerNayraTraitTest extends TestCase { protected function setUp(): void @@ -317,4 +361,100 @@ public function testConfiguredRestApiHostConnectionFailureDoesNotStartTenantDock $this->assertSame(0, $runner->bringUpNayraCalls); $this->assertNull(ScriptDockerNayraTraitFunctionState::$curlUrl); } + + public function testRuntimeBringUpNayraCachesEndpointAfterReadinessPasses() + { + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + $runner->exposedCacheNayraEndpointAfterReadiness(ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + ScriptDockerNayraTraitTestHarness::getNayraEndpoint() + ); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); + } + + public function testRuntimeBringUpNayraDoesNotCacheEndpointWhenReadinessFails() + { + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => false, + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + + try { + $runner->exposedCacheNayraEndpointAfterReadiness(ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST); + $this->fail('Expected runtime Nayra startup to fail when the endpoint is unreachable.'); + } catch (ScriptException $exception) { + $this->assertSame('Could not connect to the nayra container', $exception->getMessage()); + } + + $this->assertNull(ScriptDockerNayraTraitTestHarness::getNayraEndpoint()); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); + } + + public function testBringUpNayraExecutorCachesEndpointAfterItIsReachable() + { + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; + $builder = new ScriptDockerNayraTraitBuildScriptExecutorsHarness(); + + ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor($builder, 'processmaker4/nayra:test'); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + ScriptDockerNayraTraitTestHarness::getNayraEndpoint() + ); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); + $this->assertCount(3, $builder->commands); + $this->assertStringContainsString('docker run -d -p 8081:8080', $builder->commands[2]); + $this->assertContains('Nayra endpoint: ' . ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, $builder->infoMessages); + $this->assertSame([ + [ + 'output' => 0, + 'status' => 'done', + ], + ], $builder->events); + } + + public function testBringUpNayraExecutorDoesNotCacheEndpointWhenItIsUnreachable() + { + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => false, + ]; + $builder = new ScriptDockerNayraTraitBuildScriptExecutorsHarness(); + + try { + ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor($builder, 'processmaker4/nayra:test'); + $this->fail('Expected Nayra executor startup to fail when the endpoint is unreachable.'); + } catch (UnexpectedValueException $exception) { + $this->assertSame('Could not connect to the nayra container', $exception->getMessage()); + } + + $this->assertNull(ScriptDockerNayraTraitTestHarness::getNayraEndpoint()); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); + $this->assertCount(3, $builder->commands); + $this->assertSame([], $builder->events); + } } From 3907e0745f0a40626758da72200e25f1fe72a914 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 12 May 2026 13:17:01 -0600 Subject: [PATCH 4/5] FOUR-30789: Fix Nayra endpoint for dockerized phpunit --- .../Models/ScriptDockerNayraTrait.php | 49 +++++++-- .../Models/ScriptDockerNayraTraitTest.php | 104 +++++++++++++++++- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/ProcessMaker/Models/ScriptDockerNayraTrait.php b/ProcessMaker/Models/ScriptDockerNayraTrait.php index ace1d3015f..8465234fd2 100644 --- a/ProcessMaker/Models/ScriptDockerNayraTrait.php +++ b/ProcessMaker/Models/ScriptDockerNayraTrait.php @@ -186,6 +186,7 @@ private function bringUpNayra($restart = false) throw new ScriptException('Error starting Nayra Docker'); } + $endpoint = self::buildNayraEndpointAfterStartup($docker, $instanceName, $this->schema); $this->cacheNayraEndpointAfterReadiness($endpoint); } @@ -256,6 +257,11 @@ private function waitContainerNetwork($docker, $instanceName) * @return bool Returns true if the Nayra addresses were found, false otherwise. */ private static function findNayraAddresses($docker, $instanceName, $times): bool + { + return self::resolveNayraContainerAddress($docker, $instanceName, $times) !== null; + } + + private static function resolveNayraContainerAddress($docker, $instanceName, $times): ?string { $ip = ''; $nayraDockerNetwork = config('app.nayra_docker_network'); @@ -264,15 +270,17 @@ private static function findNayraAddresses($docker, $instanceName, $times): bool if ($i > 0) { sleep(1); } + $output = []; + $status = 0; if ($nayraDockerNetwork === 'host') { - $ip = exec( + $ip = static::execDockerCommand( $docker . " exec {$instanceName}_nayra hostname -i 2>/dev/null", $output, $status ); $ip = explode(' ', trim($ip))[0]; } else { - $ip = exec( + $ip = static::execDockerCommand( $docker . ' inspect --format ' . ($nayraDockerNetwork ? "'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'" @@ -288,11 +296,16 @@ private static function findNayraAddresses($docker, $instanceName, $times): bool } if ($ip) { self::setNayraAddresses([$ip]); - return true; + return $ip; } } - return false; + return null; + } + + protected static function execDockerCommand(string $command, array &$output, int &$status): string|false + { + return exec($command, $output, $status); } /** @@ -360,9 +373,31 @@ private function buildNayraEndpoint(): string return self::buildNayraEndpointUrl($this->schema); } - private static function buildNayraEndpointUrl(string $schema = 'http'): string + private static function buildNayraEndpointUrl(string $schema = 'http', ?string $host = null): string { - return $schema . '://' . self::getNayraEndpointHost() . ':' . self::getNayraPortValue(); + return $schema . '://' . ($host ?? self::getNayraEndpointHost()) . ':' . self::getNayraPortValue(); + } + + private static function buildNayraEndpointAfterStartup( + string $docker, + string $instanceName, + string $schema = 'http', + int $addressAttempts = 30 + ): string { + if (self::shouldUseContainerHostNetworkEndpoint()) { + $host = self::resolveNayraContainerAddress($docker, $instanceName, $addressAttempts); + if ($host) { + return self::buildNayraEndpointUrl($schema, $host); + } + } + + return self::buildNayraEndpointUrl($schema); + } + + private static function shouldUseContainerHostNetworkEndpoint(): bool + { + return config('app.nayra_docker_network') === 'host' + && !config('app.processmaker_scripts_docker_host'); } private static function getNayraEndpointHost(): string @@ -505,7 +540,7 @@ public static function bringUpNayraExecutor(BuildScriptExecutors $builder, strin : '') . $image ); - $endpoint = self::buildNayraEndpointUrl(); + $endpoint = self::buildNayraEndpointAfterStartup($docker, $instanceName); if (!static::nayraEndpointIsRunning($endpoint)) { throw new UnexpectedValueException('Could not connect to the nayra container'); } diff --git a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php index e3263a36d7..02a54843f7 100644 --- a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php +++ b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php @@ -13,6 +13,12 @@ class ScriptDockerNayraTraitFunctionState public const LOCAL_NAYRA_HOST = 'http://127.0.0.1:8081'; + public const HOST_NETWORK_IP = '172.17.0.' . '1'; + + public const HOST_NETWORK_NAYRA_HOST = 'http://' . self::HOST_NETWORK_IP . ':8081'; + + public const NAYRA_TEST_IMAGE = 'processmaker4/nayra:test'; + public const OK_HEADER = 'HTTP/1.1 200 OK'; public const SCRIPT_CODE = 'assertSame('http://qa-remotedocker:8080', $runner->exposedGetNayraInstanceUrl()); } + public function testLocalHostNetworkNayraEndpointUsesDockerReportedAddressAfterStartup() + { + config(['app.nayra_docker_network' => 'host']); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::HOST_NETWORK_NAYRA_HOST, + $runner->exposedBuildNayraEndpointAfterStartup('docker', 'processmaker', 'http', 1) + ); + $this->assertCount(1, ScriptDockerNayraTraitFunctionState::$execCommands); + $this->assertStringContainsString( + 'exec processmaker_nayra hostname -i 2>/dev/null', + ScriptDockerNayraTraitFunctionState::$execCommands[0] + ); + } + + public function testLocalHostNetworkNayraEndpointDoesNotOverrideRemoteDockerHost() + { + config([ + 'app.nayra_docker_network' => 'host', + 'app.processmaker_scripts_docker_host' => 'tcp://qa-remotedocker:2375', + ]); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + 'http://qa-remotedocker:8081', + $runner->exposedBuildNayraEndpointAfterStartup('DOCKER_HOST=tcp://qa-remotedocker:2375 docker', 'processmaker', 'http', 1) + ); + $this->assertSame([], ScriptDockerNayraTraitFunctionState::$execCommands); + } + public function testMultitenantNayraUsesStableContainerNameAcrossTenants() { config([ @@ -414,7 +472,10 @@ public function testBringUpNayraExecutorCachesEndpointAfterItIsReachable() ]; $builder = new ScriptDockerNayraTraitBuildScriptExecutorsHarness(); - ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor($builder, 'processmaker4/nayra:test'); + ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor( + $builder, + ScriptDockerNayraTraitFunctionState::NAYRA_TEST_IMAGE + ); $this->assertSame( ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, @@ -443,7 +504,10 @@ public function testBringUpNayraExecutorDoesNotCacheEndpointWhenItIsUnreachable( $builder = new ScriptDockerNayraTraitBuildScriptExecutorsHarness(); try { - ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor($builder, 'processmaker4/nayra:test'); + ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor( + $builder, + ScriptDockerNayraTraitFunctionState::NAYRA_TEST_IMAGE + ); $this->fail('Expected Nayra executor startup to fail when the endpoint is unreachable.'); } catch (UnexpectedValueException $exception) { $this->assertSame('Could not connect to the nayra container', $exception->getMessage()); @@ -457,4 +521,40 @@ public function testBringUpNayraExecutorDoesNotCacheEndpointWhenItIsUnreachable( $this->assertCount(3, $builder->commands); $this->assertSame([], $builder->events); } + + public function testBringUpNayraExecutorCachesHostNetworkEndpointAfterItIsReachable() + { + config(['app.nayra_docker_network' => 'host']); + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::HOST_NETWORK_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; + $builder = new ScriptDockerNayraTraitBuildScriptExecutorsHarness(); + + ScriptDockerNayraTraitTestHarness::bringUpNayraExecutor( + $builder, + ScriptDockerNayraTraitFunctionState::NAYRA_TEST_IMAGE + ); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::HOST_NETWORK_NAYRA_HOST, + ScriptDockerNayraTraitTestHarness::getNayraEndpoint() + ); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::HOST_NETWORK_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); + $this->assertCount(1, ScriptDockerNayraTraitFunctionState::$execCommands); + $this->assertStringContainsString( + 'exec processmaker_nayra hostname -i 2>/dev/null', + ScriptDockerNayraTraitFunctionState::$execCommands[0] + ); + $this->assertCount(3, $builder->commands); + $this->assertStringContainsString('docker run -d -e PORT=8081', $builder->commands[2]); + $this->assertContains( + 'Nayra endpoint: ' . ScriptDockerNayraTraitFunctionState::HOST_NETWORK_NAYRA_HOST, + $builder->infoMessages + ); + } } From 5ae8bb66ebc95b8424bbf20d808e06d3307c71c5 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 12 May 2026 15:01:42 -0600 Subject: [PATCH 5/5] FOUR-30789: Fix Nayra PHPUnit network endpoint Resolve the Nayra endpoint using the Docker-reported container IP when PHPUnit runs inside a local Docker network such as pm4-tools_default, while preserving remote Docker and published-port behavior.\n\nAdd warning-level runtime diagnostics so the multitenant CI/CD 500 can be debugged with the server's LOG_LEVEL=warning configuration. --- .../Models/ScriptDockerNayraTrait.php | 63 ++++++++++++++++-- .../Models/ScriptDockerNayraTraitTest.php | 66 ++++++++++++++++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/ProcessMaker/Models/ScriptDockerNayraTrait.php b/ProcessMaker/Models/ScriptDockerNayraTrait.php index 8465234fd2..050c99eff2 100644 --- a/ProcessMaker/Models/ScriptDockerNayraTrait.php +++ b/ProcessMaker/Models/ScriptDockerNayraTrait.php @@ -75,28 +75,37 @@ public function handleNayraDocker(string $code, array $data, array $config, $tim private function getNayraInstanceUrl() { if (config('app.nayra_rest_api_host')) { - return $this->normalizeNayraUrl(config('app.nayra_rest_api_host')); + $endpoint = $this->normalizeNayraUrl(config('app.nayra_rest_api_host')); + Log::warning('Nayra endpoint selected from config', self::nayraRuntimeContext($endpoint, 'config')); + return $endpoint; } if ($endpoint = self::getNayraEndpoint()) { + Log::warning('Nayra endpoint selected from cache', self::nayraRuntimeContext($endpoint, 'cache')); return $endpoint; } - return $this->buildNayraEndpoint(); + $endpoint = $this->buildNayraEndpoint(); + Log::warning('Nayra endpoint selected from fallback', self::nayraRuntimeContext($endpoint, 'fallback')); + return $endpoint; } private function resolveNayraBaseUrl() { if (config('app.nayra_rest_api_host')) { - return $this->normalizeNayraUrl(config('app.nayra_rest_api_host')); + $endpoint = $this->normalizeNayraUrl(config('app.nayra_rest_api_host')); + Log::warning('Nayra runtime endpoint resolved from config', self::nayraRuntimeContext($endpoint, 'config')); + return $endpoint; } $endpoint = self::getNayraEndpoint(); if ($endpoint) { if ($this->isNayraServiceReachable($endpoint)) { + Log::warning('Cached Nayra endpoint is reachable', self::nayraRuntimeContext($endpoint, 'cache')); return $endpoint; } + Log::warning('Cached Nayra endpoint is not reachable; clearing cache', self::nayraRuntimeContext($endpoint, 'cache')); self::clearNayraEndpoint(); } @@ -126,13 +135,16 @@ private function getDockerLogs($instanceName) private function ensureNayraServerIsRunning(string $url): string { if ($this->isNayraServiceReachable($url)) { + Log::warning('Nayra endpoint readiness confirmed before script execution', self::nayraRuntimeContext($url, 'selected')); return $url; } if (config('app.nayra_rest_api_host')) { + Log::warning('Configured Nayra endpoint is not reachable', self::nayraRuntimeContext($url, 'config')); throw new ScriptException('Could not connect to the configured Nayra REST API host: ' . $url); } + Log::warning('Nayra endpoint is not reachable; rebuilding container endpoint', self::nayraRuntimeContext($url, 'selected')); self::clearNayraEndpoint(); $this->bringUpNayra(true); @@ -152,15 +164,18 @@ private function bringUpNayra($restart = false) $docker = Docker::command(); $instanceName = self::getNayraContainerName(); $endpoint = $this->buildNayraEndpoint(); + Log::warning('Preparing Nayra runtime endpoint', self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName)); if (!$restart && $this->isNayraServiceReachable($endpoint)) { self::setNayraEndpoint($endpoint); + Log::warning('Nayra container startup skipped because endpoint is reachable', self::nayraRuntimeContext($endpoint, 'fallback', $instanceName)); return; } $image = $this->getNayraDockerImage($docker); $portMapping = $this->getNayraDockerPortMapping(); $network = config('app.nayra_docker_network'); + Log::warning('Starting Nayra Docker container', self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName)); $output = []; exec($docker . " stop {$instanceName}_nayra 2>&1 || true", $output); @@ -180,13 +195,16 @@ private function bringUpNayra($restart = false) ); if ($status) { Log::error('Error starting Nayra Docker', [ - 'output' => $output, 'status' => $status, + 'output' => self::sanitizeNayraDockerOutput($output), + 'endpointCandidate' => $endpoint, + 'context' => self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName), ]); throw new ScriptException('Error starting Nayra Docker'); } $endpoint = self::buildNayraEndpointAfterStartup($docker, $instanceName, $this->schema); + Log::warning('Nayra endpoint selected after Docker startup', self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName)); $this->cacheNayraEndpointAfterReadiness($endpoint); } @@ -194,6 +212,7 @@ private function cacheNayraEndpointAfterReadiness(string $endpoint): void { $this->nayraServiceIsRunning($endpoint); self::setNayraEndpoint($endpoint); + Log::warning('Nayra endpoint cached after readiness check', self::nayraRuntimeContext($endpoint, 'rebuild')); } private function bringUpNayraContainer() @@ -322,9 +341,15 @@ private function nayraServiceIsRunning($url): bool } $status = $this->getHeaders($url); if ($status) { + Log::warning('Nayra readiness check succeeded', self::nayraRuntimeContext($url, 'readiness') + [ + 'attempt' => $i + 1, + ]); return true; } } + Log::warning('Nayra readiness check failed', self::nayraRuntimeContext($url, 'readiness') + [ + 'attempts' => static::getNayraEndpointReadinessAttempts(), + ]); throw new ScriptException('Could not connect to the nayra container'); } @@ -384,7 +409,7 @@ private static function buildNayraEndpointAfterStartup( string $schema = 'http', int $addressAttempts = 30 ): string { - if (self::shouldUseContainerHostNetworkEndpoint()) { + if (self::shouldUseLocalContainerNetworkEndpoint()) { $host = self::resolveNayraContainerAddress($docker, $instanceName, $addressAttempts); if ($host) { return self::buildNayraEndpointUrl($schema, $host); @@ -394,12 +419,36 @@ private static function buildNayraEndpointAfterStartup( return self::buildNayraEndpointUrl($schema); } - private static function shouldUseContainerHostNetworkEndpoint(): bool + private static function shouldUseLocalContainerNetworkEndpoint(): bool { - return config('app.nayra_docker_network') === 'host' + return (bool) config('app.nayra_docker_network') && !config('app.processmaker_scripts_docker_host'); } + private static function nayraRuntimeContext( + ?string $endpoint = null, + ?string $source = null, + ?string $instanceName = null + ): array { + return [ + 'endpoint' => $endpoint, + 'source' => $source, + 'dockerHost' => config('app.processmaker_scripts_docker_host') ?: null, + 'nayraDockerNetwork' => config('app.nayra_docker_network') ?: null, + 'nayraPort' => self::getNayraPortValue(), + 'containerName' => ($instanceName ?? self::getNayraContainerName()) . '_nayra', + 'multitenancy' => (bool) config('app.multitenancy'), + ]; + } + + private static function sanitizeNayraDockerOutput(array $output): array + { + return array_map( + static fn ($line) => substr((string) $line, 0, 500), + array_slice($output, -10) + ); + } + private static function getNayraEndpointHost(): string { $dockerHost = config('app.processmaker_scripts_docker_host'); diff --git a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php index 02a54843f7..8758a841f9 100644 --- a/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php +++ b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php @@ -17,6 +17,8 @@ class ScriptDockerNayraTraitFunctionState public const HOST_NETWORK_NAYRA_HOST = 'http://' . self::HOST_NETWORK_IP . ':8081'; + public const REMOTE_DOCKER_HOST = 'tcp://qa-remotedocker:2375'; + public const NAYRA_TEST_IMAGE = 'processmaker4/nayra:test'; public const OK_HEADER = 'HTTP/1.1 200 OK'; @@ -316,7 +318,7 @@ public function testRemoteDockerHostIsUsedForFallbackNayraEndpoint() { config([ 'app.nayra_port' => '', - 'app.processmaker_scripts_docker_host' => 'tcp://qa-remotedocker:2375', + 'app.processmaker_scripts_docker_host' => ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST, ]); $runner = new ScriptDockerNayraTraitTestHarness(); @@ -324,6 +326,59 @@ public function testRemoteDockerHostIsUsedForFallbackNayraEndpoint() $this->assertSame('http://qa-remotedocker:8080', $runner->exposedGetNayraInstanceUrl()); } + public function testLocalDockerNetworkNayraEndpointUsesDockerReportedAddressAfterStartup() + { + config([ + 'app.nayra_docker_network' => 'pm4-tools_default', + 'app.nayra_port' => '', + ]); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + 'http://' . ScriptDockerNayraTraitFunctionState::HOST_NETWORK_IP . ':8080', + $runner->exposedBuildNayraEndpointAfterStartup('docker', 'processmaker', 'http', 1) + ); + $this->assertCount(1, ScriptDockerNayraTraitFunctionState::$execCommands); + $this->assertStringContainsString( + "inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' processmaker_nayra", + ScriptDockerNayraTraitFunctionState::$execCommands[0] + ); + } + + public function testLocalDockerNetworkNayraEndpointDoesNotOverrideRemoteDockerHost() + { + config([ + 'app.nayra_docker_network' => 'pm4-tools_default', + 'app.nayra_port' => '', + 'app.processmaker_scripts_docker_host' => ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST, + ]); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + 'http://qa-remotedocker:8080', + $runner->exposedBuildNayraEndpointAfterStartup( + 'DOCKER_HOST=' . ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST . ' docker', + 'processmaker', + 'http', + 1 + ) + ); + $this->assertSame([], ScriptDockerNayraTraitFunctionState::$execCommands); + } + + public function testEmptyDockerNetworkNayraEndpointUsesLocalhostAfterStartup() + { + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + $runner->exposedBuildNayraEndpointAfterStartup('docker', 'processmaker', 'http', 1) + ); + $this->assertSame([], ScriptDockerNayraTraitFunctionState::$execCommands); + } + public function testLocalHostNetworkNayraEndpointUsesDockerReportedAddressAfterStartup() { config(['app.nayra_docker_network' => 'host']); @@ -345,14 +400,19 @@ public function testLocalHostNetworkNayraEndpointDoesNotOverrideRemoteDockerHost { config([ 'app.nayra_docker_network' => 'host', - 'app.processmaker_scripts_docker_host' => 'tcp://qa-remotedocker:2375', + 'app.processmaker_scripts_docker_host' => ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST, ]); $runner = new ScriptDockerNayraTraitTestHarness(); $this->assertSame( 'http://qa-remotedocker:8081', - $runner->exposedBuildNayraEndpointAfterStartup('DOCKER_HOST=tcp://qa-remotedocker:2375 docker', 'processmaker', 'http', 1) + $runner->exposedBuildNayraEndpointAfterStartup( + 'DOCKER_HOST=' . ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST . ' docker', + 'processmaker', + 'http', + 1 + ) ); $this->assertSame([], ScriptDockerNayraTraitFunctionState::$execCommands); }