├── src └── Executor │ ├── .gitkeep │ ├── Validator │ └── TCP.php │ ├── Usage.php │ ├── Stats.php │ ├── Exception.php │ ├── StorageFactory.php │ ├── Runner │ ├── Runtime.php │ ├── Adapter.php │ ├── Repository │ │ └── Runtimes.php │ └── Docker.php │ ├── Logs.php │ └── BodyMultipart.php ├── tests ├── resources │ ├── functions │ │ ├── python │ │ │ ├── requirements.txt │ │ │ └── index.py │ │ ├── static │ │ │ ├── build.sh │ │ │ └── src │ │ │ │ └── index.html │ │ ├── ruby │ │ │ ├── Gemfile │ │ │ └── index.rb │ │ ├── cpp │ │ │ ├── CMakeLists.txt │ │ │ └── index.cc │ │ ├── node-empty-array │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── node-empty-object │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── php-build-logs │ │ │ ├── logs_success.sh │ │ │ ├── logs_failure.sh │ │ │ ├── logs_success_large.sh │ │ │ ├── logs_failure_large.sh │ │ │ ├── index.php │ │ │ └── _logs.sh │ │ ├── node-binary-request │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── php-exit │ │ │ └── index.php │ │ ├── node-v2 │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── node-specs │ │ │ ├── build.js │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── node-binary-response │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── php-timeout │ │ │ └── index.php │ │ ├── node-long-coldstart │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── node │ │ │ ├── build.js │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── node-timeout │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── dart │ │ │ ├── pubspec.yaml │ │ │ └── lib │ │ │ │ └── index.dart │ │ ├── node-logs │ │ │ ├── package.json │ │ │ └── index.js │ │ ├── README.md │ │ ├── php-mock │ │ │ ├── composer.json │ │ │ └── index.php │ │ ├── dotnet │ │ │ ├── Function.csproj │ │ │ └── Index.cs │ │ ├── php │ │ │ ├── composer.json │ │ │ └── index.php │ │ └── deno │ │ │ └── index.ts │ └── sites │ │ └── astro │ │ ├── tsconfig.json │ │ ├── astro.config.mjs │ │ ├── package.json │ │ ├── .gitignore │ │ ├── src │ │ └── pages │ │ │ └── logs.astro │ │ └── public │ │ └── favicon.svg ├── Client.php └── unit │ └── Executor │ └── Runner │ ├── RuntimeTest.php │ └── Repository │ └── RuntimesTest.php ├── pint.json ├── phpstan.neon ├── .gitignore ├── .devcontainer └── devcontainer.json ├── .gitpod.yml ├── .github └── workflows │ ├── linter.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── tests.yml ├── phpunit.xml ├── .env ├── Dockerfile ├── LICENSE ├── composer.json ├── docker-compose.yml ├── app ├── http.php ├── error.php ├── config │ └── errors.php ├── init.php └── controllers.php ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── README.md /src/Executor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/functions/python/requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.27.1 2 | -------------------------------------------------------------------------------- /tests/resources/functions/static/build.sh: -------------------------------------------------------------------------------- 1 | mkdir -p dist 2 | cp -R src/* dist/ 3 | -------------------------------------------------------------------------------- /tests/resources/functions/ruby/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "httparty" -------------------------------------------------------------------------------- /tests/resources/functions/cpp/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | target_link_libraries(${PROJECT_NAME} PUBLIC curl) -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12", 3 | "exclude": [ 4 | "tests/resources" 5 | ] 6 | } -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | scanDirectories: 3 | - vendor/swoole/ide-helper 4 | excludePaths: 5 | - tests/resources -------------------------------------------------------------------------------- /tests/resources/functions/node-empty-array/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (context) => { 2 | return context.res.json([]); 3 | } -------------------------------------------------------------------------------- /tests/resources/functions/node-empty-object/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (context) => { 2 | return context.res.json({}); 3 | } -------------------------------------------------------------------------------- /tests/resources/functions/php-build-logs/logs_success.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | echo "First log" 4 | 5 | sh _logs.sh 6 | 7 | echo "Last log" -------------------------------------------------------------------------------- /tests/resources/functions/php-build-logs/logs_failure.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | echo "First log" 4 | 5 | sh _logs.sh 6 | 7 | echo "Last log" 8 | exit 1 -------------------------------------------------------------------------------- /tests/resources/functions/node-binary-request/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (context) => { 2 | return context.res.binary(context.req.bodyBinary); 3 | } -------------------------------------------------------------------------------- /tests/resources/functions/php-build-logs/logs_success_large.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | echo "First log" 4 | 5 | sh _logs.sh 6 | sh _logs.sh 7 | 8 | echo "Last log" -------------------------------------------------------------------------------- /tests/resources/functions/php-exit/index.php: -------------------------------------------------------------------------------- 1 | res->send('OK'); 6 | }; 7 | -------------------------------------------------------------------------------- /tests/resources/functions/node-v2/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (req, res)=> { 2 | res.json({ 3 | message: 'Hello Open Runtimes 👋' 4 | }); 5 | } -------------------------------------------------------------------------------- /tests/resources/functions/node-specs/build.js: -------------------------------------------------------------------------------- 1 | console.log(`cpus=${process.env.OPEN_RUNTIMES_CPUS}`); 2 | console.log(`memory=${process.env.OPEN_RUNTIMES_MEMORY}`); 3 | -------------------------------------------------------------------------------- /tests/resources/functions/php-build-logs/logs_failure_large.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | echo "First log" 4 | 5 | sh _logs.sh 6 | sh _logs.sh 7 | 8 | echo "Last log" 9 | exit 1 -------------------------------------------------------------------------------- /tests/resources/sites/astro/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /tests/resources/functions/node-binary-response/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (context) => { 2 | return context.res.binary(Buffer.from((Uint8Array.from([0, 10, 255])))); 3 | } -------------------------------------------------------------------------------- /tests/resources/functions/php-build-logs/index.php: -------------------------------------------------------------------------------- 1 | res->send('OK'); 6 | }; 7 | -------------------------------------------------------------------------------- /tests/resources/functions/php-timeout/index.php: -------------------------------------------------------------------------------- 1 | json([ 7 | 'pass' => true 8 | ]); 9 | }; -------------------------------------------------------------------------------- /tests/resources/functions/node-long-coldstart/index.js: -------------------------------------------------------------------------------- 1 | await new Promise((resolve) => setTimeout(resolve, 10_000)); 2 | 3 | export default async (context) => { 4 | return context.res.send("OK"); 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | .phpunit.result.cache 4 | /tests/resources/functions/**/code.tar.gz 5 | /tests/resources/functions/**/code.zip 6 | /tests/resources/sites/**/code.tar.gz 7 | /tests/resources/sites/**/code.zip -------------------------------------------------------------------------------- /tests/resources/functions/node/build.js: -------------------------------------------------------------------------------- 1 | let i = 0; 2 | const interval = setInterval(() => { 3 | i++; 4 | 5 | if (i >= 10) { 6 | clearInterval(interval); 7 | } 8 | 9 | console.log("Step: " + i); 10 | }, 1000); -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-runtimes/executor", 3 | "onCreateCommand": "composer install --ignore-platform-reqs", 4 | "extensions": [ 5 | "ms-azuretools.vscode-docker", 6 | "zobo.php-intellisense" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tests/resources/functions/node-timeout/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (context) => { 2 | await new Promise((res) => { 3 | setTimeout(() => { 4 | res(true); 5 | }, 60000); 6 | }); 7 | 8 | return context.res.send('OK'); 9 | } -------------------------------------------------------------------------------- /tests/resources/functions/dart/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: open_runtimes_test 2 | description: Dart runtime test 3 | version: 1.0.0 4 | publish_to: none 5 | # homepage: https://www.example.com 6 | 7 | environment: 8 | sdk: '>=2.12.0 <3.0.0' 9 | 10 | dependencies: 11 | dio: ^4.0.4 -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: open-runtimes/executor 3 | init: | 4 | docker compose pull 5 | docker compose build 6 | composer install --ignore-platform-reqs 7 | vscode: 8 | extensions: 9 | - ms-azuretools.vscode-docker 10 | - zobo.php-intellisense -------------------------------------------------------------------------------- /tests/resources/functions/node-specs/index.js: -------------------------------------------------------------------------------- 1 | await new Promise((resolve) => setTimeout(resolve, 10_000)); 2 | 3 | export default async (context) => { 4 | return context.res.json({ 5 | cpus: process.env.OPEN_RUNTIMES_CPUS, 6 | memory: process.env.OPEN_RUNTIMES_MEMORY, 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /tests/resources/functions/node-v2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v2", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/sites/astro/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from 'astro/config'; 3 | import node from '@astrojs/node'; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | output: 'server', 8 | adapter: node({ 9 | mode: 'standalone' 10 | }), 11 | }); 12 | -------------------------------------------------------------------------------- /tests/resources/functions/node-logs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logs", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/functions/static/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 |

Website content

