├── 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 |
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 | 
4 |
5 | ---
6 |
7 | [](https://discord.gg/mkZcevnxuf)
8 | [](https://github.com/open-runtimes/executor/actions/workflows/tests.yml)
9 | [](https://twitter.com/appwrite)
10 | [](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 "\nreturn function(\$req, \$res) {\n \$res->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 |
--------------------------------------------------------------------------------