From d394da115027a2c369ccdc3d16d5e2df384a905c Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 15:30:14 +0400 Subject: [PATCH 1/8] feat: add node_modules build cache --- .env | 1 + README.md | 4 ++ app/controllers.php | 9 ++++- docker-compose.yml | 1 + src/Executor/Runner/Adapter.php | 1 + src/Executor/Runner/Docker.php | 71 +++++++++++++++++++++++++++++++++ tests/e2e/ExecutorTest.php | 68 +++++++++++++++++++++++++++++++ 7 files changed, 153 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 4dc03b3f..5cdb423b 100644 --- a/.env +++ b/.env @@ -11,3 +11,4 @@ OPR_EXECUTOR_DOCKER_HUB_PASSWORD= OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v5 OPR_EXECUTOR_RETRY_ATTEMPTS=5 OPR_EXECUTOR_RETRY_DELAY_MS=500 +OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME=openruntimes-node-modules-cache diff --git a/README.md b/README.md index cc451d65..bb70cf2b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ services: - OPR_EXECUTOR_RETRY_ATTEMPTS - OPR_EXECUTOR_RETRY_DELAY_MS - OPR_EXECUTOR_IMAGE_PULL + - OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME networks: openruntimes-runtimes: @@ -88,6 +89,7 @@ OPR_EXECUTOR_DOCKER_HUB_PASSWORD= OPR_EXECUTOR_RUNTIME_VERSIONS=v5 OPR_EXECUTOR_RETRY_ATTEMPTS=5 OPR_EXECUTOR_RETRY_DELAY_MS=500 +OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME=openruntimes-node-modules-cache ``` > `OPR_EXECUTOR_CONNECTION_STORAGE` takes a DSN string that represents a connection to your storage device. If you would like to use your local filesystem, you can use `file://localhost`. If using S3 or any other provider for storage, use a DSN of the following format `s3://access_key:access_secret@host:port/bucket_name?region=us-east-1` @@ -152,6 +154,7 @@ docker compose down | `variables` | `json` | Environment variables passed into runtime | | [ ] | | `runtimeEntrypoint` | `string` | Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | | `command` | `string` | Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | +| `cacheKey` | `string` | Optional key for sharing cached `node_modules` between builds. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | | `timeout` | `integer` | Commands execution time in seconds | | 600 | | `remove` | `boolean` | Remove a runtime after execution | | false | | `cpus` | `float` | Maximum CPU cores runtime can utilize | | 1 | @@ -197,6 +200,7 @@ docker compose down | OPR_EXECUTOR_RUNTIME_VERSIONS | Version tag for runtime environments, ex: `v5` | | OPR_EXECUTOR_RETRY_ATTEMPTS | Number of retry attempts for failed executions, ex: `5` | | OPR_EXECUTOR_RETRY_DELAY_MS | Delay (in milliseconds) between retry attempts, ex: `500` | +| OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME | Docker volume name used to store cached `node_modules` by `cacheKey`, ex: `openruntimes-node-modules-cache` | ## Contributing diff --git a/app/controllers.php b/app/controllers.php index 5fc619eb..9e2c24b9 100644 --- a/app/controllers.php +++ b/app/controllers.php @@ -60,6 +60,7 @@ ->param('variables', [], new Assoc(), 'Environment variables passed into runtime.', true) ->param('runtimeEntrypoint', '', new Text(1024, 0), 'Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long.', true) ->param('command', '', new Text(1024, 0), 'Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long.', true) + ->param('cacheKey', '', new Text(128, 0), 'Cache key used for node_modules build cache.', true) ->param('timeout', 600, new Integer(), 'Commands execution time in seconds.', true) ->param('remove', false, new Boolean(), 'Remove a runtime after execution.', true) ->param('cpus', 1, new FloatValidator(true), 'Container CPU.', true) @@ -68,9 +69,13 @@ ->param('restartPolicy', DockerAPI::RESTART_NO, new WhiteList([DockerAPI::RESTART_NO, DockerAPI::RESTART_ALWAYS, DockerAPI::RESTART_ON_FAILURE, DockerAPI::RESTART_UNLESS_STOPPED], true), 'Define restart policy for the runtime once an exit code is returned. Default value is "no". Possible values are "no", "always", "on-failure", "unless-stopped".', true) ->inject('response') ->inject('runner') - ->action(function (string $runtimeId, string $image, string $entrypoint, string $source, string $destination, string $outputDirectory, array $variables, string $runtimeEntrypoint, string $command, int $timeout, bool $remove, float $cpus, int $memory, string $version, string $restartPolicy, Response $response, Runner $runner): void { + ->action(function (string $runtimeId, string $image, string $entrypoint, string $source, string $destination, string $outputDirectory, array $variables, string $runtimeEntrypoint, string $command, string $cacheKey, int $timeout, bool $remove, float $cpus, int $memory, string $version, string $restartPolicy, Response $response, Runner $runner): void { $secret = \bin2hex(\random_bytes(16)); + if ($cacheKey !== '' && $cacheKey !== '0' && !\preg_match('/^[A-Za-z0-9._-]{1,128}$/', $cacheKey)) { + throw new Exception(Exception::EXECUTION_BAD_REQUEST, 'Cache key may only contain letters, numbers, dots, underscores, and hyphens.'); + } + /** * Create container */ @@ -102,7 +107,7 @@ $variables = array_map(strval(...), $variables); - $container = $runner->createRuntime($runtimeId, $secret, $image, $entrypoint, $source, $destination, $variables, $runtimeEntrypoint, $command, $timeout, $remove, $cpus, $memory, $version, $restartPolicy); + $container = $runner->createRuntime($runtimeId, $secret, $image, $entrypoint, $source, $destination, $variables, $runtimeEntrypoint, $command, $timeout, $remove, $cpus, $memory, $version, $restartPolicy, $cacheKey); $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($container); }); diff --git a/docker-compose.yml b/docker-compose.yml index 3c4f2b55..05360925 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - OPR_EXECUTOR_RETRY_ATTEMPTS - OPR_EXECUTOR_RETRY_DELAY_MS - OPR_EXECUTOR_IMAGE_PULL + - OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME volumes: openruntimes-builds: diff --git a/src/Executor/Runner/Adapter.php b/src/Executor/Runner/Adapter.php index 974d936b..c7bac752 100644 --- a/src/Executor/Runner/Adapter.php +++ b/src/Executor/Runner/Adapter.php @@ -31,6 +31,7 @@ abstract public function createRuntime( int $memory, string $version, string $restartPolicy, + string $cacheKey = '', string $region = '', ): mixed; diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index 0c7dc9bf..861d0ce0 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -230,6 +230,7 @@ public function createRuntime( int $memory, string $version, string $restartPolicy, + string $cacheKey = '', string $region = '', ): mixed { $runtimeName = System::getHostname() . '-' . $runtimeId; @@ -318,6 +319,12 @@ public function createRuntime( \dirname($tmpBuild) . ':' . $codeMountPath . ':rw', ]; + if ($cacheKey !== '' && $cacheKey !== '0' && $command !== '' && $command !== '0') { + $cacheVolume = System::getEnv('OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME', 'openruntimes-node-modules-cache'); + $volumes[] = $cacheVolume . ':/cache:rw'; + $command = $this->withNodeModulesCache($command, $cacheKey); + } + if ($version === 'v5') { $volumes[] = \dirname($tmpLogs . '/logs') . ':/mnt/logs:rw'; $volumes[] = \dirname($tmpLogging . '/logging') . ':/tmp/logging:rw'; @@ -504,6 +511,70 @@ public function createRuntime( return $container; } + private function withNodeModulesCache(string $command, string $cacheKey): string + { + $cacheKey = \escapeshellarg($cacheKey); + + $script = <<<'SH' +mkdir -p /tmp/open-runtimes-cache && cat > /tmp/open-runtimes-cache/node-modules.sh <<'OPEN_RUNTIMES_CACHE' +if [ "${PWD:-}" != "/usr/local/build" ] || [ -n "${OPEN_RUNTIMES_NODE_MODULES_CACHE_ACTIVE:-}" ]; then + return 0 2>/dev/null || true +fi + +set -e + +export OPEN_RUNTIMES_NODE_MODULES_CACHE_ACTIVE=1 + +CACHE_KEY=${OPEN_RUNTIMES_NODE_MODULES_CACHE_KEY:?} +CACHE_PATH="/cache/$CACHE_KEY" +CACHE_NODE_MODULES="$CACHE_PATH/node_modules" +LOCK_ROOT="/cache/.locks" +LOCK_PATH="$LOCK_ROOT/$CACHE_KEY" + +mkdir -p "$LOCK_ROOT" +while ! mkdir "$LOCK_PATH" 2>/dev/null; do + echo "Waiting for node_modules cache lock: $CACHE_KEY" + sleep 1 +done + +cleanup_node_modules_cache() { + status=$? + set +e + + if [ -L node_modules ] && [ -d "$CACHE_NODE_MODULES" ]; then + rm -rf /tmp/open-runtimes-node_modules-copy + cp -a "$CACHE_NODE_MODULES" /tmp/open-runtimes-node_modules-copy + rm -f node_modules + mv /tmp/open-runtimes-node_modules-copy node_modules + fi + + if [ "$status" -eq 0 ]; then + date -u +"%Y-%m-%dT%H:%M:%SZ" > "$CACHE_PATH/.cache-ready" + fi + + rmdir "$LOCK_PATH" 2>/dev/null || true + return "$status" +} + +trap cleanup_node_modules_cache EXIT + +mkdir -p "$CACHE_NODE_MODULES" +if [ -f "$CACHE_PATH/.cache-ready" ]; then + echo "node_modules cache hit: $CACHE_KEY" +else + echo "node_modules cache miss: $CACHE_KEY" +fi + +rm -rf node_modules +ln -s "$CACHE_NODE_MODULES" node_modules +OPEN_RUNTIMES_CACHE +export OPEN_RUNTIMES_NODE_MODULES_CACHE_KEY=__CACHE_KEY__ +export BASH_ENV=/tmp/open-runtimes-cache/node-modules.sh +SH; + + return \str_replace('__CACHE_KEY__', $cacheKey, $script) . ' && ' . $command; + } + public function deleteRuntime(string $runtimeId): void { $runtimeName = System::getHostname() . '-' . $runtimeId; diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index 7d748efb..31e0ea85 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -362,6 +362,74 @@ public function testBuildUncompressed(): void $this->assertEquals(200, $response['headers']['status-code']); } + public function testBuildNodeModulesCache(): void + { + $output = ''; + $stderr = ''; + Console::execute('cd /app/tests/resources/functions/node && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr); + + $runtimeId = \bin2hex(\random_bytes(4)); + $cacheKey = 'test-node-modules-' . $runtimeId; + + $params = [ + 'runtimeId' => 'test-build-cache-miss-' . $runtimeId, + 'source' => '/storage/functions/node/code.tar.gz', + 'destination' => '/storage/builds/test-cache-miss', + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test ! -f node_modules/.open-runtimes-cache-test && touch node_modules/.open-runtimes-cache-test"', + 'cacheKey' => $cacheKey, + 'remove' => true, + ]; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); + $this->assertEquals(201, $response['headers']['status-code']); + + $firstBuildOutput = ''; + foreach ($response['body']['output'] as $outputItem) { + $firstBuildOutput .= $outputItem['content']; + } + + $this->assertStringContainsString('node_modules cache miss: ' . $cacheKey, $firstBuildOutput); + + $params = [ + 'runtimeId' => 'test-build-cache-hit-' . $runtimeId, + 'source' => '/storage/functions/node/code.tar.gz', + 'destination' => '/storage/builds/test-cache-hit', + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -f node_modules/.open-runtimes-cache-test"', + 'cacheKey' => $cacheKey, + 'remove' => true, + ]; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); + $this->assertEquals(201, $response['headers']['status-code']); + + $secondBuildOutput = ''; + foreach ($response['body']['output'] as $outputItem) { + $secondBuildOutput .= $outputItem['content']; + } + + $this->assertStringContainsString('node_modules cache hit: ' . $cacheKey, $secondBuildOutput); + $this->assertNotEmpty($response['body']['path']); + + $buildPath = $response['body']['path']; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes/test-exec-cache-' . $runtimeId . '/executions', [], [ + 'source' => $buildPath, + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "bash helpers/server.sh"', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(200, $response['body']['statusCode']); + + $response = $this->client->call(Client::METHOD_DELETE, '/runtimes/test-exec-cache-' . $runtimeId, [], []); + $this->assertEquals(200, $response['headers']['status-code']); + } + public function testExecute(): void { /** Prepare function */ From c2808dbdb3a293e7f2bacce5fc63f05dbd192400 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 16:03:45 +0400 Subject: [PATCH 2/8] fix: resolve executor container by configured name --- app/http.php | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/app/http.php b/app/http.php index a93979ce..b006fd6b 100644 --- a/app/http.php +++ b/app/http.php @@ -37,30 +37,38 @@ /** @var Container $container */ global $container; - /* Fetch own container information */ - $hostname = gethostname() ?: throw new \RuntimeException('Could not determine hostname'); - $selfContainer = $orchestration->list(['name' => $hostname])[0] ?? throw new \RuntimeException('Own container not found'); + try { + /* Fetch own container information */ + $hostname = gethostname() ?: throw new \RuntimeException('Could not determine hostname'); + $containerName = System::getEnv('OPR_EXECUTOR_CONTAINER_NAME', 'openruntimes-executor'); + $selfContainer = $orchestration->list(['name' => $hostname])[0] + ?? $orchestration->list(['name' => $containerName])[0] + ?? throw new \RuntimeException('Own container not found'); - /* Create desired networks if they don't exist */ - $network->setup( - explode(',', System::getEnv('OPR_EXECUTOR_NETWORK') ?: 'openruntimes-runtimes'), - $selfContainer->getName() - ); - $container->set( - 'networks', - $network->getAvailable(...) - ); + /* Create desired networks if they don't exist */ + $network->setup( + explode(',', System::getEnv('OPR_EXECUTOR_NETWORK') ?: 'openruntimes-runtimes'), + $selfContainer->getName() + ); + $container->set( + 'networks', + $network->getAvailable(...) + ); - /* Pull images */ - $imagePuller->pull(explode(',', System::getEnv('OPR_EXECUTOR_IMAGES') ?: '')); + /* Pull images */ + $imagePuller->pull(explode(',', System::getEnv('OPR_EXECUTOR_IMAGES') ?: '')); - /* Start maintenance task */ - $maintenance->start( - (int)System::getEnv('OPR_EXECUTOR_MAINTENANCE_INTERVAL', '3600'), - (int)System::getEnv('OPR_EXECUTOR_INACTIVE_THRESHOLD', '60') - ); + /* Start maintenance task */ + $maintenance->start( + (int)System::getEnv('OPR_EXECUTOR_MAINTENANCE_INTERVAL', '3600'), + (int)System::getEnv('OPR_EXECUTOR_INACTIVE_THRESHOLD', '60') + ); - Console::success('Executor is ready.'); + Console::success('Executor is ready.'); + } catch (\Throwable $throwable) { + Console::error('[Executor] Startup failed: ' . $throwable->getMessage()); + throw $throwable; + } }); Http::onRequest() From 3911981064d1d39916cdd39463d06afeebe2fc5e Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 17:22:17 +0400 Subject: [PATCH 3/8] fix: use package manager build cache --- .gitignore | 1 + README.md | 4 +- app/http.php | 48 ++++++++------------ src/Executor/Runner/Docker.php | 83 +++++++++------------------------- tests/e2e/ExecutorTest.php | 13 ++---- 5 files changed, 49 insertions(+), 100 deletions(-) diff --git a/.gitignore b/.gitignore index 68b1d3e9..74188708 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /tests/resources/functions/**/code.zip /tests/resources/sites/**/code.tar.gz /tests/resources/sites/**/code.zip +.DS_Store diff --git a/README.md b/README.md index bb70cf2b..63612f82 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ docker compose down | `variables` | `json` | Environment variables passed into runtime | | [ ] | | `runtimeEntrypoint` | `string` | Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | | `command` | `string` | Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | -| `cacheKey` | `string` | Optional key for sharing cached `node_modules` between builds. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | +| `cacheKey` | `string` | Optional key for sharing package manager caches between Node builds. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | | `timeout` | `integer` | Commands execution time in seconds | | 600 | | `remove` | `boolean` | Remove a runtime after execution | | false | | `cpus` | `float` | Maximum CPU cores runtime can utilize | | 1 | @@ -200,7 +200,7 @@ docker compose down | OPR_EXECUTOR_RUNTIME_VERSIONS | Version tag for runtime environments, ex: `v5` | | OPR_EXECUTOR_RETRY_ATTEMPTS | Number of retry attempts for failed executions, ex: `5` | | OPR_EXECUTOR_RETRY_DELAY_MS | Delay (in milliseconds) between retry attempts, ex: `500` | -| OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME | Docker volume name used to store cached `node_modules` by `cacheKey`, ex: `openruntimes-node-modules-cache` | +| OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME | Docker volume name used to store package manager caches by `cacheKey`, ex: `openruntimes-node-modules-cache` | ## Contributing diff --git a/app/http.php b/app/http.php index b006fd6b..a93979ce 100644 --- a/app/http.php +++ b/app/http.php @@ -37,38 +37,30 @@ /** @var Container $container */ global $container; - try { - /* Fetch own container information */ - $hostname = gethostname() ?: throw new \RuntimeException('Could not determine hostname'); - $containerName = System::getEnv('OPR_EXECUTOR_CONTAINER_NAME', 'openruntimes-executor'); - $selfContainer = $orchestration->list(['name' => $hostname])[0] - ?? $orchestration->list(['name' => $containerName])[0] - ?? throw new \RuntimeException('Own container not found'); + /* Fetch own container information */ + $hostname = gethostname() ?: throw new \RuntimeException('Could not determine hostname'); + $selfContainer = $orchestration->list(['name' => $hostname])[0] ?? throw new \RuntimeException('Own container not found'); - /* Create desired networks if they don't exist */ - $network->setup( - explode(',', System::getEnv('OPR_EXECUTOR_NETWORK') ?: 'openruntimes-runtimes'), - $selfContainer->getName() - ); - $container->set( - 'networks', - $network->getAvailable(...) - ); + /* Create desired networks if they don't exist */ + $network->setup( + explode(',', System::getEnv('OPR_EXECUTOR_NETWORK') ?: 'openruntimes-runtimes'), + $selfContainer->getName() + ); + $container->set( + 'networks', + $network->getAvailable(...) + ); - /* Pull images */ - $imagePuller->pull(explode(',', System::getEnv('OPR_EXECUTOR_IMAGES') ?: '')); + /* Pull images */ + $imagePuller->pull(explode(',', System::getEnv('OPR_EXECUTOR_IMAGES') ?: '')); - /* Start maintenance task */ - $maintenance->start( - (int)System::getEnv('OPR_EXECUTOR_MAINTENANCE_INTERVAL', '3600'), - (int)System::getEnv('OPR_EXECUTOR_INACTIVE_THRESHOLD', '60') - ); + /* Start maintenance task */ + $maintenance->start( + (int)System::getEnv('OPR_EXECUTOR_MAINTENANCE_INTERVAL', '3600'), + (int)System::getEnv('OPR_EXECUTOR_INACTIVE_THRESHOLD', '60') + ); - Console::success('Executor is ready.'); - } catch (\Throwable $throwable) { - Console::error('[Executor] Startup failed: ' . $throwable->getMessage()); - throw $throwable; - } + Console::success('Executor is ready.'); }); Http::onRequest() diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index 861d0ce0..7ac4b295 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -319,10 +319,11 @@ public function createRuntime( \dirname($tmpBuild) . ':' . $codeMountPath . ':rw', ]; - if ($cacheKey !== '' && $cacheKey !== '0' && $command !== '' && $command !== '0') { + $cacheEnabled = $cacheKey !== '' && $cacheKey !== '0' && $command !== '' && $command !== '0'; + if ($cacheEnabled) { $cacheVolume = System::getEnv('OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME', 'openruntimes-node-modules-cache'); $volumes[] = $cacheVolume . ':/cache:rw'; - $command = $this->withNodeModulesCache($command, $cacheKey); + $variables = \array_merge($variables, $this->getPackageManagerCacheVariables($cacheKey)); } if ($version === 'v5') { @@ -351,6 +352,10 @@ public function createRuntime( throw new \Exception('Failed to create runtime'); } + if ($cacheEnabled) { + $command = $this->withPackageManagerCacheLog($command, $cacheKey); + } + /** * Execute any commands if they were provided */ @@ -511,68 +516,24 @@ public function createRuntime( return $container; } - private function withNodeModulesCache(string $command, string $cacheKey): string + private function withPackageManagerCacheLog(string $command, string $cacheKey): string { - $cacheKey = \escapeshellarg($cacheKey); - - $script = <<<'SH' -mkdir -p /tmp/open-runtimes-cache && cat > /tmp/open-runtimes-cache/node-modules.sh <<'OPEN_RUNTIMES_CACHE' -if [ "${PWD:-}" != "/usr/local/build" ] || [ -n "${OPEN_RUNTIMES_NODE_MODULES_CACHE_ACTIVE:-}" ]; then - return 0 2>/dev/null || true -fi - -set -e - -export OPEN_RUNTIMES_NODE_MODULES_CACHE_ACTIVE=1 - -CACHE_KEY=${OPEN_RUNTIMES_NODE_MODULES_CACHE_KEY:?} -CACHE_PATH="/cache/$CACHE_KEY" -CACHE_NODE_MODULES="$CACHE_PATH/node_modules" -LOCK_ROOT="/cache/.locks" -LOCK_PATH="$LOCK_ROOT/$CACHE_KEY" - -mkdir -p "$LOCK_ROOT" -while ! mkdir "$LOCK_PATH" 2>/dev/null; do - echo "Waiting for node_modules cache lock: $CACHE_KEY" - sleep 1 -done - -cleanup_node_modules_cache() { - status=$? - set +e - - if [ -L node_modules ] && [ -d "$CACHE_NODE_MODULES" ]; then - rm -rf /tmp/open-runtimes-node_modules-copy - cp -a "$CACHE_NODE_MODULES" /tmp/open-runtimes-node_modules-copy - rm -f node_modules - mv /tmp/open-runtimes-node_modules-copy node_modules - fi - - if [ "$status" -eq 0 ]; then - date -u +"%Y-%m-%dT%H:%M:%SZ" > "$CACHE_PATH/.cache-ready" - fi - - rmdir "$LOCK_PATH" 2>/dev/null || true - return "$status" -} - -trap cleanup_node_modules_cache EXIT - -mkdir -p "$CACHE_NODE_MODULES" -if [ -f "$CACHE_PATH/.cache-ready" ]; then - echo "node_modules cache hit: $CACHE_KEY" -else - echo "node_modules cache miss: $CACHE_KEY" -fi + return "printf '%s\n' '[node_modules cache] Using package manager cache.'; " . $command; + } -rm -rf node_modules -ln -s "$CACHE_NODE_MODULES" node_modules -OPEN_RUNTIMES_CACHE -export OPEN_RUNTIMES_NODE_MODULES_CACHE_KEY=__CACHE_KEY__ -export BASH_ENV=/tmp/open-runtimes-cache/node-modules.sh -SH; + /** + * @return array + */ + private function getPackageManagerCacheVariables(string $cacheKey): array + { + $cacheRoot = '/cache/' . $cacheKey; - return \str_replace('__CACHE_KEY__', $cacheKey, $script) . ' && ' . $command; + return [ + 'npm_config_cache' => $cacheRoot . '/npm', + 'YARN_CACHE_FOLDER' => $cacheRoot . '/yarn', + 'PNPM_STORE_DIR' => $cacheRoot . '/pnpm', + 'BUN_INSTALL_CACHE_DIR' => $cacheRoot . '/bun', + ]; } public function deleteRuntime(string $runtimeId): void diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index 31e0ea85..d753c94b 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -370,6 +370,7 @@ public function testBuildNodeModulesCache(): void $runtimeId = \bin2hex(\random_bytes(4)); $cacheKey = 'test-node-modules-' . $runtimeId; + $npmCache = '/cache/' . $cacheKey . '/npm'; $params = [ 'runtimeId' => 'test-build-cache-miss-' . $runtimeId, @@ -377,7 +378,7 @@ public function testBuildNodeModulesCache(): void 'destination' => '/storage/builds/test-cache-miss', 'entrypoint' => 'index.js', 'image' => 'openruntimes/node:v5-18.0', - 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test ! -f node_modules/.open-runtimes-cache-test && touch node_modules/.open-runtimes-cache-test"', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -d ' . $npmCache . ' && touch ' . $npmCache . '/.open-runtimes-cache-test"', 'cacheKey' => $cacheKey, 'remove' => true, ]; @@ -390,7 +391,7 @@ public function testBuildNodeModulesCache(): void $firstBuildOutput .= $outputItem['content']; } - $this->assertStringContainsString('node_modules cache miss: ' . $cacheKey, $firstBuildOutput); + $this->assertStringContainsString('[node_modules cache] Using package manager cache.', $firstBuildOutput); $params = [ 'runtimeId' => 'test-build-cache-hit-' . $runtimeId, @@ -398,7 +399,7 @@ public function testBuildNodeModulesCache(): void 'destination' => '/storage/builds/test-cache-hit', 'entrypoint' => 'index.js', 'image' => 'openruntimes/node:v5-18.0', - 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -f node_modules/.open-runtimes-cache-test"', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -f ' . $npmCache . '/.open-runtimes-cache-test"', 'cacheKey' => $cacheKey, 'remove' => true, ]; @@ -406,12 +407,6 @@ public function testBuildNodeModulesCache(): void $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); $this->assertEquals(201, $response['headers']['status-code']); - $secondBuildOutput = ''; - foreach ($response['body']['output'] as $outputItem) { - $secondBuildOutput .= $outputItem['content']; - } - - $this->assertStringContainsString('node_modules cache hit: ' . $cacheKey, $secondBuildOutput); $this->assertNotEmpty($response['body']['path']); $buildPath = $response['body']['path']; From ea5e3eba13f5b0a60b19ef5c7ed7388d5e69d730 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 17:36:02 +0400 Subject: [PATCH 4/8] fix: remove unused cache log parameter --- src/Executor/Runner/Docker.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index 7ac4b295..cf10318f 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -353,7 +353,7 @@ public function createRuntime( } if ($cacheEnabled) { - $command = $this->withPackageManagerCacheLog($command, $cacheKey); + $command = $this->withPackageManagerCacheLog($command); } /** @@ -516,7 +516,7 @@ public function createRuntime( return $container; } - private function withPackageManagerCacheLog(string $command, string $cacheKey): string + private function withPackageManagerCacheLog(string $command): string { return "printf '%s\n' '[node_modules cache] Using package manager cache.'; " . $command; } From df8b3dd7d1f124ab005ce973f66747a95fb559db Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 17:57:04 +0400 Subject: [PATCH 5/8] chore: generalize build cache terminology --- .env | 2 +- README.md | 8 ++++---- app/controllers.php | 2 +- docker-compose.yml | 2 +- src/Executor/Runner/Docker.php | 4 ++-- tests/e2e/ExecutorTest.php | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.env b/.env index 5cdb423b..8d3f36c9 100644 --- a/.env +++ b/.env @@ -11,4 +11,4 @@ OPR_EXECUTOR_DOCKER_HUB_PASSWORD= OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v5 OPR_EXECUTOR_RETRY_ATTEMPTS=5 OPR_EXECUTOR_RETRY_DELAY_MS=500 -OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME=openruntimes-node-modules-cache +OPR_EXECUTOR_BUILD_CACHE_VOLUME=openruntimes-build-cache diff --git a/README.md b/README.md index 63612f82..12bb303a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ services: - OPR_EXECUTOR_RETRY_ATTEMPTS - OPR_EXECUTOR_RETRY_DELAY_MS - OPR_EXECUTOR_IMAGE_PULL - - OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME + - OPR_EXECUTOR_BUILD_CACHE_VOLUME networks: openruntimes-runtimes: @@ -89,7 +89,7 @@ OPR_EXECUTOR_DOCKER_HUB_PASSWORD= OPR_EXECUTOR_RUNTIME_VERSIONS=v5 OPR_EXECUTOR_RETRY_ATTEMPTS=5 OPR_EXECUTOR_RETRY_DELAY_MS=500 -OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME=openruntimes-node-modules-cache +OPR_EXECUTOR_BUILD_CACHE_VOLUME=openruntimes-build-cache ``` > `OPR_EXECUTOR_CONNECTION_STORAGE` takes a DSN string that represents a connection to your storage device. If you would like to use your local filesystem, you can use `file://localhost`. If using S3 or any other provider for storage, use a DSN of the following format `s3://access_key:access_secret@host:port/bucket_name?region=us-east-1` @@ -154,7 +154,7 @@ docker compose down | `variables` | `json` | Environment variables passed into runtime | | [ ] | | `runtimeEntrypoint` | `string` | Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | | `command` | `string` | Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | -| `cacheKey` | `string` | Optional key for sharing package manager caches between Node builds. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | +| `cacheKey` | `string` | Optional key for sharing build caches. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | | `timeout` | `integer` | Commands execution time in seconds | | 600 | | `remove` | `boolean` | Remove a runtime after execution | | false | | `cpus` | `float` | Maximum CPU cores runtime can utilize | | 1 | @@ -200,7 +200,7 @@ docker compose down | OPR_EXECUTOR_RUNTIME_VERSIONS | Version tag for runtime environments, ex: `v5` | | OPR_EXECUTOR_RETRY_ATTEMPTS | Number of retry attempts for failed executions, ex: `5` | | OPR_EXECUTOR_RETRY_DELAY_MS | Delay (in milliseconds) between retry attempts, ex: `500` | -| OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME | Docker volume name used to store package manager caches by `cacheKey`, ex: `openruntimes-node-modules-cache` | +| OPR_EXECUTOR_BUILD_CACHE_VOLUME | Docker volume name used to store build caches by `cacheKey`, ex: `openruntimes-build-cache` | ## Contributing diff --git a/app/controllers.php b/app/controllers.php index 9e2c24b9..db4eb2a8 100644 --- a/app/controllers.php +++ b/app/controllers.php @@ -60,7 +60,7 @@ ->param('variables', [], new Assoc(), 'Environment variables passed into runtime.', true) ->param('runtimeEntrypoint', '', new Text(1024, 0), 'Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long.', true) ->param('command', '', new Text(1024, 0), 'Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long.', true) - ->param('cacheKey', '', new Text(128, 0), 'Cache key used for node_modules build cache.', true) + ->param('cacheKey', '', new Text(128, 0), 'Cache key used for build caches.', true) ->param('timeout', 600, new Integer(), 'Commands execution time in seconds.', true) ->param('remove', false, new Boolean(), 'Remove a runtime after execution.', true) ->param('cpus', 1, new FloatValidator(true), 'Container CPU.', true) diff --git a/docker-compose.yml b/docker-compose.yml index 05360925..5a933643 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: - OPR_EXECUTOR_RETRY_ATTEMPTS - OPR_EXECUTOR_RETRY_DELAY_MS - OPR_EXECUTOR_IMAGE_PULL - - OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME + - OPR_EXECUTOR_BUILD_CACHE_VOLUME volumes: openruntimes-builds: diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index cf10318f..f495dff4 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -321,7 +321,7 @@ public function createRuntime( $cacheEnabled = $cacheKey !== '' && $cacheKey !== '0' && $command !== '' && $command !== '0'; if ($cacheEnabled) { - $cacheVolume = System::getEnv('OPR_EXECUTOR_NODE_MODULES_CACHE_VOLUME', 'openruntimes-node-modules-cache'); + $cacheVolume = System::getEnv('OPR_EXECUTOR_BUILD_CACHE_VOLUME', 'openruntimes-build-cache'); $volumes[] = $cacheVolume . ':/cache:rw'; $variables = \array_merge($variables, $this->getPackageManagerCacheVariables($cacheKey)); } @@ -518,7 +518,7 @@ public function createRuntime( private function withPackageManagerCacheLog(string $command): string { - return "printf '%s\n' '[node_modules cache] Using package manager cache.'; " . $command; + return "printf '%s\n' '[build cache] Using package manager cache.'; " . $command; } /** diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index d753c94b..ca8d1694 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -362,14 +362,14 @@ public function testBuildUncompressed(): void $this->assertEquals(200, $response['headers']['status-code']); } - public function testBuildNodeModulesCache(): void + public function testBuildCache(): void { $output = ''; $stderr = ''; Console::execute('cd /app/tests/resources/functions/node && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr); $runtimeId = \bin2hex(\random_bytes(4)); - $cacheKey = 'test-node-modules-' . $runtimeId; + $cacheKey = 'test-build-cache-' . $runtimeId; $npmCache = '/cache/' . $cacheKey . '/npm'; $params = [ @@ -391,7 +391,7 @@ public function testBuildNodeModulesCache(): void $firstBuildOutput .= $outputItem['content']; } - $this->assertStringContainsString('[node_modules cache] Using package manager cache.', $firstBuildOutput); + $this->assertStringContainsString('[build cache] Using package manager cache.', $firstBuildOutput); $params = [ 'runtimeId' => 'test-build-cache-hit-' . $runtimeId, From d483c3ec5fb99965ada62f501562103a61259d9b Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 18:10:55 +0400 Subject: [PATCH 6/8] fix: address build cache review feedback --- app/controllers.php | 2 +- src/Executor/Runner/Docker.php | 4 +- tests/e2e/ExecutorTest.php | 126 ++++++++++++++++----------------- 3 files changed, 66 insertions(+), 66 deletions(-) diff --git a/app/controllers.php b/app/controllers.php index db4eb2a8..782abee0 100644 --- a/app/controllers.php +++ b/app/controllers.php @@ -72,7 +72,7 @@ ->action(function (string $runtimeId, string $image, string $entrypoint, string $source, string $destination, string $outputDirectory, array $variables, string $runtimeEntrypoint, string $command, string $cacheKey, int $timeout, bool $remove, float $cpus, int $memory, string $version, string $restartPolicy, Response $response, Runner $runner): void { $secret = \bin2hex(\random_bytes(16)); - if ($cacheKey !== '' && $cacheKey !== '0' && !\preg_match('/^[A-Za-z0-9._-]{1,128}$/', $cacheKey)) { + if ($cacheKey !== '' && !\preg_match('/^[A-Za-z0-9._-]{1,128}$/', $cacheKey)) { throw new Exception(Exception::EXECUTION_BAD_REQUEST, 'Cache key may only contain letters, numbers, dots, underscores, and hyphens.'); } diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index f495dff4..11993d4d 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -319,7 +319,7 @@ public function createRuntime( \dirname($tmpBuild) . ':' . $codeMountPath . ':rw', ]; - $cacheEnabled = $cacheKey !== '' && $cacheKey !== '0' && $command !== '' && $command !== '0'; + $cacheEnabled = $cacheKey !== '' && $command !== '' && $command !== '0'; if ($cacheEnabled) { $cacheVolume = System::getEnv('OPR_EXECUTOR_BUILD_CACHE_VOLUME', 'openruntimes-build-cache'); $volumes[] = $cacheVolume . ':/cache:rw'; @@ -531,7 +531,7 @@ private function getPackageManagerCacheVariables(string $cacheKey): array return [ 'npm_config_cache' => $cacheRoot . '/npm', 'YARN_CACHE_FOLDER' => $cacheRoot . '/yarn', - 'PNPM_STORE_DIR' => $cacheRoot . '/pnpm', + 'npm_config_store_dir' => $cacheRoot . '/pnpm', 'BUN_INSTALL_CACHE_DIR' => $cacheRoot . '/bun', ]; } diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index ca8d1694..a9741759 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -362,69 +362,6 @@ public function testBuildUncompressed(): void $this->assertEquals(200, $response['headers']['status-code']); } - public function testBuildCache(): void - { - $output = ''; - $stderr = ''; - Console::execute('cd /app/tests/resources/functions/node && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr); - - $runtimeId = \bin2hex(\random_bytes(4)); - $cacheKey = 'test-build-cache-' . $runtimeId; - $npmCache = '/cache/' . $cacheKey . '/npm'; - - $params = [ - 'runtimeId' => 'test-build-cache-miss-' . $runtimeId, - 'source' => '/storage/functions/node/code.tar.gz', - 'destination' => '/storage/builds/test-cache-miss', - 'entrypoint' => 'index.js', - 'image' => 'openruntimes/node:v5-18.0', - 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -d ' . $npmCache . ' && touch ' . $npmCache . '/.open-runtimes-cache-test"', - 'cacheKey' => $cacheKey, - 'remove' => true, - ]; - - $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); - $this->assertEquals(201, $response['headers']['status-code']); - - $firstBuildOutput = ''; - foreach ($response['body']['output'] as $outputItem) { - $firstBuildOutput .= $outputItem['content']; - } - - $this->assertStringContainsString('[build cache] Using package manager cache.', $firstBuildOutput); - - $params = [ - 'runtimeId' => 'test-build-cache-hit-' . $runtimeId, - 'source' => '/storage/functions/node/code.tar.gz', - 'destination' => '/storage/builds/test-cache-hit', - 'entrypoint' => 'index.js', - 'image' => 'openruntimes/node:v5-18.0', - 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -f ' . $npmCache . '/.open-runtimes-cache-test"', - 'cacheKey' => $cacheKey, - 'remove' => true, - ]; - - $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); - $this->assertEquals(201, $response['headers']['status-code']); - - $this->assertNotEmpty($response['body']['path']); - - $buildPath = $response['body']['path']; - - $response = $this->client->call(Client::METHOD_POST, '/runtimes/test-exec-cache-' . $runtimeId . '/executions', [], [ - 'source' => $buildPath, - 'entrypoint' => 'index.js', - 'image' => 'openruntimes/node:v5-18.0', - 'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "bash helpers/server.sh"', - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(200, $response['body']['statusCode']); - - $response = $this->client->call(Client::METHOD_DELETE, '/runtimes/test-exec-cache-' . $runtimeId, [], []); - $this->assertEquals(200, $response['headers']['status-code']); - } - public function testExecute(): void { /** Prepare function */ @@ -615,6 +552,69 @@ public function testExecute(): void $this->assertEquals(200, $response['headers']['status-code']); } + public function testBuildCache(): void + { + $output = ''; + $stderr = ''; + Console::execute('cd /app/tests/resources/functions/node && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr); + + $runtimeId = \bin2hex(\random_bytes(4)); + $cacheKey = 'test-build-cache-' . $runtimeId; + $npmCache = '/cache/' . $cacheKey . '/npm'; + + $params = [ + 'runtimeId' => 'test-build-cache-miss-' . $runtimeId, + 'source' => '/storage/functions/node/code.tar.gz', + 'destination' => '/storage/builds/test-cache-miss', + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -d ' . $npmCache . ' && touch ' . $npmCache . '/.open-runtimes-cache-test"', + 'cacheKey' => $cacheKey, + 'remove' => true, + ]; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); + $this->assertEquals(201, $response['headers']['status-code']); + + $firstBuildOutput = ''; + foreach ($response['body']['output'] as $outputItem) { + $firstBuildOutput .= $outputItem['content']; + } + + $this->assertStringContainsString('[build cache] Using package manager cache.', $firstBuildOutput); + + $params = [ + 'runtimeId' => 'test-build-cache-hit-' . $runtimeId, + 'source' => '/storage/functions/node/code.tar.gz', + 'destination' => '/storage/builds/test-cache-hit', + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -f ' . $npmCache . '/.open-runtimes-cache-test"', + 'cacheKey' => $cacheKey, + 'remove' => true, + ]; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); + $this->assertEquals(201, $response['headers']['status-code']); + + $this->assertNotEmpty($response['body']['path']); + + $buildPath = $response['body']['path']; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes/test-exec-cache-' . $runtimeId . '/executions', [], [ + 'source' => $buildPath, + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "bash helpers/server.sh"', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(200, $response['body']['statusCode']); + + $response = $this->client->call(Client::METHOD_DELETE, '/runtimes/test-exec-cache-' . $runtimeId, [], []); + $this->assertEquals(200, $response['headers']['status-code']); + } + // We also test SSR two Set-cookie here public function testSSRLogs(): void { From 2bc68f50b5b62ae624779e1b2685b18eef4bf006 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 18:32:39 +0400 Subject: [PATCH 7/8] fix: tighten build cache key validation --- README.md | 2 +- app/controllers.php | 4 ++-- tests/e2e/ExecutorTest.php | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 12bb303a..2ceaf73e 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ docker compose down | `variables` | `json` | Environment variables passed into runtime | | [ ] | | `runtimeEntrypoint` | `string` | Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | | `command` | `string` | Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | -| `cacheKey` | `string` | Optional key for sharing build caches. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | +| `cacheKey` | `string` | Optional key for sharing build caches. Must start with a letter or number. Allowed characters are letters, numbers, dots, underscores, and hyphens. | | ' ' | | `timeout` | `integer` | Commands execution time in seconds | | 600 | | `remove` | `boolean` | Remove a runtime after execution | | false | | `cpus` | `float` | Maximum CPU cores runtime can utilize | | 1 | diff --git a/app/controllers.php b/app/controllers.php index 782abee0..b2f16240 100644 --- a/app/controllers.php +++ b/app/controllers.php @@ -72,8 +72,8 @@ ->action(function (string $runtimeId, string $image, string $entrypoint, string $source, string $destination, string $outputDirectory, array $variables, string $runtimeEntrypoint, string $command, string $cacheKey, int $timeout, bool $remove, float $cpus, int $memory, string $version, string $restartPolicy, Response $response, Runner $runner): void { $secret = \bin2hex(\random_bytes(16)); - if ($cacheKey !== '' && !\preg_match('/^[A-Za-z0-9._-]{1,128}$/', $cacheKey)) { - throw new Exception(Exception::EXECUTION_BAD_REQUEST, 'Cache key may only contain letters, numbers, dots, underscores, and hyphens.'); + if ($cacheKey !== '' && !\preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/', $cacheKey)) { + throw new Exception(Exception::EXECUTION_BAD_REQUEST, 'Cache key must start with a letter or number and may only contain letters, numbers, dots, underscores, and hyphens.'); } /** diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index a9741759..a0542182 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -251,6 +251,21 @@ public function testBuild(): void $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); $this->assertEquals(400, $response['headers']['status-code']); + /** Invalid cache key */ + $params = [ + 'runtimeId' => 'test-build-fail-cache-key-' . $runtimeId, + 'source' => '/storage/functions/php/code.tar.gz', + 'destination' => '/storage/builds/test', + 'entrypoint' => 'index.php', + 'image' => 'openruntimes/php:v5-8.1', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "composer install"', + 'cacheKey' => '..', + 'remove' => true + ]; + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], $params); + $this->assertEquals(400, $response['headers']['status-code']); + /** Test invalid path */ $params = [ 'runtimeId' => 'test-build-fail-500-' . $runtimeId, From d5c67a1a9bcfc27960b0755df681285e60e4dfb1 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 19:57:25 +0400 Subject: [PATCH 8/8] fix: isolate build cache by volume subpath --- .env | 1 + README.md | 3 +++ composer.json | 4 +-- composer.lock | 47 ++++++++++++++++++---------------- docker-compose.yml | 1 + src/Executor/Runner/Docker.php | 28 +++++++++++++++++--- tests/e2e/ExecutorTest.php | 6 ++--- 7 files changed, 59 insertions(+), 31 deletions(-) diff --git a/.env b/.env index 8d3f36c9..f6ff2552 100644 --- a/.env +++ b/.env @@ -12,3 +12,4 @@ OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v5 OPR_EXECUTOR_RETRY_ATTEMPTS=5 OPR_EXECUTOR_RETRY_DELAY_MS=500 OPR_EXECUTOR_BUILD_CACHE_VOLUME=openruntimes-build-cache +OPR_EXECUTOR_BUILD_CACHE_HELPER_IMAGE=busybox:1.37 diff --git a/README.md b/README.md index 2ceaf73e..a49fe3a2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ services: - OPR_EXECUTOR_RETRY_DELAY_MS - OPR_EXECUTOR_IMAGE_PULL - OPR_EXECUTOR_BUILD_CACHE_VOLUME + - OPR_EXECUTOR_BUILD_CACHE_HELPER_IMAGE networks: openruntimes-runtimes: @@ -90,6 +91,7 @@ OPR_EXECUTOR_RUNTIME_VERSIONS=v5 OPR_EXECUTOR_RETRY_ATTEMPTS=5 OPR_EXECUTOR_RETRY_DELAY_MS=500 OPR_EXECUTOR_BUILD_CACHE_VOLUME=openruntimes-build-cache +OPR_EXECUTOR_BUILD_CACHE_HELPER_IMAGE=busybox:1.37 ``` > `OPR_EXECUTOR_CONNECTION_STORAGE` takes a DSN string that represents a connection to your storage device. If you would like to use your local filesystem, you can use `file://localhost`. If using S3 or any other provider for storage, use a DSN of the following format `s3://access_key:access_secret@host:port/bucket_name?region=us-east-1` @@ -201,6 +203,7 @@ docker compose down | OPR_EXECUTOR_RETRY_ATTEMPTS | Number of retry attempts for failed executions, ex: `5` | | OPR_EXECUTOR_RETRY_DELAY_MS | Delay (in milliseconds) between retry attempts, ex: `500` | | OPR_EXECUTOR_BUILD_CACHE_VOLUME | Docker volume name used to store build caches by `cacheKey`, ex: `openruntimes-build-cache` | +| OPR_EXECUTOR_BUILD_CACHE_HELPER_IMAGE | Helper image used to create Docker volume subpaths before mounting isolated build caches, ex: `busybox:1.37` | ## Contributing diff --git a/composer.json b/composer.json index 26fe0c99..2da0fe32 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,11 @@ "ext-json": "*", "ext-swoole": "*", "utopia-php/config": "^0.2.2", - "utopia-php/console": "^0.1.1", + "utopia-php/console": "^0.2.0", "utopia-php/di": "0.3.*", "utopia-php/dsn": "0.2.*", "utopia-php/http": "0.34.*", - "utopia-php/orchestration": "^0.19.2", + "utopia-php/orchestration": "dev-feature/mount-value-object", "utopia-php/storage": "^2.0", "utopia-php/system": "0.10.*" }, diff --git a/composer.lock b/composer.lock index 68e044ad..16a8461a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fcc19e168530cc9be6ce3a78ceb18bff", + "content-hash": "5388b15866a89c7266460dd828945305", "packages": [ { "name": "brick/math", @@ -1974,20 +1974,21 @@ }, { "name": "utopia-php/console", - "version": "0.1.1", + "version": "0.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/console.git", - "reference": "d298e43960780e6d76e66de1228c75dc81220e3e" + "reference": "97e3de44424ee9ea207c3129dfcc82f8df37c5b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/console/zipball/d298e43960780e6d76e66de1228c75dc81220e3e", - "reference": "d298e43960780e6d76e66de1228c75dc81220e3e", + "url": "https://api.github.com/repos/utopia-php/console/zipball/97e3de44424ee9ea207c3129dfcc82f8df37c5b5", + "reference": "97e3de44424ee9ea207c3129dfcc82f8df37c5b5", "shasum": "" }, "require": { - "php": ">=8.0" + "php": ">=8.0", + "utopia-php/validators": "^0.2.0" }, "require-dev": { "laravel/pint": "1.2.*", @@ -2016,9 +2017,9 @@ ], "support": { "issues": "https://github.com/utopia-php/console/issues", - "source": "https://github.com/utopia-php/console/tree/0.1.1" + "source": "https://github.com/utopia-php/console/tree/0.2.1" }, - "time": "2026-02-10T10:20:29+00:00" + "time": "2026-04-20T10:53:53+00:00" }, { "name": "utopia-php/di", @@ -2176,21 +2177,21 @@ }, { "name": "utopia-php/orchestration", - "version": "0.19.2", + "version": "dev-feature/mount-value-object", "source": { "type": "git", "url": "https://github.com/utopia-php/orchestration.git", - "reference": "445895025c4b76707ef21630aa5063e41e0e6d36" + "reference": "34f62718bde0017030cb48e90a1c843e1befa49d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/orchestration/zipball/445895025c4b76707ef21630aa5063e41e0e6d36", - "reference": "445895025c4b76707ef21630aa5063e41e0e6d36", + "url": "https://api.github.com/repos/utopia-php/orchestration/zipball/34f62718bde0017030cb48e90a1c843e1befa49d", + "reference": "34f62718bde0017030cb48e90a1c843e1befa49d", "shasum": "" }, "require": { "php": ">=8.0", - "utopia-php/console": "0.1.*" + "utopia-php/console": "0.2.*" }, "require-dev": { "laravel/pint": "^1.2", @@ -2220,9 +2221,9 @@ ], "support": { "issues": "https://github.com/utopia-php/orchestration/issues", - "source": "https://github.com/utopia-php/orchestration/tree/0.19.2" + "source": "https://github.com/utopia-php/orchestration/tree/feature/mount-value-object" }, - "time": "2026-05-03T04:11:24+00:00" + "time": "2026-06-01T15:18:44+00:00" }, { "name": "utopia-php/servers", @@ -2443,16 +2444,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.3", + "version": "0.2.4", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "9770269c8ed8e6909934965fa8722103c7434c23" + "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/9770269c8ed8e6909934965fa8722103c7434c23", - "reference": "9770269c8ed8e6909934965fa8722103c7434c23", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/b4ee60db4dbae5ffbe53968d01f69b6941251576", + "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576", "shasum": "" }, "require": { @@ -2482,9 +2483,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.3" + "source": "https://github.com/utopia-php/validators/tree/0.2.4" }, - "time": "2026-05-14T08:05:44+00:00" + "time": "2026-05-21T12:47:43+00:00" } ], "packages-dev": [ @@ -4594,7 +4595,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/orchestration": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/docker-compose.yml b/docker-compose.yml index 5a933643..46926a0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: - OPR_EXECUTOR_RETRY_DELAY_MS - OPR_EXECUTOR_IMAGE_PULL - OPR_EXECUTOR_BUILD_CACHE_VOLUME + - OPR_EXECUTOR_BUILD_CACHE_HELPER_IMAGE volumes: openruntimes-builds: diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index 11993d4d..1bb55fd6 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -12,6 +12,7 @@ use Utopia\Console; use Utopia\Http\Response; use Utopia\Logger\Log; +use Utopia\Orchestration\Mount; use Utopia\Orchestration\Orchestration; use Utopia\Orchestration\Exception\Timeout as TimeoutException; use Utopia\Orchestration\Exception\Orchestration as OrchestrationException; @@ -322,8 +323,9 @@ public function createRuntime( $cacheEnabled = $cacheKey !== '' && $command !== '' && $command !== '0'; if ($cacheEnabled) { $cacheVolume = System::getEnv('OPR_EXECUTOR_BUILD_CACHE_VOLUME', 'openruntimes-build-cache'); - $volumes[] = $cacheVolume . ':/cache:rw'; - $variables = \array_merge($variables, $this->getPackageManagerCacheVariables($cacheKey)); + $this->ensureBuildCacheSubpath($cacheVolume, $cacheKey); + $volumes[] = Mount::volume($cacheVolume, '/cache', false, $cacheKey); + $variables = \array_merge($variables, $this->getPackageManagerCacheVariables()); } if ($version === 'v5') { @@ -521,17 +523,35 @@ private function withPackageManagerCacheLog(string $command): string return "printf '%s\n' '[build cache] Using package manager cache.'; " . $command; } + private function ensureBuildCacheSubpath(string $cacheVolume, string $cacheKey): void + { + $output = ''; + $stderr = ''; + $helperImage = System::getEnv('OPR_EXECUTOR_BUILD_CACHE_HELPER_IMAGE', 'busybox:1.37'); + + $command = 'docker volume create ' . \escapeshellarg($cacheVolume) . ' >/dev/null && docker run --rm --volume ' . \escapeshellarg($cacheVolume . ':/cache:rw') . ' ' . \escapeshellarg($helperImage) . ' mkdir -p ' . \escapeshellarg('/cache/' . $cacheKey); + $status = Console::execute($command, '', $output, $stderr, 30); + + if ($status !== 0) { + $error = $stderr !== '' ? $stderr : $output; + throw new \Exception('Failed to prepare build cache subpath: ' . $error); + } + } + /** * @return array */ - private function getPackageManagerCacheVariables(string $cacheKey): array + private function getPackageManagerCacheVariables(): array { - $cacheRoot = '/cache/' . $cacheKey; + $cacheRoot = '/cache'; return [ 'npm_config_cache' => $cacheRoot . '/npm', 'YARN_CACHE_FOLDER' => $cacheRoot . '/yarn', 'npm_config_store_dir' => $cacheRoot . '/pnpm', + 'pnpm_config_store_dir' => $cacheRoot . '/pnpm', + 'XDG_CACHE_HOME' => $cacheRoot . '/xdg-cache', + 'XDG_STATE_HOME' => $cacheRoot . '/xdg-state', 'BUN_INSTALL_CACHE_DIR' => $cacheRoot . '/bun', ]; } diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index a0542182..9fab9f00 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -575,7 +575,7 @@ public function testBuildCache(): void $runtimeId = \bin2hex(\random_bytes(4)); $cacheKey = 'test-build-cache-' . $runtimeId; - $npmCache = '/cache/' . $cacheKey . '/npm'; + $pnpmCache = '/cache/pnpm'; $params = [ 'runtimeId' => 'test-build-cache-miss-' . $runtimeId, @@ -583,7 +583,7 @@ public function testBuildCache(): void 'destination' => '/storage/builds/test-cache-miss', 'entrypoint' => 'index.js', 'image' => 'openruntimes/node:v5-18.0', - 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -d ' . $npmCache . ' && touch ' . $npmCache . '/.open-runtimes-cache-test"', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "pnpm install && test -d ' . $pnpmCache . ' && touch ' . $pnpmCache . '/.open-runtimes-cache-test"', 'cacheKey' => $cacheKey, 'remove' => true, ]; @@ -604,7 +604,7 @@ public function testBuildCache(): void 'destination' => '/storage/builds/test-cache-hit', 'entrypoint' => 'index.js', 'image' => 'openruntimes/node:v5-18.0', - 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "npm install && test -f ' . $npmCache . '/.open-runtimes-cache-test"', + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "pnpm install && test -f ' . $pnpmCache . '/.open-runtimes-cache-test"', 'cacheKey' => $cacheKey, 'remove' => true, ];