diff --git a/ProcessMaker/Models/ScriptDockerNayraTrait.php b/ProcessMaker/Models/ScriptDockerNayraTrait.php index 8a2aa83625..050c99eff2 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,25 +44,20 @@ 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->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; @@ -80,8 +74,44 @@ public function handleNayraDocker(string $code, array $data, array $config, $tim private function getNayraInstanceUrl() { - $servers = self::getNayraAddresses(); - return $this->schema . '://' . $servers[0] . ':' . $this->getNayraPort(); + if (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; + } + + $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')) { + $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(); + } + + $this->bringUpNayra(); + + return $this->getNayraInstanceUrl(); } private function getDockerLogs($instanceName) @@ -99,15 +129,29 @@ 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) { - $this->bringUpNayra(true); + 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); + + $url = $this->getNayraInstanceUrl(); + $this->nayraServiceIsRunning($url); + + return $url; } /** @@ -118,44 +162,57 @@ 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(); + 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->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'); + Log::warning('Starting Nayra Docker container', self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName)); + + $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', [ + 'status' => $status, + 'output' => self::sanitizeNayraDockerOutput($output), + 'endpointCandidate' => $endpoint, + 'context' => self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName), + ]); + throw new ScriptException('Error starting Nayra Docker'); } - $this->waitContainerNetwork($docker, $instanceName); - $url = $this->getNayraInstanceUrl(); - $this->nayraServiceIsRunning($url); + + $endpoint = self::buildNayraEndpointAfterStartup($docker, $instanceName, $this->schema); + Log::warning('Nayra endpoint selected after Docker startup', self::nayraRuntimeContext($endpoint, 'rebuild', $instanceName)); + $this->cacheNayraEndpointAfterReadiness($endpoint); + } + + 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() @@ -164,6 +221,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. * @@ -185,6 +276,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'); @@ -193,15 +289,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}}'" @@ -217,11 +315,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); } /** @@ -232,18 +335,204 @@ 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); } - $status = @get_headers($url); + $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'); } + private function isNayraServiceReachable(string $url): bool + { + return (bool) $this->getHeaders($url); + } + + protected function getHeaders(string $url): array|false + { + return static::getNayraEndpointHeaders($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 $host = null): string + { + return $schema . '://' . ($host ?? self::getNayraEndpointHost()) . ':' . self::getNayraPortValue(); + } + + private static function buildNayraEndpointAfterStartup( + string $docker, + string $instanceName, + string $schema = 'http', + int $addressAttempts = 30 + ): string { + if (self::shouldUseLocalContainerNetworkEndpoint()) { + $host = self::resolveNayraContainerAddress($docker, $instanceName, $addressAttempts); + if ($host) { + return self::buildNayraEndpointUrl($schema, $host); + } + } + + return self::buildNayraEndpointUrl($schema); + } + + private static function shouldUseLocalContainerNetworkEndpoint(): bool + { + 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'); + 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 @@ -285,27 +574,53 @@ 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::buildNayraEndpointAfterStartup($docker, $instanceName); + 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; } /** @@ -314,6 +629,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); @@ -327,6 +643,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 new file mode 100644 index 0000000000..8758a841f9 --- /dev/null +++ b/tests/unit/ProcessMaker/Models/ScriptDockerNayraTraitTest.php @@ -0,0 +1,620 @@ +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 + { + 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 static function execDockerCommand(string $command, array &$output, int &$status): string|false + { + ScriptDockerNayraTraitFunctionState::$execCommands[] = $command; + $status = ScriptDockerNayraTraitFunctionState::$containerAddressStatus; + $output = $status ? [] : [ScriptDockerNayraTraitFunctionState::$containerAddress]; + + return $status ? false : ScriptDockerNayraTraitFunctionState::$containerAddress; + } + + protected function curlInit(string $url): object + { + ScriptDockerNayraTraitFunctionState::$curlUrl = $url; + + return (object) ['url' => $url]; + } + + protected function curlSetOpt(mixed $handle, int $option, mixed $value): bool + { + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; + ScriptDockerNayraTraitFunctionState::$curlOptions[$option] = $value; + + return true; + } + + protected function curlExec(mixed $handle): string|bool + { + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; + + return ScriptDockerNayraTraitFunctionState::$curlResult; + } + + protected function curlGetInfo(mixed $handle, int $option): mixed + { + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; + + if ($option === CURLINFO_HTTP_CODE) { + return ScriptDockerNayraTraitFunctionState::$curlHttpStatus; + } + + return ['http_code' => ScriptDockerNayraTraitFunctionState::$curlHttpStatus]; + } + + protected function curlClose(mixed $handle): void + { + ScriptDockerNayraTraitFunctionState::$curlHandles[__FUNCTION__][] = $handle; + ScriptDockerNayraTraitFunctionState::$curlClosed = true; + } +} + +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 + { + parent::setUp(); + + 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' => '', + ]); + } + + protected function tearDown(): void + { + ScriptDockerNayraTraitFunctionState::reset(); + ScriptDockerNayraTraitTestHarness::clearNayraAddresses(); + ScriptDockerNayraTraitTestHarness::clearNayraEndpoint(); + app()->forgetInstance('currentTenant'); + + parent::tearDown(); + } + + public function testHandleNayraDockerUsesConfiguredRestApiHostWithoutStartingDocker() + { + config(['app.nayra_rest_api_host' => ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST]); + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + $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' => ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST]); + ScriptDockerNayraTraitTestHarness::setNayraEndpoint('http://127.0.0.1:9090'); + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + $runner->handleNayraDocker(ScriptDockerNayraTraitFunctionState::SCRIPT_CODE, [], [], 30, []); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST . '/run_script', + ScriptDockerNayraTraitFunctionState::$curlUrl + ); + $this->assertSame(0, $runner->bringUpNayraCalls); + } + + public function testNayraBaseUrlFallsBackToReachableCachedEndpointWithoutRestApiHost() + { + ScriptDockerNayraTraitTestHarness::setNayraEndpoint(ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST); + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => [ + ScriptDockerNayraTraitFunctionState::OK_HEADER, + ], + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + $runner->exposedResolveNayraBaseUrl() + ); + $this->assertSame(0, $runner->bringUpNayraCalls); + } + + public function testNayraBaseUrlStartsDockerBeforeResolvingUrlWhenNoRestApiHostOrCachedAddressExists() + { + $runner = new ScriptDockerNayraTraitTestHarness(); + + $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' => ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST, + ]); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $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']); + + $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' => ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST, + ]); + + $runner = new ScriptDockerNayraTraitTestHarness(); + + $this->assertSame( + 'http://qa-remotedocker:8081', + $runner->exposedBuildNayraEndpointAfterStartup( + 'DOCKER_HOST=' . ScriptDockerNayraTraitFunctionState::REMOTE_DOCKER_HOST . ' docker', + 'processmaker', + 'http', + 1 + ) + ); + $this->assertSame([], ScriptDockerNayraTraitFunctionState::$execCommands); + } + + 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' => ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST]); + ScriptDockerNayraTraitFunctionState::$headers = [ + ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST => false, + ]; + + $runner = new ScriptDockerNayraTraitTestHarness(); + + try { + $runner->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: ' + . ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST, + $exception->getMessage() + ); + } + + $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, + ScriptDockerNayraTraitFunctionState::NAYRA_TEST_IMAGE + ); + + $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, + 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()); + } + + $this->assertNull(ScriptDockerNayraTraitTestHarness::getNayraEndpoint()); + $this->assertSame( + [ScriptDockerNayraTraitFunctionState::LOCAL_NAYRA_HOST], + ScriptDockerNayraTraitFunctionState::$requestedHeaders + ); + $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 + ); + } +}