10 | 11 | -------------------------------------------------------------------------------- /tests/resources/functions/node-timeout/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timeout", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/functions/node-empty-array/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "empty-array", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/functions/node-empty-object/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "empty-object", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/functions/README.md: -------------------------------------------------------------------------------- 1 | `php.tar.gz` includes dependency in `composer.json` and files: 2 | 3 | - `index.php` - Function that returns JSON with a `payload`, `variable` (from `customVariable`), and `unicode` message. 4 | - `timeout.php` - Simple JSON response `{pass:true}` but with 15 seconds sleep. Should be used to test timeout logic. -------------------------------------------------------------------------------- /tests/resources/functions/node-binary-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "binary-request", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/functions/node-binary-response/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "binary-response", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /tests/resources/functions/node-long-coldstart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timeout", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC" 13 | } 14 | -------------------------------------------------------------------------------- /tests/resources/sites/astro/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "build": "astro build", 8 | "preview": "astro preview", 9 | "astro": "astro" 10 | }, 11 | "dependencies": { 12 | "@astrojs/node": "^9.1.2", 13 | "astro": "^5.4.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/resources/functions/node-specs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "specs", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "build": "node build.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC" 14 | } 15 | -------------------------------------------------------------------------------- /tests/resources/sites/astro/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | -------------------------------------------------------------------------------- /tests/resources/functions/node-logs/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async (context) => { 2 | let log1kb = ''; 3 | 4 | for(let i = 0; i < 1023; i++) { // context.log adds a new line character 5 | log1kb += "A"; 6 | } 7 | 8 | // 1MB * bodyText log 9 | for(let i = 0; i < 1024 * (+context.req.bodyText); i++) { 10 | context.log(log1kb); 11 | } 12 | 13 | return context.res.send('OK'); 14 | } -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: "Linter" 2 | 3 | on: [pull_request] 4 | jobs: 5 | lint: 6 | name: Linter 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check out the repo 11 | uses: actions/checkout@v2 12 | 13 | - name: Run Linter 14 | run: | 15 | docker run --rm -v $PWD:/app composer sh -c \ 16 | "composer install --profile --ignore-platform-reqs && composer lint" -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: [pull_request] 4 | jobs: 5 | lint: 6 | name: CodeQL 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check out the repo 11 | uses: actions/checkout@v2 12 | 13 | - name: Run CodeQL 14 | run: | 15 | docker run --rm -v $PWD:/app composer:2.6 sh -c \ 16 | "composer install --profile --ignore-platform-reqs && composer check" -------------------------------------------------------------------------------- /tests/resources/sites/astro/src/pages/logs.astro: -------------------------------------------------------------------------------- 1 | --- 2 | console.log('Open runtimes log'); 3 | console.log('A developer log'); 4 | console.error('Open runtimes error'); 5 | 6 | Astro.cookies.set('astroCookie1', 'astroValue1', { 7 | httpOnly: true, 8 | maxAge: 1800 9 | }); 10 | 11 | Astro.cookies.set('astroCookie2', 'astroValue2', { 12 | httpOnly: true, 13 | maxAge: 1800 14 | }); 15 | 16 | --- 17 | 18 |

OK

19 | -------------------------------------------------------------------------------- /tests/resources/functions/php-mock/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-runtimes/php-example", 3 | "description": "", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Team Appwrite", 9 | "email": "team@appwrite.io" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.4.0", 14 | "ext-curl": "*", 15 | "ext-json": "*" 16 | } 17 | } -------------------------------------------------------------------------------- /tests/resources/functions/php-mock/index.php: -------------------------------------------------------------------------------- 1 | log("Sample Log"); 7 | 8 | return $context->res->json([ 9 | 'isTest' => true, 10 | 'message' => 'Hello Open Runtimes 👋', 11 | 'variable' => \getenv('TEST_VARIABLE'), 12 | 'url' => $context->req->url, 13 | 'todo' => ['userId' => 13] 14 | ]); 15 | }; 16 | -------------------------------------------------------------------------------- /tests/resources/functions/dotnet/Function.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | net6.0 6 | Exe 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/resources/functions/php/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-runtimes/php-example", 3 | "description": "", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Team Appwrite", 9 | "email": "team@appwrite.io" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.4.0", 14 | "ext-curl": "*", 15 | "ext-json": "*", 16 | "guzzlehttp/guzzle": "^7.0" 17 | } 18 | } -------------------------------------------------------------------------------- /tests/resources/functions/node/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | module.exports = async (context)=> { 4 | const todo = await fetch(`https://dummyjson.com/todos/${context.req.body.id ?? 1}`).then(r => r.json()); 5 | context.log('Sample Log'); 6 | 7 | return context.res.json({ 8 | isTest: true, 9 | message: 'Hello Open Runtimes 👋', 10 | url: context.req.url, 11 | variable: process.env['TEST_VARIABLE'], 12 | todo 13 | }); 14 | } -------------------------------------------------------------------------------- /tests/resources/functions/ruby/index.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | require 'json' 3 | 4 | def main(context) 5 | todo = JSON.parse(HTTParty.get("https://dummyjson.com/todos/" + (context.req.body['id'] || '1')).body) 6 | 7 | context.log('Sample Log') 8 | 9 | return context.res.json({ 10 | 'isTest': true, 11 | 'message': 'Hello Open Runtimes 👋', 12 | 'todo': todo, 13 | 'url': context.req.url, 14 | 'variable': ENV['TEST_VARIABLE'] || nil, 15 | }) 16 | end -------------------------------------------------------------------------------- /tests/resources/functions/deno/index.ts: -------------------------------------------------------------------------------- 1 | import axiod from "https://deno.land/x/axiod/mod.ts"; 2 | 3 | export default async function(context: any) { 4 | const todo = (await axiod.get(`https://dummyjson.com/todos/${context.req.body.id ?? 1}`)).data; 5 | 6 | context.log('Sample Log'); 7 | 8 | return context.res.json({ 9 | isTest: true, 10 | message: 'Hello Open Runtimes 👋', 11 | url: context.req.url, 12 | variable: Deno.env.get("TEST_VARIABLE"), 13 | todo 14 | }); 15 | } -------------------------------------------------------------------------------- /tests/resources/functions/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "echo -e \"\\033[33mOrange message\\033[0m\" && node build.js && echo -e \"\\033[31mRed message\\033[0m\" && sleep 2" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "node-fetch": "^2.6.1" 15 | } 16 | } -------------------------------------------------------------------------------- /tests/resources/functions/python/index.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | 5 | def main(context): 6 | todo_id = context.req.body.get('id', 1) 7 | var_data = os.environ.get('TEST_VARIABLE', None) 8 | 9 | todo = (requests.get('https://dummyjson.com/todos/' + str(todo_id))).json() 10 | 11 | context.log('Sample Log') 12 | 13 | return context.res.json({ 14 | 'isTest': True, 15 | 'message': 'Hello Open Runtimes 👋', 16 | 'todo': todo, 17 | 'url': context.req.url, 18 | 'variable': var_data 19 | }) -------------------------------------------------------------------------------- /tests/resources/functions/dart/lib/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:dio/dio.dart' hide Response; 4 | import 'dart:io' show Platform; 5 | 6 | Future main(final context) async { 7 | final id = context.req.body['id'] ?? '1'; 8 | final todo = await Dio().get('https://dummyjson.com/todos/$id'); 9 | context.log('Sample Log'); 10 | 11 | return context.res.json({ 12 | 'isTest': true, 13 | 'message': "Hello Open Runtimes 👋", 14 | 'url': context.req.url, 15 | 'variable': Platform.environment['TEST_VARIABLE'] ?? '', 16 | 'todo': todo.data, 17 | }); 18 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | ./tests/ExecutorTest.php 15 | 16 | 17 | ./tests/unit 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | OPR_EXECUTOR_ENV=development 2 | OPR_EXECUTOR_IMAGE_PULL=enabled 3 | OPR_EXECUTOR_RUNTIMES=php-8.1,dart-2.18,deno-1.24,node-18.0,python-3.10,ruby-3.1,cpp-17,dotnet-6.0 4 | OPR_EXECUTOR_CONNECTION_STORAGE=local://localhost 5 | OPR_EXECUTOR_INACTIVE_THRESHOLD=60 6 | OPR_EXECUTOR_MAINTENANCE_INTERVAL=60 7 | OPR_EXECUTOR_NETWORK=executor_runtimes 8 | OPR_EXECUTOR_IMAGE=executor-local 9 | OPR_EXECUTOR_SECRET=executor-secret-key 10 | OPR_EXECUTOR_LOGGING_PROVIDER= 11 | OPR_EXECUTOR_LOGGING_CONFIG= 12 | OPR_EXECUTOR_LOGGING_IDENTIFIER= 13 | OPR_EXECUTOR_DOCKER_HUB_USERNAME= 14 | OPR_EXECUTOR_DOCKER_HUB_PASSWORD= 15 | OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v5 16 | OPR_EXECUTOR_RETRY_ATTEMPTS=5 17 | OPR_EXECUTOR_RETRY_DELAY_MS=500 18 | -------------------------------------------------------------------------------- /tests/resources/functions/php-build-logs/_logs.sh: -------------------------------------------------------------------------------- 1 | CHARS_128="11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" 2 | CHARS_1KB="$CHARS_128$CHARS_128$CHARS_128$CHARS_128$CHARS_128$CHARS_128$CHARS_128$CHARS_128" 3 | CHARS_16KB="$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB$CHARS_1KB" 4 | CHARS_128KB="$CHARS_16KB$CHARS_16KB$CHARS_16KB$CHARS_16KB$CHARS_16KB$CHARS_16KB$CHARS_16KB$CHARS_16KB" 5 | 6 | # 7 * 128KB 7 | echo -n "$CHARS_128KB" 8 | echo -n "$CHARS_128KB" 9 | echo -n "$CHARS_128KB" 10 | echo -n "$CHARS_128KB" 11 | echo -n "$CHARS_128KB" 12 | echo -n "$CHARS_128KB" 13 | echo -n "$CHARS_128KB" 14 | -------------------------------------------------------------------------------- /tests/resources/functions/php/index.php: -------------------------------------------------------------------------------- 1 | 'https://dummyjson.com' 10 | ]); 11 | 12 | return function ($context) use ($client) { 13 | $response = $client->request('GET', '/todos/' . ($context->req->body['id'] ?? 1)); 14 | $todo = \json_decode($response->getBody()->getContents(), true); 15 | 16 | $context->log("Sample Log"); 17 | 18 | return $context->res->json([ 19 | 'isTest' => true, 20 | 'message' => 'Hello Open Runtimes 👋', 21 | 'variable' => \getenv('TEST_VARIABLE'), 22 | 'url' => $context->req->url, 23 | 'todo' => $todo 24 | ], 200, [ 25 | 'x-key' => 'aValue' 26 | ]); 27 | }; 28 | -------------------------------------------------------------------------------- /tests/resources/sites/astro/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Install PHP libraries 2 | FROM composer:2.0 AS composer 3 | 4 | WORKDIR /usr/local/src/ 5 | 6 | COPY composer.lock /usr/local/src/ 7 | COPY composer.json /usr/local/src/ 8 | 9 | RUN composer install --ignore-platform-reqs --optimize-autoloader \ 10 | --no-plugins --no-scripts --prefer-dist 11 | 12 | # Executor 13 | FROM openruntimes/base:0.1.0 AS final 14 | 15 | ARG OPR_EXECUTOR_VERSION 16 | ENV OPR_EXECUTOR_VERSION=$OPR_EXECUTOR_VERSION 17 | 18 | # Source code 19 | COPY ./app /usr/local/app 20 | COPY ./src /usr/local/src 21 | 22 | # Extensions and libraries 23 | COPY --from=composer /usr/local/src/vendor /usr/local/vendor 24 | 25 | HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=3 CMD curl -s -H "Authorization: Bearer ${OPR_EXECUTOR_SECRET}" --fail http://127.0.0.1:80/v1/health 26 | 27 | CMD [ "php", "app/http.php" ] 28 | -------------------------------------------------------------------------------- /tests/resources/functions/dotnet/Index.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetRuntime; 2 | 3 | using System; 4 | using Newtonsoft.Json; 5 | 6 | public class Handler { 7 | static readonly HttpClient http = new(); 8 | 9 | public async Task Main(RuntimeContext Context) 10 | { 11 | Dictionary Body = (Dictionary) Context.Req.Body; 12 | 13 | string id = Body.TryGetValue("id", out var value) == true ? value.ToString()! : "1"; 14 | var varData = Environment.GetEnvironmentVariable("TEST_VARIABLE") ?? null; 15 | 16 | var response = await http.GetStringAsync($"https://dummyjson.com/todos/" + id); 17 | var todo = JsonConvert.DeserializeObject>(response, settings: null); 18 | 19 | Context.Log("Sample Log"); 20 | 21 | return Context.Res.Json(new() 22 | { 23 | { "isTest", true }, 24 | { "message", "Hello Open Runtimes 👋" }, 25 | { "variable", varData }, 26 | { "url", Context.Req.Url }, 27 | { "todo", todo } 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Open Runtimes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | IMAGE_NAME: openruntimes/executor 9 | TAG: ${{ github.event.release.tag_name }} 10 | USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 11 | PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out the repo 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | 26 | - name: Login to DockerHub 27 | uses: docker/login-action@v2 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ env.USERNAME }} 31 | password: ${{ env.PASSWORD }} 32 | 33 | - name: Build and push 34 | uses: docker/build-push-action@v4 35 | with: 36 | build-args: | 37 | OPR_EXECUTOR_VERSION=${{ env.TAG }} 38 | platforms: linux/amd64,linux/arm64 39 | context: . 40 | push: true 41 | tags: ${{ env.IMAGE_NAME }}:latest,${{ env.IMAGE_NAME }}:${{ env.TAG }} -------------------------------------------------------------------------------- /src/Executor/Validator/TCP.php: -------------------------------------------------------------------------------- 1 | timeout); // @ prevents warnings (Unable to connect) 48 | 49 | if (!$socket) { 50 | return false; 51 | } else { 52 | \fclose($socket); 53 | return true; 54 | } 55 | } catch (\RuntimeException) { 56 | return false; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-runtimes/executor", 3 | "description": "Serverless runtimes executor for container based environments ⚡️", 4 | "type": "project", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "OpenRuntimes\\": "src/", 9 | "Tests\\": "tests" 10 | } 11 | }, 12 | "scripts": { 13 | "lint": "./vendor/bin/pint --test --config pint.json", 14 | "format": "./vendor/bin/pint --config pint.json", 15 | "check": "./vendor/bin/phpstan analyse --level 8 --memory-limit=2G -c phpstan.neon app src tests", 16 | "test": "./vendor/bin/phpunit --configuration phpunit.xml --debug" 17 | }, 18 | "require": { 19 | "php": ">=8.3.0", 20 | "ext-curl": "*", 21 | "ext-json": "*", 22 | "ext-swoole": "*", 23 | "appwrite/php-runtimes": "0.19.*", 24 | "utopia-php/config": "^0.2.2", 25 | "utopia-php/console": "0.0.*", 26 | "utopia-php/dsn": "0.2.*", 27 | "utopia-php/fetch": "0.4.*", 28 | "utopia-php/framework": "0.34.*", 29 | "utopia-php/logger": "0.6.*", 30 | "utopia-php/orchestration": "0.14.*", 31 | "utopia-php/preloader": "0.2.*", 32 | "utopia-php/registry": "0.5.*", 33 | "utopia-php/storage": "0.18.*", 34 | "utopia-php/system": "0.9.*" 35 | }, 36 | "require-dev": { 37 | "laravel/pint": "1.*", 38 | "phpstan/phpstan": "1.*", 39 | "phpunit/phpunit": "9.*", 40 | "swoole/ide-helper": "5.1.2" 41 | }, 42 | "config": { 43 | "platform": { 44 | "php": "8.3" 45 | }, 46 | "allow-plugins": { 47 | "php-http/discovery": false, 48 | "tbachert/spi": false 49 | }, 50 | "process-timeout": 0 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # WARNING! 2 | # This is a development version of the docker-compose.yml file. 3 | # Avoid using this file in your production environment. 4 | # We're exposing here sensitive ports and mounting code volumes for rapid development and debugging of the server stack. 5 | 6 | x-logging: &x-logging 7 | logging: 8 | driver: 'json-file' 9 | options: 10 | max-file: '5' 11 | max-size: '10m' 12 | 13 | services: 14 | openruntimes-executor: 15 | hostname: executor 16 | <<: *x-logging 17 | stop_signal: SIGINT 18 | build: 19 | context: . 20 | networks: 21 | - runtimes 22 | ports: 23 | - 9900:80 24 | volumes: 25 | - /var/run/docker.sock:/var/run/docker.sock 26 | - ./app:/usr/local/app:rw 27 | - ./src:/usr/local/src:rw 28 | - ./tests:/usr/local/tests:rw 29 | - ./phpunit.xml:/usr/local/phpunit.xml 30 | - openruntimes-builds:/storage/builds:rw 31 | - openruntimes-functions:/storage/functions:rw 32 | - /tmp:/tmp:rw 33 | - ./tests/resources/functions:/storage/functions:rw 34 | - ./tests/resources/sites:/storage/sites:rw 35 | environment: 36 | - OPR_EXECUTOR_ENV 37 | - OPR_EXECUTOR_RUNTIMES 38 | - OPR_EXECUTOR_CONNECTION_STORAGE 39 | - OPR_EXECUTOR_INACTIVE_THRESHOLD 40 | - OPR_EXECUTOR_MAINTENANCE_INTERVAL 41 | - OPR_EXECUTOR_NETWORK 42 | - OPR_EXECUTOR_SECRET 43 | - OPR_EXECUTOR_LOGGING_PROVIDER 44 | - OPR_EXECUTOR_LOGGING_CONFIG 45 | - OPR_EXECUTOR_DOCKER_HUB_USERNAME 46 | - OPR_EXECUTOR_DOCKER_HUB_PASSWORD 47 | - OPR_EXECUTOR_RUNTIME_VERSIONS 48 | - OPR_EXECUTOR_RETRY_ATTEMPTS 49 | - OPR_EXECUTOR_RETRY_DELAY_MS 50 | - OPR_EXECUTOR_IMAGE_PULL 51 | 52 | volumes: 53 | openruntimes-builds: 54 | openruntimes-functions: 55 | 56 | networks: 57 | runtimes: 58 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | on: [pull_request] 4 | jobs: 5 | unit-tests: 6 | name: Unit Tests 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check out the repo 11 | uses: actions/checkout@v2 12 | 13 | - name: Run Unit Tests 14 | run: | 15 | docker run --rm \ 16 | -v $PWD:/app \ 17 | -w /app \ 18 | phpswoole/swoole:5.1.2-php8.3-alpine \ 19 | sh -c " 20 | apk update && \ 21 | apk add zip unzip && \ 22 | composer install --profile --ignore-platform-reqs && \ 23 | composer test -- --testsuite=unit 24 | " 25 | 26 | executor-tests: 27 | name: Executor Tests 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Check out the repo 32 | uses: actions/checkout@v2 33 | 34 | - name: Start Test Stack 35 | run: | 36 | export COMPOSE_INTERACTIVE_NO_CLI 37 | export DOCKER_BUILDKIT=1 38 | export COMPOSE_DOCKER_CLI_BUILD=1 39 | export BUILDKIT_PROGRESS=plain 40 | docker pull composer:2.0 41 | docker compose build 42 | docker compose up -d 43 | sleep 60 44 | 45 | - name: Doctor 46 | run: | 47 | docker compose logs 48 | docker ps 49 | docker network ls 50 | 51 | - name: Run Executor Tests 52 | run: | 53 | docker run --rm \ 54 | -v $PWD:/app \ 55 | -v /tmp:/tmp \ 56 | -v /var/run/docker.sock:/var/run/docker.sock \ 57 | --network executor_runtimes \ 58 | -w /app \ 59 | phpswoole/swoole:5.1.2-php8.3-alpine \ 60 | sh -c " 61 | apk update && \ 62 | apk add docker-cli zip unzip && \ 63 | composer install --profile --ignore-platform-reqs && \ 64 | composer test -- --testsuite=e2e 65 | " 66 | -------------------------------------------------------------------------------- /app/http.php: -------------------------------------------------------------------------------- 1 | inject('response') 32 | ->action(function (Response $response) { 33 | $response->addHeader('Server', 'Executor'); 34 | }); 35 | 36 | 37 | run(function () { 38 | $orchestration = new Orchestration(new DockerAPI( 39 | System::getEnv('OPR_EXECUTOR_DOCKER_HUB_USERNAME', ''), 40 | System::getEnv('OPR_EXECUTOR_DOCKER_HUB_PASSWORD', '') 41 | )); 42 | $networks = explode(',', System::getEnv('OPR_EXECUTOR_NETWORK') ?: 'openruntimes-runtimes'); 43 | $runner = new Docker($orchestration, new Runtimes(), $networks); 44 | 45 | Http::setResource('runner', fn () => $runner); 46 | 47 | $payloadSize = 22 * (1024 * 1024); 48 | $settings = [ 49 | 'package_max_length' => $payloadSize, 50 | 'buffer_output_size' => $payloadSize, 51 | ]; 52 | 53 | $server = new Server('0.0.0.0', '80', $settings); 54 | $http = new Http($server, 'UTC'); 55 | 56 | Console::success('Executor is ready.'); 57 | 58 | $http->start(); 59 | }); 60 | -------------------------------------------------------------------------------- /src/Executor/Usage.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $containerUsage = []; 20 | 21 | public function __construct(protected Orchestration $orchestration) 22 | { 23 | } 24 | 25 | public function run(): void 26 | { 27 | $this->hostUsage = null; 28 | $this->containerUsage = []; 29 | 30 | batch([ 31 | fn () => $this->runHost(), 32 | fn () => $this->runContainers() 33 | ]); 34 | } 35 | 36 | protected function runHost(): void 37 | { 38 | try { 39 | $this->hostUsage = System::getCPUUsage(2); 40 | } catch (Exception $err) { 41 | Console::warning('Skipping host stats loop due to error: ' . $err->getMessage()); 42 | } 43 | } 44 | 45 | protected function runContainers(): void 46 | { 47 | try { 48 | $containerUsages = $this->orchestration->getStats( 49 | filters: [ 'label' => 'openruntimes-executor=' . System::getHostname() ] 50 | ); 51 | 52 | foreach ($containerUsages as $containerUsage) { 53 | $hostnameArr = \explode('-', $containerUsage->getContainerName()); 54 | \array_shift($hostnameArr); 55 | $hostname = \implode('-', $hostnameArr); 56 | 57 | $this->containerUsage[$hostname] = $containerUsage->getCpuUsage() * 100; 58 | } 59 | } catch (Exception $err) { 60 | Console::warning('Skipping runtimes stats loop due to error: ' . $err->getMessage()); 61 | } 62 | } 63 | 64 | public function getHostUsage(): ?float 65 | { 66 | return $this->hostUsage; 67 | } 68 | 69 | public function getRuntimeUsage(string $runtimeId): ?float 70 | { 71 | return $this->containerUsage[$runtimeId] ?? null; 72 | } 73 | 74 | /** 75 | * @return array 76 | */ 77 | public function getRuntimesUsage(): array 78 | { 79 | return $this->containerUsage; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/resources/functions/cpp/index.cc: -------------------------------------------------------------------------------- 1 | #include "RuntimeResponse.h" 2 | #include "RuntimeRequest.h" 3 | #include "RuntimeOutput.h" 4 | #include "RuntimeContext.h" 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | using namespace std; 12 | 13 | namespace runtime { 14 | class Handler { 15 | public: 16 | static RuntimeOutput main(RuntimeContext &context) 17 | { 18 | RuntimeRequest req = context.req; 19 | RuntimeResponse res = context.res; 20 | 21 | Json::Value payload = std::any_cast(req.body); 22 | std::string id = payload["id"].asString(); 23 | 24 | Json::CharReaderBuilder builder; 25 | Json::CharReader *reader = builder.newCharReader(); 26 | 27 | CURL *curl; 28 | CURLcode curlRes; 29 | std::string todoBuffer; 30 | 31 | curl = curl_easy_init(); 32 | if (curl) 33 | { 34 | std::string url = "https://dummyjson.com/todos/" + id; 35 | curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); 36 | curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); 37 | curl_easy_setopt(curl, CURLOPT_WRITEDATA, &todoBuffer); 38 | curlRes = curl_easy_perform(curl); 39 | curl_easy_cleanup(curl); 40 | } 41 | 42 | Json::Value todo; 43 | reader->parse( 44 | todoBuffer.c_str(), 45 | todoBuffer.c_str() + todoBuffer.size(), 46 | &todo, 47 | nullptr 48 | ); 49 | 50 | delete reader; 51 | 52 | Json::Value response; 53 | response["isTest"] = true; 54 | response["message"] = "Hello Open Runtimes 👋"; 55 | response["url"] = req.url; 56 | response["variable"] = std::getenv("TEST_VARIABLE"); 57 | response["todo"] = todo; 58 | 59 | context.log("Sample Log"); 60 | 61 | return res.json(response); 62 | } 63 | 64 | static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) 65 | { 66 | ((std::string *) userp)->append((char *) contents, size * nmemb); 67 | return size * nmemb; 68 | } 69 | }; 70 | } -------------------------------------------------------------------------------- /src/Executor/Stats.php: -------------------------------------------------------------------------------- 1 | host = new Table(4096); 15 | $this->host->column('usage', Table::TYPE_FLOAT, 8); 16 | $this->host->create(); 17 | 18 | $this->containers = new Table(4096); 19 | $this->containers->column('usage', Table::TYPE_FLOAT, 8); 20 | $this->containers->create(); 21 | } 22 | 23 | public function getHostUsage(): ?float 24 | { 25 | return $this->host->get('host', 'usage') ?? null; 26 | } 27 | 28 | /** 29 | * @return mixed[] 30 | */ 31 | public function getContainerUsage(): array 32 | { 33 | $data = []; 34 | foreach ($this->containers as $hostname => $stat) { 35 | $data[$hostname] = [ 36 | 'status' => 'pass', 37 | 'usage' => $stat['usage'] ?? null 38 | ]; 39 | } 40 | return $data; 41 | } 42 | 43 | public function updateStats(Usage $usage): void 44 | { 45 | // Update host usage stats 46 | if ($usage->getHostUsage() !== null) { 47 | $oldStat = $this->getHostUsage(); 48 | 49 | if ($oldStat === null) { 50 | $stat = $usage->getHostUsage(); 51 | } else { 52 | $stat = ($oldStat + $usage->getHostUsage()) / 2; 53 | } 54 | 55 | $this->host->set('host', ['usage' => $stat]); 56 | } 57 | 58 | // Update runtime usage stats 59 | foreach ($usage->getRuntimesUsage() as $runtime => $usageStat) { 60 | $oldStat = $this->containers->get($runtime, 'usage') ?? null; 61 | 62 | if ($oldStat === null) { 63 | $stat = $usageStat; 64 | } else { 65 | $stat = ($oldStat + $usageStat) / 2; 66 | } 67 | 68 | $this->containers->set($runtime, ['usage' => $stat]); 69 | } 70 | 71 | // Delete gone runtimes 72 | $runtimes = \array_keys($usage->getRuntimesUsage()); 73 | foreach ($this->containers as $hostname => $stat) { 74 | if (!(\in_array($hostname, $runtimes))) { 75 | $this->containers->delete($hostname); 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/error.php: -------------------------------------------------------------------------------- 1 | getMessage()); 15 | Console::error('[Error] File: ' . $error->getFile()); 16 | Console::error('[Error] Line: ' . $error->getLine()); 17 | 18 | if ($logger === null) { 19 | return; 20 | } 21 | 22 | // Log everything, except those explicitly marked as not loggable 23 | if ($error instanceof Exception && !$error->isPublishable()) { 24 | return; 25 | } 26 | 27 | try { 28 | $log->setType(Log::TYPE_ERROR); 29 | $log->setMessage($error->getMessage()); 30 | $log->setAction("httpError"); 31 | $log->addTag('code', \strval($error->getCode())); 32 | $log->addTag('verboseType', get_class($error)); 33 | $log->addExtra('file', $error->getFile()); 34 | $log->addExtra('line', $error->getLine()); 35 | $log->addExtra('trace', $error->getTraceAsString()); 36 | 37 | $status = $logger->addLog($log); 38 | 39 | Console::info("Pushed log with response status code: $status"); 40 | } catch (\Throwable $e) { 41 | Console::error("Failed to push log: {$e->getMessage()}"); 42 | } 43 | } 44 | 45 | Http::error() 46 | ->inject('error') 47 | ->inject('logger') 48 | ->inject('response') 49 | ->inject('log') 50 | ->action(function (Throwable $error, ?Logger $logger, Response $response, Log $log) { 51 | logError($log, $error, $logger); 52 | 53 | // Show all Executor\Exceptions, or everything if in development 54 | $public = $error instanceof Exception || Http::isDevelopment(); 55 | $exception = $public ? $error : new Exception(Exception::GENERAL_UNKNOWN); 56 | $code = $exception->getCode() ?: 500; 57 | 58 | $output = [ 59 | 'type' => $exception instanceof Exception ? $exception->getType() : Exception::GENERAL_UNKNOWN, 60 | 'message' => $exception->getMessage(), 61 | 'code' => $code, 62 | 'version' => System::getEnv('OPR_EXECUTOR_VERSION', 'unknown') 63 | ]; 64 | 65 | // If in development, include some additional details. 66 | if (Http::isDevelopment()) { 67 | $output['file'] = $exception->getFile(); 68 | $output['line'] = $exception->getLine(); 69 | $output['trace'] = \json_encode($exception->getTrace(), JSON_UNESCAPED_UNICODE) === false ? [] : $exception->getTrace(); 70 | } 71 | 72 | $response 73 | ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') 74 | ->addHeader('Expires', '0') 75 | ->addHeader('Pragma', 'no-cache') 76 | ->setStatusCode($code) 77 | ->json($output); 78 | }); 79 | -------------------------------------------------------------------------------- /src/Executor/Exception.php: -------------------------------------------------------------------------------- 1 | _ 14 | */ 15 | public const string GENERAL_UNKNOWN = 'general_unknown'; 16 | public const string GENERAL_ROUTE_NOT_FOUND = 'general_route_not_found'; 17 | public const string GENERAL_UNAUTHORIZED = 'general_unauthorized'; 18 | 19 | public const string EXECUTION_BAD_REQUEST = 'execution_bad_request'; 20 | public const string EXECUTION_TIMEOUT = 'execution_timeout'; 21 | public const string EXECUTION_BAD_JSON = 'execution_bad_json'; 22 | 23 | public const string RUNTIME_NOT_FOUND = 'runtime_not_found'; 24 | public const string RUNTIME_CONFLICT = 'runtime_conflict'; 25 | public const string RUNTIME_FAILED = 'runtime_failed'; 26 | public const string RUNTIME_TIMEOUT = 'runtime_timeout'; 27 | 28 | public const string LOGS_TIMEOUT = 'logs_timeout'; 29 | 30 | public const string COMMAND_TIMEOUT = 'command_timeout'; 31 | public const string COMMAND_FAILED = 'command_failed'; 32 | 33 | /** 34 | * Properties 35 | */ 36 | protected readonly string $type; 37 | protected readonly string $short; 38 | protected readonly bool $publish; 39 | 40 | /** 41 | * Constructor for the Exception class. 42 | * 43 | * @param string $type The type of exception. This will automatically set fallbacks for the other parameters. 44 | * @param string|null $message The error message. 45 | * @param int|null $code The error code. 46 | * @param \Throwable|null $previous The previous exception. 47 | */ 48 | public function __construct( 49 | string $type = Exception::GENERAL_UNKNOWN, 50 | ?string $message = null, 51 | ?int $code = null, 52 | ?\Throwable $previous = null 53 | ) { 54 | $errors = Config::getParam('errors'); 55 | 56 | $this->type = $type; 57 | $error = $errors[$type] ?? []; 58 | 59 | $this->message = $message ?? $error['message']; 60 | $this->code = $code ?? $error['code'] ?: 500; 61 | $this->short = $error['short'] ?? ''; 62 | 63 | $this->publish = $error['publish'] ?? true; 64 | 65 | parent::__construct($this->message, $this->code, $previous); 66 | } 67 | 68 | /** 69 | * Get the type of the exception. 70 | * 71 | * @return string 72 | */ 73 | public function getType(): string 74 | { 75 | return $this->type; 76 | } 77 | 78 | /** 79 | * Get the short version of the exception. 80 | * 81 | * @return string 82 | */ 83 | public function getShort(): string 84 | { 85 | return $this->short; 86 | } 87 | 88 | /** 89 | * Check whether the error message is publishable to logging systems (e.g. Sentry). 90 | * 91 | * @return bool 92 | */ 93 | public function isPublishable(): bool 94 | { 95 | return $this->publish; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/config/errors.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'name' => Exception::GENERAL_UNKNOWN, 9 | 'short' => 'Whoops', 10 | 'message' => 'Internal server error.', 11 | 'code' => 500, 12 | ], 13 | Exception::GENERAL_ROUTE_NOT_FOUND => [ 14 | 'name' => Exception::GENERAL_ROUTE_NOT_FOUND, 15 | 'short' => 'Not found', 16 | 'message' => 'The requested route was not found.', 17 | 'code' => 404, 18 | ], 19 | Exception::GENERAL_UNAUTHORIZED => [ 20 | 'name' => Exception::GENERAL_UNAUTHORIZED, 21 | 'short' => 'Unauthorized', 22 | 'message' => 'You are not authorized to access this resource.', 23 | 'code' => 401, 24 | ], 25 | /* Runtime */ 26 | Exception::RUNTIME_FAILED => [ 27 | 'name' => Exception::RUNTIME_FAILED, 28 | 'short' => 'Failed', 29 | 'message' => 'Runtime failed.', 30 | 'code' => 400, 31 | 'publish' => false, 32 | ], 33 | Exception::RUNTIME_TIMEOUT => [ 34 | 'name' => Exception::RUNTIME_TIMEOUT, 35 | 'short' => 'Timeout', 36 | 'message' => 'Timed out waiting for runtime.', 37 | 'code' => 400, 38 | ], 39 | Exception::RUNTIME_NOT_FOUND => [ 40 | 'name' => Exception::RUNTIME_CONFLICT, 41 | 'short' => 'Not found', 42 | 'message' => 'Runtime not found', 43 | 'code' => 404, 44 | ], 45 | Exception::RUNTIME_CONFLICT => [ 46 | 'name' => Exception::RUNTIME_CONFLICT, 47 | 'short' => 'Conflict', 48 | 'message' => 'Runtime already exists ', 49 | 'code' => 409, 50 | ], 51 | /* Execution */ 52 | Exception::EXECUTION_BAD_REQUEST => [ 53 | 'name' => Exception::EXECUTION_BAD_REQUEST, 54 | 'short' => 'Invalid request', 55 | 'message' => 'Execution request was invalid.', 56 | 'code' => 400, 57 | ], 58 | Exception::EXECUTION_BAD_JSON => [ 59 | 'name' => Exception::EXECUTION_BAD_JSON, 60 | 'short' => 'Invalid response', 61 | 'message' => 'Execution resulted in binary response, but JSON response does not allow binaries. Use "Accept: multipart/form-data" header to support binaries.', 62 | 'code' => 400, 63 | ], 64 | Exception::EXECUTION_TIMEOUT => [ 65 | 'name' => Exception::EXECUTION_TIMEOUT, 66 | 'short' => 'Timeout', 67 | 'message' => 'Timed out waiting for execution.', 68 | 'code' => 400, 69 | ], 70 | /* Logs */ 71 | Exception::LOGS_TIMEOUT => [ 72 | 'name' => Exception::LOGS_TIMEOUT, 73 | 'short' => 'Timeout', 74 | 'message' => 'Timed out waiting for logs.', 75 | 'code' => 504, 76 | ], 77 | /* Command */ 78 | Exception::COMMAND_TIMEOUT => [ 79 | 'name' => Exception::COMMAND_TIMEOUT, 80 | 'short' => 'Timeout', 81 | 'message' => 'Operation timed out.', 82 | 'code' => 500, 83 | ], 84 | Exception::COMMAND_FAILED => [ 85 | 'name' => Exception::COMMAND_FAILED, 86 | 'short' => 'Failed', 87 | 'message' => 'Failed to execute command.', 88 | 'code' => 500, 89 | ], 90 | ]; 91 | -------------------------------------------------------------------------------- /src/Executor/StorageFactory.php: -------------------------------------------------------------------------------- 1 | getScheme(); 42 | $accessKey = $dsn->getUser() ?? ''; 43 | $accessSecret = $dsn->getPassword() ?? ''; 44 | $host = $dsn->getHost(); 45 | $bucket = $dsn->getPath() ?? ''; 46 | $dsnRegion = $dsn->getParam('region'); 47 | $insecure = $dsn->getParam('insecure', 'false') === 'true'; 48 | $url = $dsn->getParam('url', ''); 49 | 50 | } catch (\Throwable $e) { 51 | Console::warning($e->getMessage() . ' - Invalid DSN. Defaulting to Local device.'); 52 | } 53 | 54 | switch ($deviceType) { 55 | case Storage::DEVICE_S3: 56 | if (!empty($url)) { 57 | return new S3($root, $accessKey, $accessSecret, $url, $dsnRegion, $acl); 58 | } 59 | if (!empty($host)) { 60 | $host = $insecure ? 'http://' . $host : $host; 61 | return new S3(root: $root, accessKey: $accessKey, secretKey: $accessSecret, host: $host, region: $dsnRegion, acl: $acl); 62 | } 63 | return new AWS(root: $root, accessKey: $accessKey, secretKey: $accessSecret, bucket: $bucket, region: $dsnRegion, acl: $acl); 64 | 65 | case Storage::DEVICE_DO_SPACES: 66 | return new DOSpaces($root, $accessKey, $accessSecret, $bucket, $dsnRegion, $acl); 67 | 68 | case Storage::DEVICE_BACKBLAZE: 69 | return new Backblaze($root, $accessKey, $accessSecret, $bucket, $dsnRegion, $acl); 70 | 71 | case Storage::DEVICE_LINODE: 72 | return new Linode($root, $accessKey, $accessSecret, $bucket, $dsnRegion, $acl); 73 | 74 | case Storage::DEVICE_WASABI: 75 | return new Wasabi($root, $accessKey, $accessSecret, $bucket, $dsnRegion, $acl); 76 | 77 | case Storage::DEVICE_LOCAL: 78 | default: 79 | return new Local($root); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Executor/Runner/Runtime.php: -------------------------------------------------------------------------------- 1 | $this->version, 55 | 'created' => $this->created, 56 | 'updated' => $this->updated, 57 | 'name' => $this->name, 58 | 'hostname' => $this->hostname, 59 | 'status' => $this->status, 60 | 'key' => $this->key, 61 | 'listening' => $this->listening, 62 | 'image' => $this->image, 63 | 'initialised' => $this->initialised 64 | ]; 65 | } 66 | 67 | /** 68 | * Converts a string-indexed array to a runtime instance. 69 | * 70 | * @param array{ 71 | * version: string, 72 | * created: float, 73 | * updated: float, 74 | * name: string, 75 | * hostname: string, 76 | * status: string, 77 | * key: string, 78 | * listening: int, 79 | * image: string, 80 | * initialised: int 81 | * } $data 82 | */ 83 | public static function fromArray(array $data): self 84 | { 85 | return new self( 86 | $data['version'], 87 | $data['created'], 88 | $data['updated'], 89 | $data['name'], 90 | $data['hostname'], 91 | $data['status'], 92 | $data['key'], 93 | $data['listening'], 94 | $data['image'], 95 | $data['initialised'] 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Client.php: -------------------------------------------------------------------------------- 1 | $baseHeaders 12 | */ 13 | public function __construct( 14 | private readonly string $endpoint, 15 | private array $baseHeaders = [] 16 | ) { 17 | } 18 | 19 | public function setKey(string $key): void 20 | { 21 | $this->baseHeaders['Authorization'] = 'Bearer ' . $key; 22 | } 23 | 24 | /** 25 | * Wrapper method for client calls to make requests to the executor 26 | * 27 | * @param string $method 28 | * @param string $path 29 | * @param array $headers 30 | * @param array $params 31 | * @param bool $decode 32 | * @param ?callable $callback 33 | * @return array 34 | */ 35 | public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, ?callable $callback = null): array 36 | { 37 | $url = $this->endpoint . $path; 38 | 39 | $client = new FetchClient(); 40 | $client->setTimeout(60); 41 | 42 | foreach ($this->baseHeaders as $key => $value) { 43 | $client->addHeader($key, $value); 44 | } 45 | foreach ($headers as $key => $value) { 46 | $client->addHeader($key, $value); 47 | } 48 | 49 | $response = $client->fetch( 50 | url: $url, 51 | method: $method, 52 | body: $method !== FetchClient::METHOD_GET ? $params : [], 53 | query: $method === FetchClient::METHOD_GET ? $params : [], 54 | chunks: $callback ? function ($chunk) use ($callback) { 55 | $callback($chunk->getData()); 56 | } : null 57 | ); 58 | 59 | $body = null; 60 | if ($callback === null) { 61 | if ($decode) { 62 | $contentType = $response->getHeaders()['content-type'] ?? ''; 63 | $strpos = strpos($contentType, ';'); 64 | $strpos = is_bool($strpos) ? strlen($contentType) : $strpos; 65 | $contentType = substr($contentType, 0, $strpos); 66 | 67 | switch ($contentType) { 68 | case 'multipart/form-data': 69 | $boundary = explode('boundary=', $response->getHeaders()['content-type'] ?? '')[1] ?? ''; 70 | $multipartResponse = new BodyMultipart($boundary); 71 | $multipartResponse->load($response->text()); 72 | $body = $multipartResponse->getParts(); 73 | break; 74 | case 'application/json': 75 | $body = $response->json(); 76 | break; 77 | default: 78 | $body = $response->text(); 79 | break; 80 | } 81 | } else { 82 | $body = $response->text(); 83 | } 84 | } 85 | 86 | $result = [ 87 | 'headers' => array_merge( 88 | $response->getHeaders(), 89 | ['status-code' => $response->getStatusCode()] 90 | ), 91 | 'body' => $body 92 | ]; 93 | 94 | return $result; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/init.php: -------------------------------------------------------------------------------- 1 | set('logger', function () { 25 | $providerName = System::getEnv('OPR_EXECUTOR_LOGGING_PROVIDER', ''); 26 | $providerConfig = System::getEnv('OPR_EXECUTOR_LOGGING_CONFIG', ''); 27 | 28 | try { 29 | $loggingProvider = new DSN($providerConfig ?? ''); 30 | 31 | $providerName = $loggingProvider->getScheme(); 32 | $providerConfig = match ($providerName) { 33 | 'sentry' => ['key' => $loggingProvider->getPassword(), 'projectId' => $loggingProvider->getUser() ?? '', 'host' => 'https://' . $loggingProvider->getHost()], 34 | 'logowl' => ['ticket' => $loggingProvider->getUser() ?? '', 'host' => $loggingProvider->getHost()], 35 | default => ['key' => $loggingProvider->getHost()], 36 | }; 37 | } catch (Throwable) { 38 | $configChunks = \explode(";", ($providerConfig ?? '')); 39 | 40 | $providerConfig = match ($providerName) { 41 | 'sentry' => ['key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',], 42 | 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''], 43 | default => ['key' => $providerConfig], 44 | }; 45 | } 46 | 47 | $logger = null; 48 | 49 | if (!empty($providerName) && is_array($providerConfig) && Logger::hasProvider($providerName)) { 50 | $adapter = match ($providerName) { 51 | 'sentry' => new Sentry($providerConfig['projectId'] ?? '', $providerConfig['key'] ?? '', $providerConfig['host'] ?? ''), 52 | 'logowl' => new LogOwl($providerConfig['ticket'] ?? '', $providerConfig['host'] ?? ''), 53 | 'raygun' => new Raygun($providerConfig['key'] ?? ''), 54 | 'appsignal' => new AppSignal($providerConfig['key'] ?? ''), 55 | default => throw new Exception('Provider "' . $providerName . '" not supported.') 56 | }; 57 | 58 | $logger = new Logger($adapter); 59 | } 60 | 61 | return $logger; 62 | }); 63 | 64 | /** Resources */ 65 | Http::setResource('log', function (?Route $route) { 66 | $log = new Log(); 67 | 68 | $log->setNamespace("executor"); 69 | $log->setEnvironment(Http::isProduction() ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); 70 | 71 | $version = (string) System::getEnv('OPR_EXECUTOR_VERSION', 'UNKNOWN'); 72 | $log->setVersion($version); 73 | 74 | $server = System::getEnv('OPR_EXECUTOR_LOGGING_IDENTIFIER', \gethostname() ?: 'UNKNOWN'); 75 | $log->setServer($server); 76 | 77 | if ($route) { 78 | $log->addTag('method', $route->getMethod()); 79 | $log->addTag('url', $route->getPath()); 80 | } 81 | 82 | return $log; 83 | }, ['route']); 84 | 85 | Http::setResource('register', fn () => $register); 86 | 87 | Http::setResource('logger', fn (Registry $register) => $register->get('logger'), ['register']); 88 | -------------------------------------------------------------------------------- /tests/unit/Executor/Runner/RuntimeTest.php: -------------------------------------------------------------------------------- 1 | assertSame('v5', $runtime->version); 28 | $this->assertSame($time, $runtime->created); 29 | $this->assertSame($time, $runtime->updated); 30 | $this->assertSame('runtime1', $runtime->name); 31 | $this->assertSame('runtime1', $runtime->hostname); 32 | $this->assertSame('pending', $runtime->status); 33 | $this->assertSame('secret', $runtime->key); 34 | $this->assertSame(0, $runtime->listening); 35 | $this->assertSame('php', $runtime->image); 36 | $this->assertSame(0, $runtime->initialised); 37 | } 38 | 39 | public function testFromArray(): void 40 | { 41 | $time = microtime(true); 42 | $runtime = Runtime::fromArray([ 43 | 'version' => 'v5', 44 | 'created' => $time, 45 | 'updated' => $time, 46 | 'name' => 'runtime1', 47 | 'hostname' => 'runtime1', 48 | 'status' => 'pending', 49 | 'key' => 'secret', 50 | 'listening' => 0, 51 | 'image' => 'php', 52 | 'initialised' => 0 53 | ]); 54 | 55 | $this->assertSame('v5', $runtime->version); 56 | $this->assertSame($time, $runtime->created); 57 | $this->assertSame($time, $runtime->updated); 58 | $this->assertSame('runtime1', $runtime->name); 59 | $this->assertSame('runtime1', $runtime->hostname); 60 | $this->assertSame('pending', $runtime->status); 61 | $this->assertSame('secret', $runtime->key); 62 | $this->assertSame(0, $runtime->listening); 63 | $this->assertSame('php', $runtime->image); 64 | $this->assertSame(0, $runtime->initialised); 65 | } 66 | 67 | public function testToArray(): void 68 | { 69 | $time = microtime(true); 70 | $runtime = new Runtime( 71 | version:'v5', 72 | created: $time, 73 | updated: $time, 74 | name: 'runtime1', 75 | hostname: 'runtime1', 76 | status: 'pending', 77 | key: 'secret', 78 | listening: 0, 79 | image: 'php', 80 | initialised: 0, 81 | ); 82 | $runtime = $runtime->toArray(); 83 | 84 | $this->assertSame('v5', $runtime['version']); 85 | $this->assertSame($time, $runtime['created']); 86 | $this->assertSame($time, $runtime['updated']); 87 | $this->assertSame('runtime1', $runtime['name']); 88 | $this->assertSame('runtime1', $runtime['hostname']); 89 | $this->assertSame('pending', $runtime['status']); 90 | $this->assertSame('secret', $runtime['key']); 91 | $this->assertSame(0, $runtime['listening']); 92 | $this->assertSame('php', $runtime['image']); 93 | $this->assertSame(0, $runtime['initialised']); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity, expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@appwrite.io. All complaints will 59 | be reviewed and investigated and will result in a response that is deemed 60 | necessary and appropriate to the circumstances. The project team is obligated 61 | to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /src/Executor/Runner/Adapter.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class Runtimes implements Iterator 13 | { 14 | private Table $runtimes; 15 | 16 | /** 17 | * Create a runtime repository. 18 | * This class should be initialized in the main application process, before workers are forked. 19 | * 20 | * @param int $size The size of the table. Swoole tables must be preallocated. 21 | */ 22 | public function __construct(int $size = 4096) 23 | { 24 | $this->runtimes = new Table($size); 25 | 26 | $this->runtimes->column('version', Table::TYPE_STRING, 32); 27 | $this->runtimes->column('created', Table::TYPE_FLOAT); 28 | $this->runtimes->column('updated', Table::TYPE_FLOAT); 29 | $this->runtimes->column('name', Table::TYPE_STRING, 1024); 30 | $this->runtimes->column('hostname', Table::TYPE_STRING, 1024); 31 | $this->runtimes->column('status', Table::TYPE_STRING, 256); 32 | $this->runtimes->column('key', Table::TYPE_STRING, 1024); 33 | $this->runtimes->column('listening', Table::TYPE_INT, 1); 34 | $this->runtimes->column('image', Table::TYPE_STRING, 1024); 35 | $this->runtimes->column('initialised', Table::TYPE_INT, 0); 36 | 37 | $this->runtimes->create(); 38 | } 39 | 40 | /** 41 | * Get a runtime by ID. 42 | * 43 | * @param string $id The ID of the runtime to retrieve. 44 | * @return Runtime|null The runtime object or null if not found. 45 | */ 46 | public function get(string $id): ?Runtime 47 | { 48 | $runtime = $this->runtimes->get($id); 49 | if ($runtime === false) { 50 | return null; 51 | } 52 | return Runtime::fromArray($runtime); 53 | } 54 | 55 | /** 56 | * Check if a runtime exists by ID. 57 | * 58 | * @param string $id The ID of the runtime to check. 59 | * @return bool True if the runtime exists, false otherwise. 60 | */ 61 | public function exists(string $id): bool 62 | { 63 | return $this->runtimes->exists($id); 64 | } 65 | 66 | /** 67 | * Set a runtime by ID. Existing runtime will be overwritten. 68 | * 69 | * @param string $id The ID of the runtime to update. 70 | * @param Runtime $runtime The updated runtime object. 71 | * @return void 72 | */ 73 | public function set(string $id, Runtime $runtime): void 74 | { 75 | $this->runtimes->set($id, $runtime->toArray()); 76 | } 77 | 78 | /** 79 | * Remove a runtime by ID. 80 | * 81 | * @param string $id The ID of the runtime to remove. 82 | * @return bool True if the runtime was removed, false otherwise. 83 | */ 84 | public function remove(string $id): bool 85 | { 86 | return $this->runtimes->del($id); 87 | } 88 | 89 | // Iterator traits 90 | public function current(): Runtime 91 | { 92 | $runtime = $this->runtimes->current(); 93 | return Runtime::fromArray($runtime); 94 | } 95 | 96 | public function next(): void 97 | { 98 | $this->runtimes->next(); 99 | } 100 | 101 | public function key(): string 102 | { 103 | return $this->runtimes->key(); 104 | } 105 | 106 | public function valid(): bool 107 | { 108 | return $this->runtimes->valid(); 109 | } 110 | 111 | public function rewind(): void 112 | { 113 | $this->runtimes->rewind(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/unit/Executor/Runner/Repository/RuntimesTest.php: -------------------------------------------------------------------------------- 1 | set('rt-1', $runtime); 28 | 29 | $this->assertTrue($repository->exists('rt-1')); 30 | 31 | $stored = $repository->get('rt-1'); 32 | 33 | $this->assertInstanceOf(Runtime::class, $stored); 34 | $this->assertSame($runtime->toArray(), $stored->toArray()); 35 | 36 | } 37 | 38 | public function testEmpty(): void 39 | { 40 | $repository = new Runtimes(16); 41 | $this->assertNull($repository->get('missing')); 42 | } 43 | 44 | public function testRemove(): void 45 | { 46 | $repository = new Runtimes(16); 47 | $runtime = new Runtime( 48 | version: 'v2', 49 | created: 1010.0, 50 | updated: 1011.0, 51 | name: 'runtime-two', 52 | hostname: 'runtime-two.host', 53 | status: 'pending', 54 | key: 'key-runtime-two', 55 | listening: 0, 56 | image: 'image-runtime-two', 57 | initialised: 0, 58 | ); 59 | 60 | $repository->set('rt-2', $runtime); 61 | 62 | $this->assertTrue($repository->remove('rt-2')); 63 | $this->assertFalse($repository->exists('rt-2')); 64 | $this->assertNull($repository->get('rt-2')); 65 | 66 | $this->assertFalse($repository->remove('rt-2')); 67 | } 68 | 69 | public function testIteration(): void 70 | { 71 | $repository = new Runtimes(16); 72 | $runtimeOne = new Runtime( 73 | version: 'v1', 74 | created: 1100.0, 75 | updated: 1101.0, 76 | name: 'runtime-one', 77 | hostname: 'runtime-one.host', 78 | status: 'pending', 79 | key: 'key-runtime-one', 80 | listening: 0, 81 | image: 'image-runtime-one', 82 | initialised: 0, 83 | ); 84 | $runtimeTwo = new Runtime( 85 | version: 'v2', 86 | created: 1200.0, 87 | updated: 1201.0, 88 | name: 'runtime-two', 89 | hostname: 'runtime-two.host', 90 | status: 'pending', 91 | key: 'key-runtime-two', 92 | listening: 0, 93 | image: 'image-runtime-two', 94 | initialised: 0, 95 | ); 96 | 97 | $repository->set('rt-1', $runtimeOne); 98 | $repository->set('rt-2', $runtimeTwo); 99 | 100 | $collected = []; 101 | 102 | foreach ($repository as $id => $runtime) { 103 | $this->assertInstanceOf(Runtime::class, $runtime); 104 | $collected[$id] = $runtime->toArray(); 105 | } 106 | 107 | $expected = [ 108 | 'rt-1' => $runtimeOne->toArray(), 109 | 'rt-2' => $runtimeTwo->toArray() 110 | ]; 111 | 112 | ksort($collected); 113 | ksort($expected); 114 | 115 | $this->assertSame($expected, $collected); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/Executor/Logs.php: -------------------------------------------------------------------------------- 1 | > 13 | */ 14 | public static function get(string $containerId): array 15 | { 16 | $output = []; 17 | 18 | $dir = "/tmp/$containerId/logging"; 19 | $logsFile = $dir . "/logs.txt"; 20 | $timingsFile = $dir . "/timings.txt"; 21 | 22 | if (!\file_exists($logsFile) || !\file_exists($timingsFile)) { 23 | return []; 24 | } 25 | 26 | $logs = \file_get_contents($logsFile) ?: ''; 27 | $timings = \file_get_contents($timingsFile) ?: ''; 28 | 29 | $offset = 0; // Current offset from timing for reading logs content 30 | $introOffset = self::getLogOffset($logs); 31 | 32 | $parts = self::parseTiming($timings); 33 | 34 | foreach ($parts as $part) { 35 | $timestamp = $part['timestamp'] ?? ''; 36 | $length = \intval($part['length'] ?? '0'); 37 | 38 | if ($offset >= MAX_BUILD_LOG_SIZE) { 39 | $output[] = [ 40 | 'timestamp' => $timestamp, 41 | 'content' => 'Logs truncated due to size exceeding ' . number_format(MAX_LOG_SIZE / 1048576, 2) . 'MB.', 42 | ]; 43 | break; 44 | } 45 | 46 | $logContent = \substr($logs, $introOffset + $offset, \abs($length)) ?: ''; 47 | 48 | $output[] = [ 49 | 'timestamp' => $timestamp, 50 | 'content' => $logContent 51 | ]; 52 | 53 | $offset += $length; 54 | } 55 | 56 | return $output; 57 | } 58 | 59 | /** 60 | * @return array> 61 | * @throws \Exception 62 | */ 63 | public static function parseTiming(string $timing, ?DateTime $datetime = null): array 64 | { 65 | if (\is_null($datetime)) { 66 | $datetime = new DateTime("now", new DateTimeZone("UTC")); // Date used for tracking absolute log timing 67 | } 68 | 69 | if (empty($timing)) { 70 | return []; 71 | } 72 | 73 | $parts = []; 74 | 75 | $rows = \explode("\n", $timing); 76 | foreach ($rows as $row) { 77 | if (empty($row)) { 78 | continue; 79 | } 80 | 81 | [$timing, $length] = \explode(' ', $row, 2); 82 | $timing = \floatval($timing); 83 | $timing = \ceil($timing * 1000000); // Convert to microseconds 84 | $length = \intval($length); 85 | 86 | $interval = DateInterval::createFromDateString($timing . ' microseconds'); 87 | 88 | if (!$interval) { 89 | throw new \Exception('Failed to create DateInterval from timing: ' . $timing); 90 | } 91 | 92 | $date = $datetime 93 | ->add($interval) 94 | ->format('Y-m-d\TH:i:s.vP'); 95 | 96 | $parts[] = [ 97 | 'timestamp' => $date, 98 | 'length' => $length 99 | ]; 100 | } 101 | 102 | return $parts; 103 | } 104 | 105 | public static function getLogOffset(string $logs): int 106 | { 107 | $contentSplit = \explode("\n", $logs, 2); // Find first linebreak to identify prefix 108 | $offset = \strlen($contentSplit[0] ?? ''); // Ignore script addition "Script started on..." 109 | $offset += 1; // Consider linebreak an intro too 110 | 111 | return $offset; 112 | } 113 | 114 | public static function getTimestamp(): string 115 | { 116 | return (new DateTime("now", new DateTimeZone("UTC")))->format('Y-m-d\TH:i:s.vP'); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Executor/BodyMultipart.php: -------------------------------------------------------------------------------- 1 | $parts 9 | */ 10 | private array $parts = []; 11 | private string $boundary = ""; 12 | 13 | public function __construct(?string $boundary = null) 14 | { 15 | if (is_null($boundary)) { 16 | $this->boundary = self::generateBoundary(); 17 | } else { 18 | $this->boundary = $boundary; 19 | } 20 | } 21 | 22 | public static function generateBoundary(): string 23 | { 24 | return '-----------------------------' . \uniqid(); 25 | } 26 | 27 | public function load(string $body): self 28 | { 29 | $eol = "\r\n"; 30 | 31 | $sections = \explode('--' . $this->boundary, $body); 32 | 33 | foreach ($sections as $section) { 34 | if (empty($section)) { 35 | continue; 36 | } 37 | 38 | if (strpos($section, $eol) === 0) { 39 | $section = substr($section, \strlen($eol)); 40 | } 41 | 42 | if (substr($section, -2) === $eol) { 43 | $section = substr($section, 0, -1 * \strlen($eol)); 44 | } 45 | 46 | if ($section == '--') { 47 | continue; 48 | } 49 | 50 | $partChunks = \explode($eol . $eol, $section, 2); 51 | 52 | if (\count($partChunks) < 2) { 53 | continue; // Broken part 54 | } 55 | 56 | [ $partHeaders, $partBody ] = $partChunks; 57 | $partHeaders = \explode($eol, $partHeaders); 58 | 59 | $partName = ""; 60 | foreach ($partHeaders as $partHeader) { 61 | if (!empty($partName)) { 62 | break; 63 | } 64 | 65 | $partHeaderArray = \explode(':', $partHeader, 2); 66 | 67 | $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); 68 | $partHeaderValue = $partHeaderArray[1] ?? ''; 69 | if ($partHeaderName == "content-disposition") { 70 | $dispositionChunks = \explode("; ", $partHeaderValue); 71 | foreach ($dispositionChunks as $dispositionChunk) { 72 | $dispositionChunkValues = \explode("=", $dispositionChunk, 2); 73 | if (\count($dispositionChunkValues) >= 2) { 74 | if ($dispositionChunkValues[0] === "name") { 75 | $partName = \trim($dispositionChunkValues[1], "\""); 76 | break; 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | if (!empty($partName)) { 84 | $this->parts[$partName] = $partBody; 85 | } 86 | } 87 | return $this; 88 | } 89 | 90 | /** 91 | * @return array 92 | */ 93 | public function getParts(): array 94 | { 95 | return $this->parts ?? []; 96 | } 97 | 98 | public function getPart(string $key, mixed $default = ''): mixed 99 | { 100 | return $this->parts[$key] ?? $default; 101 | } 102 | 103 | public function setPart(string $key, mixed $value): self 104 | { 105 | $this->parts[$key] = $value; 106 | return $this; 107 | } 108 | 109 | public function getBoundary(): string 110 | { 111 | return $this->boundary; 112 | } 113 | 114 | public function setBoundary(string $boundary): self 115 | { 116 | $this->boundary = $boundary; 117 | return $this; 118 | } 119 | 120 | public function exportHeader(): string 121 | { 122 | return 'multipart/form-data; boundary=' . $this->boundary; 123 | } 124 | 125 | public function exportBody(): string 126 | { 127 | $eol = "\r\n"; 128 | $query = '--' . $this->boundary; 129 | 130 | foreach ($this->parts as $key => $value) { 131 | $query .= $eol . 'Content-Disposition: form-data; name="' . $key . '"'; 132 | 133 | if ($value instanceof \CURLFile) { 134 | $filename = $value->getPostFilename() ?: \basename($value->getFilename()); 135 | $mime = $value->getMimeType() ?: 'application/octet-stream'; 136 | 137 | $query .= '; filename="' . $filename . '"' . $eol; 138 | $query .= 'Content-Type: ' . $mime . $eol . $eol; 139 | $query .= \file_get_contents($value->getFilename()) . $eol; 140 | } elseif (\is_array($value)) { 141 | $query .= $eol . 'Content-Type: application/json' . $eol . $eol; 142 | $query .= \json_encode($value) . $eol; 143 | } else { 144 | $query .= $eol . $eol . $value . $eol; 145 | } 146 | 147 | $query .= '--' . $this->boundary; 148 | } 149 | 150 | $query .= "--" . $eol; 151 | 152 | return $query; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We would ❤️ for you to contribute to Open Runtimes and help make it better! We want contributing to Open Runtmies to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more. 4 | 5 | ## How to Start? 6 | 7 | If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Open Runtimes team on Discord](https://discord.gg/mkZcevnxuf). You can also submit an issue, and a maintainer can guide you! 8 | 9 | ## Code of Conduct 10 | 11 | Help us keep Open Runtimes open and inclusive. Please read and follow our [Code of Conduct](/CODE_OF_CONDUCT.md). 12 | 13 | ## Submit a Pull Request 🚀 14 | 15 | Branch naming convention is as following 16 | 17 | `TYPE-ISSUE_ID-DESCRIPTION` 18 | 19 | example: 20 | 21 | ``` 22 | doc-548-submit-a-pull-request-section-to-contribution-guide 23 | ``` 24 | 25 | When `TYPE` can be: 26 | 27 | - **feat** - is a new feature 28 | - **doc** - documentation only changes 29 | - **cicd** - changes related to CI/CD system 30 | - **fix** - a bug fix 31 | - **refactor** - code change that neither fixes a bug nor adds a feature 32 | 33 | **All PRs must include a commit message with the changes description!** 34 | 35 | For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to: 36 | 37 | 1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date. 38 | 39 | ``` 40 | $ git pull 41 | ``` 42 | 43 | 2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
44 | 45 | ``` 46 | $ git checkout -b [name_of_your_new_branch] 47 | ``` 48 | 49 | 3. Work - commit - repeat ( be sure to be in your branch ) 50 | 51 | 4. Push changes to GitHub 52 | 53 | ``` 54 | $ git push origin [name_of_your_new_branch] 55 | ``` 56 | 57 | 5. Submit your changes for review 58 | If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. 59 | 6. Start a Pull Request 60 | Now submit the pull request and click on `Create pull request`. 61 | 7. Get a code review approval/reject 62 | 8. After approval, merge your PR 63 | 9. GitHub will automatically delete the branch after the merge is done. (they can still be restored). 64 | 65 | ## Running 66 | 67 | To run Open Runtimes Executor, make sure to install PHP dependencies: 68 | 69 | ```bash 70 | docker run --rm --interactive --tty --volume $PWD:/app composer composer install --profile --ignore-platform-reqs 71 | ``` 72 | 73 | Next start the Docker Compose stack that includes executor server with nessessary networks and volumes: 74 | 75 | ```bash 76 | docker compose up -d 77 | ``` 78 | 79 | You can now use `http://localhost:9800/v1/` endpoint to communicate with Open Runtimes Executor. You can see 'Getting Started' section of README to learn about endpoints. 80 | 81 | ## Testing 82 | 83 | We use PHP framework PHPUnit to test Open Runtimes. Every PR is automatically tested by Travis CI, and tests run for all runtimes. Since this is PHP source code, we also run [Pint](https://github.com/laravel/pint) linter and [PHPStan](https://phpstan.org/) code analysis. 84 | 85 | Before running the tests, make sure to install all required PHP libraries: 86 | 87 | ```bash 88 | composer install --profile --ignore-platform-reqs 89 | ``` 90 | 91 | > We run tests in separate Swoole container to ensure unit tests have all nessessary extensions ready. 92 | 93 | Once ready, you can test executor. 94 | 95 | To run tests, you need to start Docker Compose stack, and then run PHPUnit: 96 | 97 | ```bash 98 | docker compose up -d 99 | # Wait for ~5 seconds for executor to start 100 | docker run --rm -v $PWD:/app --network executor_runtimes -w /app phpswoole/swoole:5.1.2-php8.3-alpine sh -c \ "composer test" 101 | ``` 102 | 103 | To run linter, you need to run Pint: 104 | 105 | ```bash 106 | composer format 107 | ``` 108 | 109 | To run static code analysis, you need to run PHPStan: 110 | 111 | ```bash 112 | composer check 113 | ``` 114 | 115 | ## Introducing New Features 116 | 117 | We would 💖 you to contribute to Open Runtimes, but we would also like to make sure Open Runtimes is as great as possible and loyal to its vision and mission statement 🙏. 118 | 119 | For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request. 120 | 121 | This will allow the Open Runtimes community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision. 122 | 123 | This is also important for the Open Runtimes lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc). 124 | 125 | ## Other Ways to Help 126 | 127 | Pull requests are great, but there are many other areas where you can help Open Runtimes. 128 | 129 | ### Blogging & Speaking 130 | 131 | Blogging, speaking about, or creating tutorials about one of Open Runtimes many features is great way to contribute and help our project grow. 132 | 133 | ### Presenting at Meetups 134 | 135 | Presenting at meetups and conferences about your Open Runtimes projects. Your unique challenges and successes in building things with Open Runtimes can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help! 136 | 137 | ### Sending Feedbacks & Reporting Bugs 138 | 139 | Sending feedback is a great way for us to understand your different use cases of Open Runtimes better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/mkZcevnxuf). 140 | 141 | ### Submitting New Ideas 142 | 143 | If you think Open Runtimes could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature. 144 | 145 | ### Improving Documentation 146 | 147 | Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated. 148 | 149 | ### Helping Someone 150 | 151 | Searching for Open Runtimes, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Open Runtimes repo! -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Runtimes Executor 🤖 2 | 3 | ![open-runtimes-box-bg-cover](https://user-images.githubusercontent.com/1297371/151676246-0e18f694-dfd7-4bab-b64b-f590fec76ef1.png) 4 | 5 | --- 6 | 7 | [![Discord](https://img.shields.io/discord/937092945713172480?label=discord&style=flat-square)](https://discord.gg/mkZcevnxuf) 8 | [![Build Status](https://github.com/open-runtimes/executor/actions/workflows/tests.yml/badge.svg)](https://github.com/open-runtimes/executor/actions/workflows/tests.yml) 9 | [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) 10 | [![Docker Pulls](https://img.shields.io/docker/pulls/openruntimes/executor?color=f02e65&style=flat-square)](https://hub.docker.com/r/openruntimes/executor) 11 | 12 | Executor for [Open Runtimes](https://github.com/open-runtimes/open-runtimes), a runtime environments for serverless cloud computing for multiple coding languages. 13 | 14 | Executor is responsible for providing HTTP API for creating and executing Open Runtimes. Executor is stateless and can be scaled horizontally when a load balancer is introduced in front of it. You could use any load balancer but we highly recommend using [Open Runtimes Proxy](https://github.com/open-runtimes/proxy) for it's ease of setup with Open Runtimes Executor. 15 | 16 | ## Features 17 | 18 | * **Flexibility** - Configuring custom image lets you use **any** runtime for your functions. 19 | * **Performance** - Coroutine-style HTTP servers allows asynchronous operations without blocking. We. Run. Fast! ⚡ 20 | * **Open Source** - Released under the MIT license, free to use and extend. 21 | 22 | ## Getting Started 23 | 24 | 1. Pull Open Runtimes Executor image: 25 | 26 | ```bash 27 | docker pull openruntimes/executor 28 | ``` 29 | 30 | 2. Create `docker-compose.yml` file: 31 | 32 | ```yml 33 | version: '3' 34 | services: 35 | openruntimes-executor: 36 | container_name: openruntimes-executor 37 | hostname: executor 38 | stop_signal: SIGINT 39 | image: openruntimes/executor 40 | networks: 41 | openruntimes-runtimes: 42 | ports: 43 | - 9900:80 44 | volumes: 45 | - /var/run/docker.sock:/var/run/docker.sock 46 | - openruntimes-builds:/storage/builds:rw 47 | - openruntimes-functions:/storage/functions:rw 48 | - /tmp:/tmp:rw 49 | - ./functions:/storage/functions:rw 50 | environment: 51 | - OPR_EXECUTOR_ENV 52 | - OPR_EXECUTOR_RUNTIMES 53 | - OPR_EXECUTOR_CONNECTION_STORAGE 54 | - OPR_EXECUTOR_INACTIVE_THRESHOLD 55 | - OPR_EXECUTOR_MAINTENANCE_INTERVAL 56 | - OPR_EXECUTOR_NETWORK 57 | - OPR_EXECUTOR_SECRET 58 | - OPR_EXECUTOR_LOGGING_PROVIDER 59 | - OPR_EXECUTOR_LOGGING_CONFIG 60 | - OPR_EXECUTOR_DOCKER_HUB_USERNAME 61 | - OPR_EXECUTOR_DOCKER_HUB_PASSWORD 62 | - OPR_EXECUTOR_RUNTIME_VERSIONS 63 | - OPR_EXECUTOR_RETRY_ATTEMPTS 64 | - OPR_EXECUTOR_RETRY_DELAY_MS 65 | - OPR_EXECUTOR_IMAGE_PULL 66 | 67 | networks: 68 | openruntimes-runtimes: 69 | name: openruntimes-runtimes 70 | 71 | volumes: 72 | openruntimes-builds: 73 | openruntimes-functions: 74 | ``` 75 | 76 | > Notice we added bind to local `./functions` directory. That is only nessessary for this getting started, since we will be executing our custom function. 77 | 78 | 3. Create `.env` file: 79 | 80 | ``` 81 | OPR_EXECUTOR_ENV=development 82 | OPR_EXECUTOR_RUNTIMES=php-8.0 83 | OPR_EXECUTOR_CONNECTION_STORAGE=file://localhost 84 | OPR_EXECUTOR_IMAGE_PULL=enabled 85 | OPR_EXECUTOR_INACTIVE_THRESHOLD=60 86 | OPR_EXECUTOR_MAINTENANCE_INTERVAL=60 87 | OPR_EXECUTOR_NETWORK=openruntimes-runtimes 88 | OPR_EXECUTOR_SECRET=executor-secret-key 89 | OPR_EXECUTOR_LOGGING_PROVIDER= 90 | OPR_EXECUTOR_LOGGING_CONFIG= 91 | OPR_EXECUTOR_LOGGING_IDENTIFIER= 92 | OPR_EXECUTOR_DOCKER_HUB_USERNAME= 93 | OPR_EXECUTOR_DOCKER_HUB_PASSWORD= 94 | OPR_EXECUTOR_RUNTIME_VERSIONS=v5 95 | OPR_EXECUTOR_RETRY_ATTEMPTS=5 96 | OPR_EXECUTOR_RETRY_DELAY_MS=500 97 | ``` 98 | 99 | > `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` 100 | 101 | > For backwards compatibility, executor also supports `OPR_EXECUTOR_STORAGE_*` variables as replacement for `OPR_EXECUTOR_CONNECTION_STORAGE`, as seen in [Appwrite repository](https://github.com/appwrite/appwrite/blob/1.3.8/.env#L26-L46). 102 | 103 | 4. Start Docker container: 104 | 105 | ```bash 106 | docker compose up -d 107 | ``` 108 | 109 | 5. Prepare a function we will ask executor to run: 110 | 111 | ```bash 112 | mkdir -p functions && cd functions && mkdir -p php-function && cd php-function 113 | printf "json([ 'n' => \mt_rand() / \mt_getrandmax() ]);\n};" > index.php 114 | tar -czf ../my-function.tar.gz . 115 | cd .. && rm -r php-function 116 | ``` 117 | 118 | > This created `my-function.tar.gz` that includes `index.php` with a simple Open Runtimes script. 119 | 120 | 5. Send a HTTP request to executor server: 121 | 122 | ```bash 123 | curl -H "authorization: Bearer executor-secret-key" -H "Content-Type: application/json" -X POST http://localhost:9900/v1/runtimes/my-function/execution -d '{"image":"openruntimes/php:v2-8.0","source":"/storage/functions/my-function.tar.gz","entrypoint":"index.php"}' 124 | ``` 125 | 126 | 6. Stop Docker containers: 127 | 128 | ```bash 129 | docker compose down 130 | ``` 131 | 132 | ## API Endpoints 133 | 134 | | Method | Endpoint | Description | Params | 135 | |--------|----------|-------------| ------ | 136 | | GET |`/v1/runtimes/{runtimeId}/logs`| Get live stream of logs of a runtime | [JSON](#v1runtimesruntimeidlogs) | 137 | | POST |`/v1/runtimes`| Create a new runtime server | [JSON](#v1runtimes) | 138 | | GET |`/v1/runtimes`| List currently active runtimes | X | 139 | | GET |`/v1/runtimes/{runtimeId}`| Get a runtime by its ID | [JSON](#v1runtimesruntimeid) | 140 | | DELETE |`/v1/runtimes/{runtimeId}`| Delete a runtime | [JSON](#v1runtimesruntimeid) | 141 | | POST |`/v1/runtimes/{runtimeId}/executions`| Create an execution | [JSON](#v1runtimesruntimeidexecutions) | 142 | | GET |`/v1/health`| Get health status of host machine and runtimes | X | 143 | 144 | #### /v1/runtimes/{runtimeId}/logs 145 | | Param | Type | Description | Required | Default | 146 | |-------|------|-------------|----------|---------| 147 | | `runtimeId` | `string` | Runtime unique ID | ✅ | | 148 | | `timeout` | `string` | Maximum logs timeout in seconds | | '600' | 149 | 150 | #### /v1/runtimes 151 | | Param | Type | Description | Required | Default | 152 | |-------|------|-------------|----------|---------| 153 | | `runtimeId` | `string` | Runtime unique ID | ✅ | | 154 | | `image` | `string` | Base image name of the runtime | ✅ | | 155 | | `entrypoint` | `string` | Entrypoint of the code file | | ' ' | 156 | | `source` | `string` | Path to source files | | ' ' | 157 | | `destination` | `string` | Destination folder to store runtime files into | | ' ' | 158 | | `variables` | `json` | Environment variables passed into runtime | | [ ] | 159 | | `runtimeEntrypoint` | `string` | Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | 160 | | `command` | `string` | Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | 161 | | `timeout` | `integer` | Commands execution time in seconds | | 600 | 162 | | `remove` | `boolean` | Remove a runtime after execution | | false | 163 | | `cpus` | `float` | Maximum CPU cores runtime can utilize | | 1 | 164 | | `memory` | `integer` | Container RAM memory in MBs | | 512 | 165 | | `version` | `string` | Runtime Open Runtime version (allowed values: 'v2', 'v5') | | 'v5' | 166 | 167 | #### /v1/runtimes/{runtimeId} 168 | | Param | Type | Description | Required | Default | 169 | |-------|------|-------------|----------|---------| 170 | | `runtimeId` | `string` | Runtime unique ID | ✅ | | 171 | 172 | #### /v1/runtimes/{runtimeId}/executions 173 | | Param | Type | Description | Required | Default | 174 | |-------|------|-------------|----------|---------| 175 | | `runtimeId` | `string` | The runtimeID to execute | ✅ | | 176 | | `body` | `string` | Data to be forwarded to the function, this is user specified. | | ' ' | 177 | | `path` | `string` | Path from which execution comes | | '/' | 178 | | `method` | `array` | Path from which execution comes | | 'GET' | 179 | | `headers` | `json` | Headers passed into runtime | | [ ] | 180 | | `timeout` | `integer` | Function maximum execution time in seconds | | 15 | 181 | | `image` | `string` | Base image name of the runtime | | ' ' | 182 | | `source` | `string` | Path to source files | | ' ' | 183 | | `entrypoint` | `string` | Entrypoint of the code file | | ' ' | 184 | | `variables` | `json` | Environment variables passed into runtime | | [ ] | 185 | | `cpus` | `floats` | Maximum CPU cores runtime can utilize | | 1 | 186 | | `memory` | `integer` | Container RAM memory in MBs | | 512 | 187 | | `version` | `string` | Runtime Open Runtime version (allowed values: 'v2', 'v5') | | 'v5' | 188 | | `runtimeEntrypoint` | `string` | Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long. | | ' ' | 189 | 190 | ## Environment variables 191 | 192 | | Variable name | Description | 193 | |------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| 194 | | OPR_EXECUTOR_ENV | Environment mode of the executor, ex. `development` | 195 | | OPR_EXECUTOR_RUNTIMES | Comma-separated list of supported runtimes `(ex: php-8.1,dart-2.18,deno-1.24,..)`. These runtimes should be available as container images. | 196 | | OPR_EXECUTOR_CONNECTION_STORAGE | DSN string that represents a connection to your storage device, ex: `file://localhost` for local storage | 197 | | OPR_EXECUTOR_INACTIVE_THRESHOLD | Threshold time (in seconds) for detecting inactive runtimes, ex: `60` | 198 | | OPR_EXECUTOR_MAINTENANCE_INTERVAL| Interval (in seconds) at which the Executor performs maintenance tasks, ex: `60` | 199 | | OPR_EXECUTOR_NETWORK | Network used by the executor for runtimes, ex: `openruntimes-runtimes` | 200 | | OPR_EXECUTOR_SECRET | Secret key used by the executor for authentication | 201 | | OPR_EXECUTOR_LOGGING_PROVIDER | Deprecated: use `OPR_EXECUTOR_LOGGING_CONFIG` with DSN instead. External logging provider used by the executor, ex: `sentry` | 202 | | OPR_EXECUTOR_LOGGING_CONFIG | External logging provider DSN used by the executor, ex: `sentry://PROJECT_ID:SENTRY_API_KEY@SENTRY_HOST/` | 203 | | OPR_EXECUTOR_DOCKER_HUB_USERNAME | Username for Docker Hub authentication (if applicable) | 204 | | OPR_EXECUTOR_DOCKER_HUB_PASSWORD | Password for Docker Hub authentication (if applicable) | 205 | | OPR_EXECUTOR_RUNTIME_VERSIONS | Version tag for runtime environments, ex: `v5` | 206 | | OPR_EXECUTOR_RETRY_ATTEMPTS | Number of retry attempts for failed executions, ex: `5` | 207 | | OPR_EXECUTOR_RETRY_DELAY_MS | Delay (in milliseconds) between retry attempts, ex: `500` | 208 | | OPR_EXECUTOR_IMAGE_PULL | Pull open runtimes images before executor starts. Takes `disabled` and `enabled` | 209 | 210 | ## Contributing 211 | 212 | All code contributions - including those of people having commit access - must go through a pull request and be approved by a core developer before being merged. This is to ensure a proper review of all the code. 213 | 214 | We truly ❤️ pull requests! If you wish to help, you can learn more about how you can contribute to this project in the [contribution guide](CONTRIBUTING.md). 215 | 216 | ## Security 217 | 218 | For security issues, kindly email us at [security@appwrite.io](mailto:security@appwrite.io) instead of posting a public issue on GitHub. 219 | 220 | ## Follow Us 221 | 222 | Join our growing community around the world! See our official [Blog](https://medium.com/appwrite-io). Follow us on [Twitter](https://twitter.com/appwrite), [Facebook Page](https://www.facebook.com/appwrite.io), [Facebook Group](https://www.facebook.com/groups/appwrite.developers/) , [Dev Community](https://dev.to/appwrite) or join our live [Discord server](https://discord.gg/mkZcevnxuf) for more help, ideas, and discussions. 223 | 224 | ## License 225 | 226 | This repository is available under the [MIT License](./LICENSE). 227 | -------------------------------------------------------------------------------- /app/controllers.php: -------------------------------------------------------------------------------- 1 | groups(['api', 'runtimes']) 24 | ->desc("Get live stream of logs of a runtime") 25 | ->param('runtimeId', '', new Text(64), 'Runtime unique ID.') 26 | ->param('timeout', '600', new Text(16), 'Maximum logs timeout.', true) 27 | ->inject('response') 28 | ->inject('log') 29 | ->inject('runner') 30 | ->action(function (string $runtimeId, string $timeoutStr, Response $response, Log $log, Runner $runner) { 31 | $timeout = \intval($timeoutStr); 32 | 33 | $response->sendHeader('Content-Type', 'text/event-stream'); 34 | $response->sendHeader('Cache-Control', 'no-cache'); 35 | 36 | $runner->getLogs($runtimeId, $timeout, $response, $log); 37 | 38 | $response->end(); 39 | }); 40 | 41 | Http::post('/v1/runtimes/:runtimeId/commands') 42 | ->desc('Execute a command inside an existing runtime') 43 | ->param('runtimeId', '', new Text(64), 'Unique runtime ID.') 44 | ->param('command', '', new Text(1024), 'Command to execute.') 45 | ->param('timeout', 600, new Integer(), 'Commands execution time in seconds.', true) 46 | ->inject('response') 47 | ->inject('runner') 48 | ->action(function (string $runtimeId, string $command, int $timeout, Response $response, Runner $runner) { 49 | $output = $runner->executeCommand($runtimeId, $command, $timeout); 50 | $response->setStatusCode(Response::STATUS_CODE_OK)->json([ 'output' => $output ]); 51 | }); 52 | 53 | Http::post('/v1/runtimes') 54 | ->groups(['api']) 55 | ->desc("Create a new runtime server") 56 | ->param('runtimeId', '', new Text(64), 'Unique runtime ID.') 57 | ->param('image', '', new Text(128), 'Base image name of the runtime.') 58 | ->param('entrypoint', '', new Text(256, 0), 'Entrypoint of the code file.', true) 59 | ->param('source', '', new Text(0, 0), 'Path to source files.', true) 60 | ->param('destination', '', new Text(0, 0), 'Destination folder to store runtime files into.', true) 61 | ->param('outputDirectory', '', new Text(0, 0), 'Path inside build to use as output. If empty, entire build is used.', true) 62 | ->param('variables', [], new Assoc(), 'Environment variables passed into runtime.', true) 63 | ->param('runtimeEntrypoint', '', new Text(1024, 0), 'Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long.', true) 64 | ->param('command', '', new Text(1024, 0), 'Commands to run after container is created. Maximum of 100 commands are allowed, each 1024 characters long.', true) 65 | ->param('timeout', 600, new Integer(), 'Commands execution time in seconds.', true) 66 | ->param('remove', false, new Boolean(), 'Remove a runtime after execution.', true) 67 | ->param('cpus', 1, new FloatValidator(true), 'Container CPU.', true) 68 | ->param('memory', 512, new Integer(), 'Container RAM memory.', true) 69 | ->param('version', 'v5', new WhiteList(\explode(',', System::getEnv('OPR_EXECUTOR_RUNTIME_VERSIONS', 'v5') ?? 'v5')), 'Runtime Open Runtime version.', true) 70 | ->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) 71 | ->inject('response') 72 | ->inject('log') 73 | ->inject('runner') 74 | ->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, Log $log, Runner $runner) { 75 | $secret = \bin2hex(\random_bytes(16)); 76 | 77 | /** 78 | * Create container 79 | */ 80 | $variables = \array_merge($variables, match ($version) { 81 | 'v2' => [ 82 | 'INTERNAL_RUNTIME_KEY' => $secret, 83 | 'INTERNAL_RUNTIME_ENTRYPOINT' => $entrypoint, 84 | 'INERNAL_EXECUTOR_HOSTNAME' => System::getHostname() 85 | ], 86 | 'v4', 'v5' => [ 87 | 'OPEN_RUNTIMES_SECRET' => $secret, 88 | 'OPEN_RUNTIMES_ENTRYPOINT' => $entrypoint, 89 | 'OPEN_RUNTIMES_HOSTNAME' => System::getHostname(), 90 | 'OPEN_RUNTIMES_CPUS' => $cpus, 91 | 'OPEN_RUNTIMES_MEMORY' => $memory, 92 | ], 93 | default => [], 94 | }); 95 | 96 | if (!empty($outputDirectory)) { 97 | $variables = \array_merge($variables, [ 98 | 'OPEN_RUNTIMES_OUTPUT_DIRECTORY' => $outputDirectory 99 | ]); 100 | } 101 | 102 | $variables = \array_merge($variables, [ 103 | 'CI' => 'true' 104 | ]); 105 | 106 | $variables = array_map(fn ($v) => strval($v), $variables); 107 | 108 | $container = $runner->createRuntime($runtimeId, $secret, $image, $entrypoint, $source, $destination, $variables, $runtimeEntrypoint, $command, $timeout, $remove, $cpus, $memory, $version, $restartPolicy, $log); 109 | $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($container); 110 | }); 111 | 112 | Http::get('/v1/runtimes') 113 | ->groups(['api']) 114 | ->desc("List currently active runtimes") 115 | ->inject('runner') 116 | ->inject('response') 117 | ->action(function (Runner $runner, Response $response) { 118 | $response->setStatusCode(Response::STATUS_CODE_OK)->json($runner->getRuntimes()); 119 | }); 120 | 121 | Http::get('/v1/runtimes/:runtimeId') 122 | ->groups(['api', 'runtimes']) 123 | ->desc("Get a runtime by its ID") 124 | ->param('runtimeId', '', new Text(64), 'Runtime unique ID.') 125 | ->inject('runner') 126 | ->inject('response') 127 | ->action(function (string $runtimeId, Runner $runner, Response $response) { 128 | $runtimeName = System::getHostname() . '-' . $runtimeId; 129 | $response->setStatusCode(Response::STATUS_CODE_OK)->json($runner->getRuntime($runtimeName)); 130 | }); 131 | 132 | Http::delete('/v1/runtimes/:runtimeId') 133 | ->groups(['api', 'runtimes']) 134 | ->desc('Delete a runtime') 135 | ->param('runtimeId', '', new Text(64), 'Runtime unique ID.') 136 | ->inject('response') 137 | ->inject('log') 138 | ->inject('runner') 139 | ->action(function (string $runtimeId, Response $response, Log $log, Runner $runner) { 140 | $runner->deleteRuntime($runtimeId, $log); 141 | $response->setStatusCode(Response::STATUS_CODE_OK)->send(); 142 | }); 143 | 144 | Http::post('/v1/runtimes/:runtimeId/executions') 145 | ->groups(['api', 'runtimes']) 146 | ->alias('/v1/runtimes/:runtimeId/execution') 147 | ->desc('Create an execution') 148 | // Execution-related 149 | ->param('runtimeId', '', new Text(64), 'The runtimeID to execute.') 150 | ->param('body', '', new Text(20971520), 'Data to be forwarded to the function, this is user specified.', true) 151 | ->param('path', '/', new Text(2048), 'Path from which execution comes.', true) 152 | ->param('method', 'GET', new Whitelist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'], true), 'Path from which execution comes.', true) 153 | ->param('headers', [], new AnyOf([new Text(65535), new Assoc()], AnyOf::TYPE_MIXED), 'Headers passed into runtime.', true) 154 | ->param('timeout', 15, new Integer(true), 'Function maximum execution time in seconds.', true) 155 | // Runtime-related 156 | ->param('image', '', new Text(128), 'Base image name of the runtime.', true) 157 | ->param('source', '', new Text(0), 'Path to source files.', true) 158 | ->param('entrypoint', '', new Text(256, 0), 'Entrypoint of the code file.', true) 159 | ->param('variables', [], new AnyOf([new Text(65535), new Assoc()], AnyOf::TYPE_MIXED), 'Environment variables passed into runtime.', true) 160 | ->param('cpus', 1, new FloatValidator(true), 'Container CPU.', true) 161 | ->param('memory', 512, new Integer(true), 'Container RAM memory.', true) 162 | ->param('version', 'v5', new WhiteList(\explode(',', System::getEnv('OPR_EXECUTOR_RUNTIME_VERSIONS', 'v5') ?? 'v5')), 'Runtime Open Runtime version.', true) 163 | ->param('runtimeEntrypoint', '', new Text(1024, 0), 'Commands to run when creating a container. Maximum of 100 commands are allowed, each 1024 characters long.', true) 164 | ->param('logging', true, new Boolean(true), 'Whether executions will be logged.', true) 165 | ->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 once exit code is returned by command. Default value is "no". Possible values are "no", "always", "on-failure", "unless-stopped".', true) 166 | ->inject('response') 167 | ->inject('request') 168 | ->inject('log') 169 | ->inject('runner') 170 | ->action( 171 | function ( 172 | string $runtimeId, 173 | ?string $payload, 174 | string $path, 175 | string $method, 176 | mixed $headers, 177 | int $timeout, 178 | string $image, 179 | string $source, 180 | string $entrypoint, 181 | mixed $variables, 182 | float $cpus, 183 | int $memory, 184 | string $version, 185 | string $runtimeEntrypoint, 186 | bool $logging, 187 | string $restartPolicy, 188 | Response $response, 189 | Request $request, 190 | Log $log, 191 | Runner $runner 192 | ) { 193 | // Extra parsers and validators to support both JSON and multipart 194 | $intParams = ['timeout', 'memory']; 195 | foreach ($intParams as $intParam) { 196 | if (!empty($$intParam) && !is_numeric($$intParam)) { 197 | $$intParam = \intval($$intParam); 198 | } 199 | } 200 | 201 | $floatParams = ['cpus']; 202 | foreach ($floatParams as $floatPram) { 203 | if (!empty($$floatPram) && !is_numeric($$floatPram)) { 204 | $$floatPram = \floatval($$floatPram); 205 | } 206 | } 207 | 208 | /** 209 | * @var array $headers 210 | * @var array $variables 211 | */ 212 | $assocParams = ['headers', 'variables']; 213 | foreach ($assocParams as $assocParam) { 214 | if (!empty($$assocParam) && !is_array($$assocParam)) { 215 | $$assocParam = \json_decode($$assocParam, true); 216 | } 217 | } 218 | 219 | $booleanParams = ['logging']; 220 | foreach ($booleanParams as $booleamParam) { 221 | if (!empty($$booleamParam) && !is_bool($$booleamParam)) { 222 | $$booleamParam = $$booleamParam === "true" ? true : false; 223 | } 224 | } 225 | 226 | // 'headers' validator 227 | $validator = new Assoc(); 228 | if (!$validator->isValid($headers)) { 229 | throw new Exception(Exception::EXECUTION_BAD_REQUEST, $validator->getDescription()); 230 | } 231 | 232 | // 'variables' validator 233 | $validator = new Assoc(); 234 | if (!$validator->isValid($variables)) { 235 | throw new Exception(Exception::EXECUTION_BAD_REQUEST, $validator->getDescription()); 236 | } 237 | 238 | if (empty($payload)) { 239 | $payload = ''; 240 | } 241 | 242 | $variables = array_map(fn ($v) => strval($v), $variables); 243 | 244 | $execution = $runner->createExecution( 245 | $runtimeId, 246 | $payload, 247 | $path, 248 | $method, 249 | $headers, 250 | $timeout, 251 | $image, 252 | $source, 253 | $entrypoint, 254 | $variables, 255 | $cpus, 256 | $memory, 257 | $version, 258 | $runtimeEntrypoint, 259 | $logging, 260 | $restartPolicy, 261 | $log 262 | ); 263 | 264 | // Backwards compatibility for headers 265 | $responseFormat = $request->getHeader('x-executor-response-format', '0.10.0'); // Last version without support for array value for headers 266 | if (version_compare($responseFormat, '0.11.0', '<')) { 267 | foreach ($execution['headers'] as $key => $value) { 268 | if (\is_array($value)) { 269 | $execution['headers'][$key] = $value[\array_key_last($value)] ?? ''; 270 | } 271 | } 272 | } 273 | 274 | $acceptTypes = \explode(', ', $request->getHeader('accept', 'multipart/form-data')); 275 | $isJson = false; 276 | 277 | foreach ($acceptTypes as $acceptType) { 278 | if (\str_starts_with($acceptType, 'application/json') || \str_starts_with($acceptType, 'application/*')) { 279 | $isJson = true; 280 | break; 281 | } 282 | } 283 | 284 | if ($isJson) { 285 | $executionString = \json_encode($execution, JSON_UNESCAPED_UNICODE); 286 | if (!$executionString) { 287 | throw new Exception(Exception::EXECUTION_BAD_JSON); 288 | } 289 | 290 | $response 291 | ->setStatusCode(Response::STATUS_CODE_OK) 292 | ->addHeader('content-type', 'application/json') 293 | ->send($executionString); 294 | } else { 295 | // Multipart form data response 296 | 297 | $multipart = new BodyMultipart(); 298 | foreach ($execution as $key => $value) { 299 | $multipart->setPart($key, $value); 300 | } 301 | 302 | $response 303 | ->setStatusCode(Response::STATUS_CODE_OK) 304 | ->addHeader('content-type', $multipart->exportHeader()) 305 | ->send($multipart->exportBody()); 306 | } 307 | } 308 | ); 309 | 310 | Http::get('/v1/health') 311 | ->groups(['api']) 312 | ->desc("Get health status of host machine and runtimes.") 313 | ->inject('runner') 314 | ->inject('response') 315 | ->action(function (Runner $runner, Response $response) { 316 | $stats = $runner->getStats(); 317 | $output = [ 318 | 'usage' => $stats->getHostUsage(), 319 | 'runtimes' => $stats->getContainerUsage(), 320 | ]; 321 | $response->setStatusCode(Response::STATUS_CODE_OK)->json($output); 322 | }); 323 | 324 | Http::init() 325 | ->groups(['api']) 326 | ->inject('request') 327 | ->action(function (Request $request) { 328 | $secretKey = \explode(' ', $request->getHeader('authorization', ''))[1] ?? ''; 329 | if (empty($secretKey) || $secretKey !== System::getEnv('OPR_EXECUTOR_SECRET', '')) { 330 | throw new Exception(Exception::GENERAL_UNAUTHORIZED, 'Missing executor key'); 331 | } 332 | }); 333 | -------------------------------------------------------------------------------- /src/Executor/Runner/Docker.php: -------------------------------------------------------------------------------- 1 | stats = new Stats(); 46 | $this->init($networks); 47 | } 48 | 49 | /** 50 | * @param string[] $networks 51 | * @return void 52 | * @throws \Utopia\Http\Exception 53 | */ 54 | private function init(array $networks): void 55 | { 56 | /* 57 | * Remove residual runtimes and networks 58 | */ 59 | Console::info('Removing orphan runtimes and networks...'); 60 | $this->cleanUp(); 61 | Console::success("Orphan runtimes and networks removal finished."); 62 | 63 | /** 64 | * Create and store Docker Bridge networks used for communication between executor and runtimes 65 | */ 66 | Console::info('Creating networks...'); 67 | $createdNetworks = $this->createNetworks($networks); 68 | $this->networks = $createdNetworks; 69 | 70 | /** 71 | * Warmup: make sure images are ready to run fast 🚀 72 | */ 73 | $allowList = empty(System::getEnv('OPR_EXECUTOR_RUNTIMES')) ? [] : \explode(',', System::getEnv('OPR_EXECUTOR_RUNTIMES')); 74 | 75 | if (System::getEnv('OPR_EXECUTOR_IMAGE_PULL', 'enabled') === 'disabled') { 76 | // Useful to prevent auto-pulling from remote when testing local images 77 | Console::info("Skipping image pulling"); 78 | } else { 79 | $runtimeVersions = \explode(',', System::getEnv('OPR_EXECUTOR_RUNTIME_VERSIONS', 'v5') ?? 'v5'); 80 | foreach ($runtimeVersions as $runtimeVersion) { 81 | Console::success("Pulling $runtimeVersion images..."); 82 | $images = new AppwriteRuntimes($runtimeVersion); // TODO: @Meldiron Make part of open runtimes 83 | $images = $images->getAll(true, $allowList); 84 | $callables = []; 85 | foreach ($images as $image) { 86 | $callables[] = function () use ($image) { 87 | Console::log('Warming up ' . $image['name'] . ' ' . $image['version'] . ' environment...'); 88 | $response = $this->orchestration->pull($image['image']); 89 | if ($response) { 90 | Console::info("Successfully Warmed up {$image['name']} {$image['version']}!"); 91 | } else { 92 | Console::warning("Failed to Warmup {$image['name']} {$image['version']}!"); 93 | } 94 | }; 95 | } 96 | 97 | batch($callables); 98 | } 99 | } 100 | 101 | Console::success("Image pulling finished."); 102 | 103 | /** 104 | * Run a maintenance worker every X seconds to remove inactive runtimes 105 | */ 106 | Console::info('Starting maintenance interval...'); 107 | $interval = (int)System::getEnv('OPR_EXECUTOR_MAINTENANCE_INTERVAL', '3600'); // In seconds 108 | Timer::tick($interval * 1000, function () { 109 | Console::info("Running maintenance task ..."); 110 | // Stop idling runtimes 111 | foreach ($this->runtimes as $runtimeName => $runtime) { 112 | $inactiveThreshold = \time() - \intval(System::getEnv('OPR_EXECUTOR_INACTIVE_THRESHOLD', '60')); 113 | if ($runtime->updated < $inactiveThreshold) { 114 | go(function () use ($runtimeName, $runtime) { 115 | try { 116 | $this->orchestration->remove($runtime->name, true); 117 | Console::success("Successfully removed {$runtime->name}"); 118 | } catch (Throwable $th) { 119 | Console::error('Inactive Runtime deletion failed: ' . $th->getMessage()); 120 | } finally { 121 | $this->runtimes->remove($runtimeName); 122 | } 123 | }); 124 | } 125 | } 126 | 127 | // Clear leftover build folders 128 | $localDevice = new Local(); 129 | $tmpPath = DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR; 130 | $entries = $localDevice->getFiles($tmpPath); 131 | $prefix = $tmpPath . System::getHostname() . '-'; 132 | foreach ($entries as $entry) { 133 | if (\str_starts_with($entry, $prefix)) { 134 | $isActive = false; 135 | 136 | foreach ($this->runtimes as $runtimeName => $runtime) { 137 | if (\str_ends_with($entry, $runtimeName)) { 138 | $isActive = true; 139 | break; 140 | } 141 | } 142 | 143 | if (!$isActive) { 144 | $localDevice->deletePath($entry); 145 | } 146 | } 147 | } 148 | 149 | Console::success("Maintanance task finished."); 150 | }); 151 | 152 | Console::success('Maintenance interval started.'); 153 | 154 | /** 155 | * Get usage stats every X seconds to update swoole table 156 | */ 157 | Console::info('Starting stats interval...'); 158 | $getStats = function (): void { 159 | // Get usage stats 160 | $usage = new Usage($this->orchestration); 161 | $usage->run(); 162 | $this->stats->updateStats($usage); 163 | }; 164 | 165 | // Load initial stats in blocking way 166 | $getStats(); 167 | 168 | // Setup infinite recursion in non-blocking way 169 | \go(fn () => Timer::after(1000, fn () => $getStats())); 170 | 171 | Console::success('Stats interval started.'); 172 | 173 | Process::signal(SIGINT, fn () => $this->cleanUp($this->networks)); 174 | Process::signal(SIGQUIT, fn () => $this->cleanUp($this->networks)); 175 | Process::signal(SIGKILL, fn () => $this->cleanUp($this->networks)); 176 | Process::signal(SIGTERM, fn () => $this->cleanUp($this->networks)); 177 | } 178 | 179 | /** 180 | * @param string $runtimeId 181 | * @param int $timeout 182 | * @param Response $response 183 | * @param Log $log 184 | * @return void 185 | */ 186 | public function getLogs(string $runtimeId, int $timeout, Response $response, Log $log): void 187 | { 188 | $runtimeName = System::getHostname() . '-' . $runtimeId; 189 | 190 | $tmpFolder = "tmp/$runtimeName/"; 191 | $tmpLogging = "/{$tmpFolder}logging"; // Build logs 192 | 193 | // TODO: Combine 3 checks below into one 194 | 195 | // Wait for runtime 196 | for ($i = 0; $i < 10; $i++) { 197 | $output = ''; 198 | $code = Console::execute('docker container inspect ' . \escapeshellarg($runtimeName), '', $output); 199 | if ($code === 0) { 200 | break; 201 | } 202 | 203 | if ($i === 9) { 204 | throw new \Exception('Runtime not ready. Container not found.'); 205 | } 206 | 207 | \usleep(500000); // 0.5s 208 | } 209 | 210 | // Wait for state 211 | $version = null; 212 | $checkStart = \microtime(true); 213 | while (true) { 214 | if (\microtime(true) - $checkStart >= 10) { // Enforced timeout of 10s 215 | throw new Exception(Exception::RUNTIME_TIMEOUT); 216 | } 217 | 218 | $runtime = $this->runtimes->get($runtimeName); 219 | if (!empty($runtime)) { 220 | $version = $runtime->version; 221 | break; 222 | } 223 | 224 | \usleep(500000); // 0.5s 225 | } 226 | 227 | if ($version === 'v2') { 228 | return; 229 | } 230 | 231 | // Wait for logging files 232 | $checkStart = \microtime(true); 233 | while (true) { 234 | if (\microtime(true) - $checkStart >= $timeout) { 235 | throw new Exception(Exception::LOGS_TIMEOUT); 236 | } 237 | 238 | if (\file_exists($tmpLogging . '/logs.txt') && \file_exists($tmpLogging . '/timings.txt')) { 239 | $timings = \file_get_contents($tmpLogging . '/timings.txt') ?: ''; 240 | if (\strlen($timings) > 0) { 241 | break; 242 | } 243 | } 244 | 245 | // Ensure runtime is still present 246 | $runtime = $this->runtimes->get($runtimeName); 247 | if ($runtime === null) { 248 | return; 249 | } 250 | 251 | \usleep(500000); // 0.5s 252 | } 253 | 254 | /** 255 | * @var mixed $logsChunk 256 | */ 257 | $logsChunk = ''; 258 | 259 | /** 260 | * @var mixed $logsProcess 261 | */ 262 | $logsProcess = null; 263 | 264 | $streamInterval = 1000; // 1 second 265 | $activeRuntimes = $this->runtimes; 266 | $timerId = Timer::tick($streamInterval, function () use (&$logsProcess, &$logsChunk, $response, $activeRuntimes, $runtimeName) { 267 | $runtime = $activeRuntimes->get($runtimeName); 268 | if ($runtime === null) { 269 | return; 270 | } 271 | 272 | if ($runtime->initialised === 1) { 273 | if (!empty($logsChunk)) { 274 | $write = $response->write($logsChunk); 275 | $logsChunk = ''; 276 | } 277 | 278 | \proc_terminate($logsProcess, 9); 279 | return; 280 | } 281 | 282 | if (empty($logsChunk)) { 283 | return; 284 | } 285 | 286 | $write = $response->write($logsChunk); 287 | $logsChunk = ''; 288 | 289 | if (!$write) { 290 | if (!empty($logsProcess)) { 291 | \proc_terminate($logsProcess, 9); 292 | } 293 | } 294 | }); 295 | 296 | $offset = 0; // Current offset from timing for reading logs content 297 | $tempLogsContent = \file_get_contents($tmpLogging . '/logs.txt') ?: ''; 298 | $introOffset = Logs::getLogOffset($tempLogsContent); 299 | 300 | $datetime = new \DateTime("now", new \DateTimeZone("UTC")); // Date used for tracking absolute log timing 301 | 302 | $output = ''; // Unused, just a refference for stdout 303 | Console::execute('tail -F ' . $tmpLogging . '/timings.txt', '', $output, $timeout, function (string $timingChunk, mixed $process) use ($tmpLogging, &$logsChunk, &$logsProcess, &$datetime, &$offset, $introOffset) { 304 | $logsProcess = $process; 305 | 306 | if (!\file_exists($tmpLogging . '/logs.txt')) { 307 | if (!empty($logsProcess)) { 308 | \proc_terminate($logsProcess, 9); 309 | } 310 | return; 311 | } 312 | 313 | $parts = Logs::parseTiming($timingChunk, $datetime); 314 | 315 | foreach ($parts as $part) { 316 | $timestamp = $part['timestamp'] ?? ''; 317 | $length = \intval($part['length'] ?? '0'); 318 | 319 | $logContent = \file_get_contents($tmpLogging . '/logs.txt', false, null, $introOffset + $offset, \abs($length)) ?: ''; 320 | 321 | $logContent = \str_replace("\n", "\\n", $logContent); 322 | 323 | $output = $timestamp . " " . $logContent . "\n"; 324 | 325 | $logsChunk .= $output; 326 | $offset += $length; 327 | } 328 | }); 329 | 330 | if (!$timerId) { 331 | throw new \Exception('Failed to create timer'); 332 | } 333 | 334 | Timer::clear($timerId); 335 | } 336 | 337 | public function executeCommand(string $runtimeId, string $command, int $timeout): string 338 | { 339 | $runtimeName = System::getHostname() . '-' . $runtimeId; 340 | 341 | if (!$this->runtimes->exists($runtimeName)) { 342 | throw new Exception(Exception::RUNTIME_NOT_FOUND); 343 | } 344 | 345 | $commands = [ 346 | 'bash', 347 | '-c', 348 | $command 349 | ]; 350 | 351 | $output = ''; 352 | 353 | try { 354 | $this->orchestration->execute($runtimeName, $commands, $output, [], $timeout); 355 | return $output; 356 | } catch (TimeoutException $e) { 357 | throw new Exception(Exception::COMMAND_TIMEOUT, previous: $e); 358 | } catch (OrchestrationException $e) { 359 | throw new Exception(Exception::COMMAND_FAILED, previous: $e); 360 | } 361 | } 362 | 363 | /** 364 | * @param string $runtimeId 365 | * @param string $secret 366 | * @param string $image 367 | * @param string $entrypoint 368 | * @param string $source 369 | * @param string $destination 370 | * @param string[] $variables 371 | * @param string $runtimeEntrypoint 372 | * @param string $command 373 | * @param int $timeout 374 | * @param bool $remove 375 | * @param float $cpus 376 | * @param int $memory 377 | * @param string $version 378 | * @param string $restartPolicy 379 | * @param Log $log 380 | * @return mixed 381 | */ 382 | public function createRuntime( 383 | string $runtimeId, 384 | string $secret, 385 | string $image, 386 | string $entrypoint, 387 | string $source, 388 | string $destination, 389 | array $variables, 390 | string $runtimeEntrypoint, 391 | string $command, 392 | int $timeout, 393 | bool $remove, 394 | float $cpus, 395 | int $memory, 396 | string $version, 397 | string $restartPolicy, 398 | Log $log, 399 | string $region = '', 400 | ): mixed { 401 | $runtimeName = System::getHostname() . '-' . $runtimeId; 402 | $runtimeHostname = \bin2hex(\random_bytes(16)); 403 | 404 | if ($this->runtimes->exists($runtimeName)) { 405 | $existingRuntime = $this->runtimes->get($runtimeName); 406 | if ($existingRuntime !== null && $existingRuntime->status === 'pending') { 407 | throw new Exception(Exception::RUNTIME_CONFLICT, 'A runtime with the same ID is already being created. Attempt a execution soon.'); 408 | } 409 | 410 | throw new Exception(Exception::RUNTIME_CONFLICT); 411 | } 412 | 413 | /** @var array $container */ 414 | $container = []; 415 | $output = []; 416 | $startTime = \microtime(true); 417 | 418 | $runtime = new Runtime( 419 | version: $version, 420 | created: $startTime, 421 | updated: $startTime, 422 | name: $runtimeName, 423 | hostname: $runtimeHostname, 424 | status: 'pending', 425 | key: $secret, 426 | listening: 0, 427 | image: $image, 428 | initialised: 0, 429 | ); 430 | $this->runtimes->set($runtimeName, $runtime); 431 | 432 | /** 433 | * Temporary file paths in the executor 434 | */ 435 | $buildFile = "code.tar.gz"; 436 | if (($variables['OPEN_RUNTIMES_BUILD_COMPRESSION'] ?? '') === 'none') { 437 | $buildFile = "code.tar"; 438 | } 439 | 440 | $sourceFile = "code.tar.gz"; 441 | if (!empty($source) && \pathinfo($source, PATHINFO_EXTENSION) === 'tar') { 442 | $sourceFile = "code.tar"; 443 | } 444 | 445 | $tmpFolder = "tmp/$runtimeName/"; 446 | $tmpSource = "/{$tmpFolder}src/$sourceFile"; 447 | $tmpBuild = "/{$tmpFolder}builds/$buildFile"; 448 | $tmpLogging = "/{$tmpFolder}logging"; // Build logs 449 | $tmpLogs = "/{$tmpFolder}logs"; // Runtime logs 450 | 451 | $sourceDevice = StorageFactory::getDevice("/", System::getEnv('OPR_EXECUTOR_CONNECTION_STORAGE')); 452 | $localDevice = new Local(); 453 | 454 | try { 455 | /** 456 | * Copy code files from source to a temporary location on the executor 457 | */ 458 | if (!empty($source)) { 459 | if (!$sourceDevice->transfer($source, $tmpSource, $localDevice)) { 460 | throw new \Exception('Failed to copy source code to temporary directory'); 461 | }; 462 | } 463 | 464 | /** 465 | * Create the mount folder 466 | */ 467 | if (!$localDevice->createDirectory(\dirname($tmpBuild))) { 468 | throw new \Exception("Failed to create temporary directory"); 469 | } 470 | 471 | $this->orchestration 472 | ->setCpus($cpus) 473 | ->setMemory($memory); 474 | 475 | if (empty($runtimeEntrypoint)) { 476 | if ($version === 'v2' && empty($command)) { 477 | $runtimeEntrypointCommands = []; 478 | } else { 479 | $runtimeEntrypointCommands = ['tail', '-f', '/dev/null']; 480 | } 481 | } else { 482 | $runtimeEntrypointCommands = ['bash', '-c', $runtimeEntrypoint]; 483 | } 484 | 485 | $codeMountPath = $version === 'v2' ? '/usr/code' : '/mnt/code'; 486 | $workdir = $version === 'v2' ? '/usr/code' : ''; 487 | 488 | $network = $this->networks[array_rand($this->networks)]; 489 | 490 | $volumes = [ 491 | \dirname($tmpSource) . ':/tmp:rw', 492 | \dirname($tmpBuild) . ':' . $codeMountPath . ':rw', 493 | ]; 494 | 495 | if ($version === 'v5') { 496 | $volumes[] = \dirname($tmpLogs . '/logs') . ':/mnt/logs:rw'; 497 | $volumes[] = \dirname($tmpLogging . '/logging') . ':/tmp/logging:rw'; 498 | } 499 | 500 | /** Keep the container alive if we have commands to be executed */ 501 | $containerId = $this->orchestration->run( 502 | image: $image, 503 | name: $runtimeName, 504 | command: $runtimeEntrypointCommands, 505 | workdir: $workdir, 506 | volumes: $volumes, 507 | vars: $variables, 508 | labels: [ 509 | 'openruntimes-executor' => System::getHostname(), 510 | 'openruntimes-runtime-id' => $runtimeId 511 | ], 512 | hostname: $runtimeHostname, 513 | network: $network, 514 | restart: $restartPolicy 515 | ); 516 | 517 | if (empty($containerId)) { 518 | throw new \Exception('Failed to create runtime'); 519 | } 520 | 521 | /** 522 | * Execute any commands if they were provided 523 | */ 524 | if (!empty($command)) { 525 | if ($version === 'v2') { 526 | $commands = [ 527 | 'sh', 528 | '-c', 529 | 'touch /var/tmp/logs.txt && (' . $command . ') >> /var/tmp/logs.txt 2>&1 && cat /var/tmp/logs.txt' 530 | ]; 531 | } else { 532 | $commands = [ 533 | 'bash', 534 | '-c', 535 | 'mkdir -p /tmp/logging && touch /tmp/logging/timings.txt && touch /tmp/logging/logs.txt && script --log-out /tmp/logging/logs.txt --flush --log-timing /tmp/logging/timings.txt --return --quiet --command "' . \str_replace('"', '\"', $command) . '"' 536 | ]; 537 | } 538 | 539 | try { 540 | $stdout = ''; 541 | $status = $this->orchestration->execute( 542 | name: $runtimeName, 543 | command: $commands, 544 | output: $stdout, 545 | timeout: $timeout 546 | ); 547 | 548 | if (!$status) { 549 | throw new Exception(Exception::RUNTIME_FAILED, "Failed to create runtime: $stdout"); 550 | } 551 | 552 | if ($version === 'v2') { 553 | $stdout = \mb_substr($stdout ?: 'Runtime created successfully!', -MAX_BUILD_LOG_SIZE); // Limit to 1MB 554 | $output[] = [ 555 | 'timestamp' => Logs::getTimestamp(), 556 | 'content' => $stdout 557 | ]; 558 | } else { 559 | $output = Logs::get($runtimeName); 560 | } 561 | } catch (Throwable $err) { 562 | throw new Exception(Exception::RUNTIME_FAILED, $err->getMessage(), null, $err); 563 | } 564 | } 565 | 566 | /** 567 | * Move built code to expected build directory 568 | */ 569 | if (!empty($destination)) { 570 | // Check if the build was successful by checking if file exists 571 | if (!$localDevice->exists($tmpBuild)) { 572 | throw new \Exception('Something went wrong when starting runtime.'); 573 | } 574 | 575 | $size = $localDevice->getFileSize($tmpBuild); 576 | $container['size'] = $size; 577 | 578 | $destinationDevice = StorageFactory::getDevice($destination, System::getEnv('OPR_EXECUTOR_CONNECTION_STORAGE')); 579 | $path = $destinationDevice->getPath(\uniqid() . '.' . \pathinfo($tmpBuild, PATHINFO_EXTENSION)); 580 | 581 | if (!$localDevice->transfer($tmpBuild, $path, $destinationDevice)) { 582 | throw new \Exception('Failed to move built code to storage'); 583 | }; 584 | 585 | $container['path'] = $path; 586 | } 587 | 588 | $endTime = \microtime(true); 589 | $duration = $endTime - $startTime; 590 | 591 | $container = array_merge($container, [ 592 | 'output' => $output, 593 | 'startTime' => $startTime, 594 | 'duration' => $duration, 595 | ]); 596 | 597 | $runtime = $this->runtimes->get($runtimeName); 598 | if ($runtime !== null) { 599 | $runtime->updated = \microtime(true); 600 | $runtime->status = 'Up ' . \round($duration, 2) . 's'; 601 | $runtime->initialised = 1; 602 | 603 | $this->runtimes->set($runtimeName, $runtime); 604 | } 605 | } catch (Throwable $th) { 606 | if ($version === 'v2') { 607 | $message = !empty($output) ? $output : $th->getMessage(); 608 | try { 609 | $logs = ''; 610 | $status = $this->orchestration->execute( 611 | name: $runtimeName, 612 | command: ['sh', '-c', 'cat /var/tmp/logs.txt'], 613 | output: $logs, 614 | timeout: 15 615 | ); 616 | 617 | if (!empty($logs)) { 618 | $message = $logs; 619 | } 620 | 621 | $message = \mb_substr($message, -MAX_BUILD_LOG_SIZE); // Limit to 1MB 622 | } catch (Throwable $err) { 623 | // Ignore, use fallback error message 624 | } 625 | 626 | $output = [ 627 | 'timestamp' => Logs::getTimestamp(), 628 | 'content' => $message 629 | ]; 630 | } else { 631 | $output = Logs::get($runtimeName); 632 | $output = \count($output) > 0 ? $output : [[ 633 | 'timestamp' => Logs::getTimestamp(), 634 | 'content' => $th->getMessage() 635 | ]]; 636 | } 637 | 638 | if ($remove) { 639 | \sleep(2); // Allow time to read logs 640 | } 641 | 642 | // Silently try to kill container 643 | try { 644 | $this->orchestration->remove($runtimeName, true); 645 | } catch (Throwable $th) { 646 | } 647 | 648 | $localDevice->deletePath($tmpFolder); 649 | $this->runtimes->remove($runtimeName); 650 | 651 | $message = ''; 652 | foreach ($output as $chunk) { 653 | $message .= $chunk['content']; 654 | } 655 | 656 | throw new \Exception($message, $th->getCode() ?: 500, $th); 657 | } 658 | 659 | // Container cleanup 660 | if ($remove) { 661 | \sleep(2); // Allow time to read logs 662 | 663 | // Silently try to kill container 664 | try { 665 | $this->orchestration->remove($runtimeName, true); 666 | } catch (Throwable $th) { 667 | } 668 | 669 | $localDevice->deletePath($tmpFolder); 670 | $this->runtimes->remove($runtimeName); 671 | } 672 | 673 | // Remove weird symbol characters (for example from Next.js) 674 | if (\is_array($container['output'])) { 675 | foreach ($container['output'] as $index => &$chunk) { 676 | $chunk['content'] = \mb_convert_encoding($chunk['content'] ?? '', 'UTF-8', 'UTF-8'); 677 | } 678 | } 679 | 680 | return $container; 681 | } 682 | 683 | /** 684 | * @param string $runtimeId 685 | * @param Log $log 686 | * @return void 687 | */ 688 | public function deleteRuntime(string $runtimeId, Log $log): void 689 | { 690 | $runtimeName = System::getHostname() . '-' . $runtimeId; 691 | 692 | if (!$this->runtimes->exists($runtimeName)) { 693 | throw new Exception(Exception::RUNTIME_NOT_FOUND); 694 | } 695 | 696 | $this->orchestration->remove($runtimeName, true); 697 | $this->runtimes->remove($runtimeName); 698 | } 699 | 700 | /** 701 | * @param string $runtimeId 702 | * @param string|null $payload 703 | * @param string $path 704 | * @param string $method 705 | * @param mixed $headers 706 | * @param int $timeout 707 | * @param string $image 708 | * @param string $source 709 | * @param string $entrypoint 710 | * @param mixed $variables 711 | * @param float $cpus 712 | * @param int $memory 713 | * @param string $version 714 | * @param string $runtimeEntrypoint 715 | * @param bool $logging 716 | * @param string $restartPolicy 717 | * @param Log $log 718 | * @return mixed 719 | * @throws Exception 720 | */ 721 | public function createExecution( 722 | string $runtimeId, 723 | ?string $payload, 724 | string $path, 725 | string $method, 726 | mixed $headers, 727 | int $timeout, 728 | string $image, 729 | string $source, 730 | string $entrypoint, 731 | mixed $variables, 732 | float $cpus, 733 | int $memory, 734 | string $version, 735 | string $runtimeEntrypoint, 736 | bool $logging, 737 | string $restartPolicy, 738 | Log $log, 739 | string $region = '', 740 | ): mixed { 741 | $runtimeName = System::getHostname() . '-' . $runtimeId; 742 | 743 | $variables = \array_merge($variables, [ 744 | 'INERNAL_EXECUTOR_HOSTNAME' => System::getHostname() 745 | ]); 746 | 747 | $prepareStart = \microtime(true); 748 | 749 | // Prepare runtime 750 | if (!$this->runtimes->exists($runtimeName)) { 751 | if (empty($image) || empty($source)) { 752 | throw new Exception(Exception::RUNTIME_NOT_FOUND, 'Runtime not found. Please start it first or provide runtime-related parameters.'); 753 | } 754 | 755 | // Prepare request to executor 756 | $sendCreateRuntimeRequest = function () use ($runtimeId, $image, $source, $entrypoint, $variables, $cpus, $memory, $version, $restartPolicy, $runtimeEntrypoint) { 757 | $ch = \curl_init(); 758 | 759 | $body = \json_encode([ 760 | 'runtimeId' => $runtimeId, 761 | 'image' => $image, 762 | 'source' => $source, 763 | 'entrypoint' => $entrypoint, 764 | 'variables' => $variables, 765 | 'cpus' => $cpus, 766 | 'memory' => $memory, 767 | 'version' => $version, 768 | 'restartPolicy' => $restartPolicy, 769 | 'runtimeEntrypoint' => $runtimeEntrypoint 770 | ]); 771 | 772 | \curl_setopt($ch, CURLOPT_URL, "http://127.0.0.1/v1/runtimes"); 773 | \curl_setopt($ch, CURLOPT_POST, true); 774 | \curl_setopt($ch, CURLOPT_POSTFIELDS, $body); 775 | \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 776 | \curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); 777 | 778 | \curl_setopt($ch, CURLOPT_HTTPHEADER, [ 779 | 'Content-Type: application/json', 780 | 'Content-Length: ' . \strlen($body ?: ''), 781 | 'authorization: Bearer ' . System::getEnv('OPR_EXECUTOR_SECRET', '') 782 | ]); 783 | 784 | $executorResponse = \curl_exec($ch); 785 | 786 | $statusCode = \curl_getinfo($ch, CURLINFO_HTTP_CODE); 787 | 788 | $error = \curl_error($ch); 789 | 790 | $errNo = \curl_errno($ch); 791 | 792 | \curl_close($ch); 793 | 794 | return [ 795 | 'errNo' => $errNo, 796 | 'error' => $error, 797 | 'statusCode' => $statusCode, 798 | 'executorResponse' => $executorResponse 799 | ]; 800 | }; 801 | 802 | // Prepare runtime 803 | while (true) { 804 | // If timeout is passed, stop and return error 805 | if (\microtime(true) - $prepareStart >= $timeout) { 806 | throw new Exception(Exception::RUNTIME_TIMEOUT); 807 | } 808 | 809 | ['errNo' => $errNo, 'error' => $error, 'statusCode' => $statusCode, 'executorResponse' => $executorResponse] = \call_user_func($sendCreateRuntimeRequest); 810 | 811 | if ($errNo === 0) { 812 | if (\is_string($executorResponse)) { 813 | $body = \json_decode($executorResponse, true); 814 | } else { 815 | $body = []; 816 | } 817 | 818 | if ($statusCode >= 500) { 819 | // If the runtime has not yet attempted to start, it will return 500 820 | $error = $body['message']; 821 | } elseif ($statusCode >= 400 && $statusCode !== 409) { 822 | // If the runtime fails to start, it will return 400, except for 409 823 | // which indicates that the runtime is already being created 824 | $error = $body['message']; 825 | throw new \Exception('An internal curl error has occurred while starting runtime! Error Msg: ' . $error, 500); 826 | } else { 827 | break; 828 | } 829 | } elseif ($errNo !== 111) { 830 | // Connection refused - see https://openswoole.com/docs/swoole-error-code 831 | throw new \Exception('An internal curl error has occurred while starting runtime! Error Msg: ' . $error, 500); 832 | } 833 | 834 | \usleep(500000); // 0.5s 835 | } 836 | } 837 | 838 | // Lower timeout by time it took to prepare container 839 | $timeout -= (\microtime(true) - $prepareStart); 840 | 841 | // Update runtimes 842 | $runtime = $this->runtimes->get($runtimeName); 843 | if ($runtime !== null) { 844 | $runtime->updated = \time(); 845 | $this->runtimes->set($runtimeName, $runtime); 846 | } 847 | 848 | // Ensure runtime started 849 | $launchStart = \microtime(true); 850 | while (true) { 851 | // If timeout is passed, stop and return error 852 | if (\microtime(true) - $launchStart >= $timeout) { 853 | throw new Exception(Exception::RUNTIME_TIMEOUT); 854 | } 855 | 856 | $runtimeStatus = $this->runtimes->get($runtimeName); 857 | if ($runtimeStatus === null) { 858 | throw new Exception(Exception::RUNTIME_NOT_FOUND, 'Runtime no longer exists.'); 859 | } 860 | 861 | if ($runtimeStatus->status !== 'pending') { 862 | break; 863 | } 864 | 865 | \usleep(500000); // 0.5s 866 | } 867 | 868 | // Lower timeout by time it took to launch container 869 | $timeout -= (\microtime(true) - $launchStart); 870 | 871 | // Ensure we have secret 872 | $runtime = $this->runtimes->get($runtimeName); 873 | if ($runtime === null) { 874 | throw new Exception(Exception::RUNTIME_NOT_FOUND, 'Runtime secret not found. Please re-create the runtime.', 500); 875 | } 876 | $hostname = $runtime->hostname; 877 | $secret = $runtime->key; 878 | if (empty($secret)) { 879 | throw new \Exception('Runtime secret not found. Please re-create the runtime.', 500); 880 | } 881 | 882 | $executeV2 = function () use ($variables, $payload, $secret, $hostname, $timeout): array { 883 | $statusCode = 0; 884 | $errNo = -1; 885 | $executorResponse = ''; 886 | 887 | $ch = \curl_init(); 888 | 889 | $body = \json_encode([ 890 | 'variables' => $variables, 891 | 'payload' => $payload, 892 | 'headers' => [] 893 | ], JSON_FORCE_OBJECT); 894 | 895 | \curl_setopt($ch, CURLOPT_URL, "http://" . $hostname . ":3000/"); 896 | \curl_setopt($ch, CURLOPT_POST, true); 897 | \curl_setopt($ch, CURLOPT_POSTFIELDS, $body); 898 | \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 899 | \curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 900 | \curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); 901 | 902 | \curl_setopt($ch, CURLOPT_HTTPHEADER, [ 903 | 'Content-Type: application/json', 904 | 'Content-Length: ' . \strlen($body ?: ''), 905 | 'x-internal-challenge: ' . $secret, 906 | 'host: null' 907 | ]); 908 | 909 | $executorResponse = \curl_exec($ch); 910 | 911 | $statusCode = \curl_getinfo($ch, CURLINFO_HTTP_CODE); 912 | 913 | $error = \curl_error($ch); 914 | 915 | $errNo = \curl_errno($ch); 916 | 917 | \curl_close($ch); 918 | 919 | if ($errNo !== 0) { 920 | return [ 921 | 'errNo' => $errNo, 922 | 'error' => $error, 923 | 'statusCode' => $statusCode, 924 | 'body' => '', 925 | 'logs' => '', 926 | 'errors' => '', 927 | 'headers' => [] 928 | ]; 929 | } 930 | 931 | // Extract response 932 | $executorResponse = json_decode(\strval($executorResponse), false); 933 | 934 | $res = $executorResponse->response ?? ''; 935 | if (is_array($res)) { 936 | $res = json_encode($res, JSON_UNESCAPED_UNICODE); 937 | } elseif (is_object($res)) { 938 | $res = json_encode($res, JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT); 939 | } 940 | 941 | $stderr = $executorResponse->stderr ?? ''; 942 | $stdout = $executorResponse->stdout ?? ''; 943 | 944 | return [ 945 | 'errNo' => $errNo, 946 | 'error' => $error, 947 | 'statusCode' => $statusCode, 948 | 'body' => $res, 949 | 'logs' => $stdout, 950 | 'errors' => $stderr, 951 | 'headers' => [] 952 | ]; 953 | }; 954 | 955 | $executeV5 = function () use ($path, $method, $headers, $payload, $secret, $hostname, $timeout, $runtimeName, $logging): array { 956 | $statusCode = 0; 957 | $errNo = -1; 958 | $executorResponse = ''; 959 | 960 | $ch = \curl_init(); 961 | 962 | $responseHeaders = []; 963 | 964 | if (!(\str_starts_with($path, '/'))) { 965 | $path = '/' . $path; 966 | } 967 | 968 | \curl_setopt($ch, CURLOPT_URL, "http://" . $hostname . ":3000" . $path); 969 | \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); 970 | \curl_setopt($ch, CURLOPT_NOBODY, \strtoupper($method) === 'HEAD'); 971 | 972 | if (!empty($payload)) { 973 | \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); 974 | } 975 | 976 | \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 977 | \curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { 978 | $len = strlen($header); 979 | $header = explode(':', $header, 2); 980 | if (count($header) < 2) { // ignore invalid headers 981 | return $len; 982 | } 983 | 984 | $key = strtolower(trim($header[0])); 985 | $value = trim($header[1]); 986 | 987 | if (\in_array($key, ['x-open-runtimes-log-id'])) { 988 | $value = \urldecode($value); 989 | } 990 | 991 | if (\array_key_exists($key, $responseHeaders)) { 992 | if (is_array($responseHeaders[$key])) { 993 | $responseHeaders[$key][] = $value; 994 | } else { 995 | $responseHeaders[$key] = [$responseHeaders[$key], $value]; 996 | } 997 | } else { 998 | $responseHeaders[$key] = $value; 999 | } 1000 | 1001 | return $len; 1002 | }); 1003 | 1004 | \curl_setopt($ch, CURLOPT_TIMEOUT, $timeout + 5); // Gives extra 5s after safe timeout to recieve response 1005 | \curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); 1006 | if ($logging === true) { 1007 | $headers['x-open-runtimes-logging'] = 'enabled'; 1008 | } else { 1009 | $headers['x-open-runtimes-logging'] = 'disabled'; 1010 | } 1011 | 1012 | $headers['Authorization'] = 'Basic ' . \base64_encode('opr:' . $secret); 1013 | $headers['x-open-runtimes-secret'] = $secret; 1014 | 1015 | $headers['x-open-runtimes-timeout'] = \max(\intval($timeout), 1); 1016 | $headersArr = []; 1017 | foreach ($headers as $key => $value) { 1018 | $headersArr[] = $key . ': ' . $value; 1019 | } 1020 | 1021 | \curl_setopt($ch, CURLOPT_HEADEROPT, CURLHEADER_UNIFIED); 1022 | \curl_setopt($ch, CURLOPT_HTTPHEADER, $headersArr); 1023 | 1024 | $executorResponse = \curl_exec($ch); 1025 | 1026 | $statusCode = \curl_getinfo($ch, CURLINFO_HTTP_CODE); 1027 | 1028 | $error = \curl_error($ch); 1029 | 1030 | $errNo = \curl_errno($ch); 1031 | 1032 | \curl_close($ch); 1033 | 1034 | if ($errNo !== 0) { 1035 | return [ 1036 | 'errNo' => $errNo, 1037 | 'error' => $error, 1038 | 'statusCode' => $statusCode, 1039 | 'body' => '', 1040 | 'logs' => '', 1041 | 'errors' => '', 1042 | 'headers' => $responseHeaders 1043 | ]; 1044 | } 1045 | 1046 | // Extract logs and errors from file based on fileId in header 1047 | $fileId = $responseHeaders['x-open-runtimes-log-id'] ?? ''; 1048 | if (\is_array($fileId)) { 1049 | $fileId = $fileId[0] ?? ''; 1050 | } 1051 | $logs = ''; 1052 | $errors = ''; 1053 | if (!empty($fileId)) { 1054 | $logFile = '/tmp/' . $runtimeName . '/logs/' . $fileId . '_logs.log'; 1055 | $errorFile = '/tmp/' . $runtimeName . '/logs/' . $fileId . '_errors.log'; 1056 | 1057 | $logDevice = new Local(); 1058 | 1059 | if ($logDevice->exists($logFile)) { 1060 | if ($logDevice->getFileSize($logFile) > MAX_LOG_SIZE) { 1061 | $maxToRead = MAX_LOG_SIZE; 1062 | $logs = $logDevice->read($logFile, 0, $maxToRead); 1063 | $logs .= "\nLog file has been truncated to " . number_format(MAX_LOG_SIZE / 1048576, 2) . "MB."; 1064 | } else { 1065 | $logs = $logDevice->read($logFile); 1066 | } 1067 | 1068 | $logDevice->delete($logFile); 1069 | } 1070 | 1071 | if ($logDevice->exists($errorFile)) { 1072 | if ($logDevice->getFileSize($errorFile) > MAX_LOG_SIZE) { 1073 | $maxToRead = MAX_LOG_SIZE; 1074 | $errors = $logDevice->read($errorFile, 0, $maxToRead); 1075 | $errors .= "\nError file has been truncated to " . number_format(MAX_LOG_SIZE / 1048576, 2) . "MB."; 1076 | } else { 1077 | $errors = $logDevice->read($errorFile); 1078 | } 1079 | 1080 | $logDevice->delete($errorFile); 1081 | } 1082 | } 1083 | 1084 | $outputHeaders = []; 1085 | foreach ($responseHeaders as $key => $value) { 1086 | if (\str_starts_with($key, 'x-open-runtimes-')) { 1087 | continue; 1088 | } 1089 | 1090 | $outputHeaders[$key] = $value; 1091 | } 1092 | 1093 | return [ 1094 | 'errNo' => $errNo, 1095 | 'error' => $error, 1096 | 'statusCode' => $statusCode, 1097 | 'body' => $executorResponse, 1098 | 'logs' => $logs, 1099 | 'errors' => $errors, 1100 | 'headers' => $outputHeaders 1101 | ]; 1102 | }; 1103 | 1104 | // From here we calculate billable duration of execution 1105 | $startTime = \microtime(true); 1106 | 1107 | if (empty($runtime->listening)) { 1108 | // Wait for cold-start to finish (app listening on port) 1109 | $pingStart = \microtime(true); 1110 | $validator = new TCP(); 1111 | while (true) { 1112 | // If timeout is passed, stop and return error 1113 | if (\microtime(true) - $pingStart >= $timeout) { 1114 | throw new Exception(Exception::RUNTIME_TIMEOUT); 1115 | } 1116 | 1117 | $online = $validator->isValid($hostname . ':' . 3000); 1118 | if ($online) { 1119 | break; 1120 | } 1121 | 1122 | \usleep(500000); // 0.5s 1123 | } 1124 | 1125 | // Update swoole table 1126 | $runtime = $this->runtimes->get($runtimeName); 1127 | if ($runtime !== null) { 1128 | $runtime->listening = 1; 1129 | $this->runtimes->set($runtimeName, $runtime); 1130 | } 1131 | 1132 | // Lower timeout by time it took to cold-start 1133 | $timeout -= (\microtime(true) - $pingStart); 1134 | } 1135 | 1136 | // Execute function 1137 | $executionRequest = $version === 'v2' ? $executeV2 : $executeV5; 1138 | 1139 | $retryDelayMs = \intval(System::getEnv('OPR_EXECUTOR_RETRY_DELAY_MS', '500')); 1140 | $retryAttempts = \intval(System::getEnv('OPR_EXECUTOR_RETRY_ATTEMPTS', '5')); 1141 | 1142 | $attempts = 0; 1143 | do { 1144 | $executionResponse = \call_user_func($executionRequest); 1145 | if ($executionResponse['errNo'] === CURLE_OK) { 1146 | break; 1147 | } 1148 | 1149 | // Not retryable, return error immediately 1150 | if (!in_array($executionResponse['errNo'], [ 1151 | CURLE_COULDNT_RESOLVE_HOST, // 6 1152 | CURLE_COULDNT_CONNECT, // 7 1153 | ])) { 1154 | break; 1155 | } 1156 | 1157 | usleep($retryDelayMs * 1000); 1158 | } while ((++$attempts < $retryAttempts) || (\microtime(true) - $startTime < $timeout)); 1159 | 1160 | // Error occurred 1161 | if ($executionResponse['errNo'] !== CURLE_OK) { 1162 | // Intended timeout error for v2 functions 1163 | if ($version === 'v2' && $executionResponse['errNo'] === SOCKET_ETIMEDOUT) { 1164 | throw new Exception(Exception::EXECUTION_TIMEOUT, $executionResponse['error'], 400); 1165 | } 1166 | 1167 | throw new \Exception('Internal curl error has occurred within the executor! Error Number: ' . $executionResponse['errNo'], 500); 1168 | } 1169 | 1170 | // Successful execution 1171 | ['statusCode' => $statusCode, 'body' => $body, 'logs' => $logs, 'errors' => $errors, 'headers' => $headers] = $executionResponse; 1172 | 1173 | $endTime = \microtime(true); 1174 | $duration = $endTime - $startTime; 1175 | 1176 | if ($version === 'v2') { 1177 | $logs = \mb_strcut($logs, 0, MAX_BUILD_LOG_SIZE); 1178 | $errors = \mb_strcut($errors, 0, MAX_BUILD_LOG_SIZE); 1179 | } 1180 | 1181 | $execution = [ 1182 | 'statusCode' => $statusCode, 1183 | 'headers' => $headers, 1184 | 'body' => $body, 1185 | 'logs' => $logs, 1186 | 'errors' => $errors, 1187 | 'duration' => $duration, 1188 | 'startTime' => $startTime, 1189 | ]; 1190 | 1191 | // Update swoole table 1192 | $runtime = $this->runtimes->get($runtimeName); 1193 | if ($runtime !== null) { 1194 | $runtime->updated = \microtime(true); 1195 | $this->runtimes->set($runtimeName, $runtime); 1196 | } 1197 | 1198 | return $execution; 1199 | } 1200 | 1201 | /** 1202 | * @param string[] $networks 1203 | * @return void 1204 | */ 1205 | private function cleanUp(array $networks = []): void 1206 | { 1207 | Console::log('Cleaning up containers and networks...'); 1208 | 1209 | $functionsToRemove = $this->orchestration->list(['label' => 'openruntimes-executor=' . System::getHostname()]); 1210 | 1211 | if (\count($functionsToRemove) === 0) { 1212 | Console::info('No containers found to clean up.'); 1213 | } 1214 | 1215 | $jobsRuntimes = []; 1216 | foreach ($functionsToRemove as $container) { 1217 | $jobsRuntimes[] = function () use ($container) { 1218 | try { 1219 | $this->orchestration->remove($container->getId(), true); 1220 | 1221 | $activeRuntimeId = $container->getName(); 1222 | 1223 | if ($this->runtimes->exists($activeRuntimeId)) { 1224 | $this->runtimes->remove($activeRuntimeId); 1225 | } 1226 | 1227 | Console::success('Removed container ' . $container->getName()); 1228 | } catch (\Throwable $th) { 1229 | Console::error('Failed to remove container: ' . $container->getName()); 1230 | Console::error($th); 1231 | } 1232 | }; 1233 | } 1234 | batch($jobsRuntimes); 1235 | 1236 | $jobsNetworks = []; 1237 | foreach ($networks as $network) { 1238 | $jobsNetworks[] = function () use ($network) { 1239 | try { 1240 | $this->orchestration->removeNetwork($network); 1241 | Console::success("Removed network: $network"); 1242 | } catch (Exception $e) { 1243 | Console::error("Failed to remove network $network: " . $e->getMessage()); 1244 | } 1245 | }; 1246 | } 1247 | batch($jobsNetworks); 1248 | 1249 | Console::success('Cleanup finished.'); 1250 | } 1251 | 1252 | /** 1253 | * @param string[] $networks 1254 | * @return string[] 1255 | */ 1256 | private function createNetworks(array $networks): array 1257 | { 1258 | $jobs = []; 1259 | $createdNetworks = []; 1260 | foreach ($networks as $network) { 1261 | $jobs[] = function () use ($network, &$createdNetworks) { 1262 | if (!$this->orchestration->networkExists($network)) { 1263 | try { 1264 | $this->orchestration->createNetwork($network, false); 1265 | Console::success("Created network: $network"); 1266 | $createdNetworks[] = $network; 1267 | } catch (\Throwable $e) { 1268 | Console::error("Failed to create network $network: " . $e->getMessage()); 1269 | } 1270 | } else { 1271 | Console::info("Network $network already exists"); 1272 | $createdNetworks[] = $network; 1273 | } 1274 | }; 1275 | } 1276 | batch($jobs); 1277 | 1278 | $image = System::getEnv('OPR_EXECUTOR_IMAGE', ''); 1279 | $containers = $this->orchestration->list(['label' => "com.openruntimes.executor.image=$image"]); 1280 | 1281 | if (count($containers) < 1) { 1282 | $containerName = ''; 1283 | Console::warning('No matching executor found. Please check the value of OPR_EXECUTOR_IMAGE. Executor will need to be connected to the runtime network manually.'); 1284 | } else { 1285 | $containerName = $containers[0]->getName(); 1286 | Console::success('Found matching executor. Executor will be connected to runtime network automatically.'); 1287 | } 1288 | 1289 | if (!empty($containerName)) { 1290 | foreach ($createdNetworks as $network) { 1291 | try { 1292 | $this->orchestration->networkConnect($containerName, $network); 1293 | Console::success("Successfully connected executor '$containerName' to network '$network'"); 1294 | } catch (\Throwable $e) { 1295 | Console::error("Failed to connect executor '$containerName' to network '$network': " . $e->getMessage()); 1296 | } 1297 | } 1298 | } 1299 | 1300 | return $createdNetworks; 1301 | } 1302 | 1303 | public function getRuntimes(): mixed 1304 | { 1305 | $runtimes = []; 1306 | foreach ($this->runtimes as $runtime) { 1307 | $runtimes[] = $runtime->toArray(); 1308 | } 1309 | return $runtimes; 1310 | } 1311 | 1312 | public function getRuntime(string $name): mixed 1313 | { 1314 | $runtime = $this->runtimes->get($name); 1315 | if ($runtime === null) { 1316 | throw new Exception(Exception::RUNTIME_NOT_FOUND); 1317 | } 1318 | 1319 | return $runtime->toArray(); 1320 | } 1321 | 1322 | public function getStats(): Stats 1323 | { 1324 | return $this->stats; 1325 | } 1326 | } 1327 | --------------------------------------------------------------------------------