diff --git a/demo.zip b/demo.zip new file mode 100644 index 0000000..0cfa06b Binary files /dev/null and b/demo.zip differ diff --git a/doc/plans/archive-function-upload-tests-2026-05-04.md b/doc/plans/archive-function-upload-tests-2026-05-04.md new file mode 100644 index 0000000..89250a5 --- /dev/null +++ b/doc/plans/archive-function-upload-tests-2026-05-04.md @@ -0,0 +1,57 @@ +# План тестирования: загрузка функций из архива (Fission Console) + +Дата: 2026-05-04 + +## Цели +- Проверить корректность загрузки функций из архива для всех поддерживаемых языков (Python, Node.js, Ruby, PHP) +- Проверить работу линтера, валидацию entrypoint, соответствие языка, структуру архива +- Проверить обработку ошибок, edge-cases, граничные размеры + +## Категории тестов + +### 1. Валидные случаи +- [ ] Python: demo.py с def main(), entrypoint demo.main +- [ ] Node.js: demo.js с module.exports = main, entrypoint demo.main +- [ ] Ruby: demo.rb с def main, entrypoint demo.main +- [ ] PHP: demo.php с function main, entrypoint demo.main +- [ ] Архив с несколькими файлами (несколько языков, только один выбран) +- [ ] Архив с подпапками (файл demo.py внутри src/) +- [ ] Entrypoint с нестандартным именем (myfunc.py, entrypoint myfunc.main) + +### 2. Ошибочные случаи +- [ ] Нет файла с нужным именем (entrypoint demo.main, а demo.py отсутствует) +- [ ] Нет функции с нужным именем (demo.py есть, но нет def main) +- [ ] Язык выбран Python, а в архиве только .js +- [ ] Язык выбран Node.js, а в архиве только .py +- [ ] Entrypoint пустой +- [ ] Entrypoint без точки ("main") +- [ ] Архив пустой +- [ ] Архив > 100 KB +- [ ] Суммарный размер файлов > 100 KB (zip bomb) +- [ ] Файл с синтаксической ошибкой (Python, JS, Ruby, PHP) + +### 3. Пограничные случаи +- [ ] demo.py ровно 100 KB +- [ ] Архив с demo.py и demo.js, язык Python, entrypoint demo.main +- [ ] Архив с demo.py и demo.js, язык Node.js, entrypoint demo.main +- [ ] demo.py с def main(event, context) +- [ ] demo.py с def main() и def main2() +- [ ] demo.py с def main : (без скобок) +- [ ] demo.py с декоратором @fission_entry +- [ ] demo.py с не-ASCII именем функции + +### 4. UI/UX +- [ ] Проверка сообщений линтера (ошибки, предупреждения, OK) +- [ ] Проверка отображения имени архива, entrypoint, языка +- [ ] Проверка что после ошибки можно загрузить исправленный архив + +## Автоматизация +- Тесты реализовать на Go (backend), Python (pytest), JS (playwright) — по необходимости +- Для каждого теста: архив, ожидаемый результат, шаги UI/REST, ожидаемое сообщение + +--- + +## Ход выполнения +- [ ] Подготовить тестовые архивы +- [ ] Реализовать автотесты (минимум: backend REST, опционально UI) +- [ ] Зафиксировать результаты в doc/reports/ diff --git a/python-env/Dockerfile b/python-env/Dockerfile new file mode 100644 index 0000000..a540dcd --- /dev/null +++ b/python-env/Dockerfile @@ -0,0 +1,2 @@ +FROM ghcr.io/fission/python-env:latest +COPY server.py /app/server.py diff --git a/python-env/server.py b/python-env/server.py new file mode 100644 index 0000000..02378a0 --- /dev/null +++ b/python-env/server.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python +import importlib +import inspect +import logging +import os +import signal +import sys +import json + +from flask import Flask, request, abort +from gevent.pywsgi import WSGIServer +import bjoern +import sentry_sdk +from sentry_sdk.integrations.flask import FlaskIntegration + +from flask_sockets import Sockets + +IS_PY2 = (sys.version_info.major == 2) +SENTRY_DSN = os.environ.get('SENTRY_DSN', None) +SENTRY_RELEASE = os.environ.get('SENTRY_RELEASE', None) +USERFUNCVOL = os.environ.get("USERFUNCVOL", "/userfunc") +RUNTIME_PORT = int(os.environ.get("RUNTIME_PORT", "8888")) + +if SENTRY_DSN: + params = {'dsn': SENTRY_DSN, 'integrations': [FlaskIntegration()]} + if SENTRY_RELEASE: + params['release'] = SENTRY_RELEASE + sentry_sdk.init(**params) + + +def import_src(path): + if IS_PY2: + import imp + return imp.load_source('mod', path) + else: + return importlib.machinery.SourceFileLoader('mod', path).load_module() + + +def store_specialize_info(state): + json.dump(state, open(os.path.join(USERFUNCVOL, "state.json"), "w")) + + +def check_specialize_info_exists(): + return os.path.exists(os.path.join(USERFUNCVOL, "state.json")) + + +def read_specialize_info(): + return json.load(open(os.path.join(USERFUNCVOL, "state.json"))) + + +def remove_specialize_info(): + os.remove(os.path.join(USERFUNCVOL, "state.json")) + + +class SignalExit(SystemExit): + + def __init__(self, signo, exccode=1): + super(SignalExit, self).__init__(exccode) + self.signo = signo + + +def register_signal_handlers(signal_handler=signal.SIG_DFL): + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + +def _count_required_params(func): + """Return number of required (non-default) positional parameters.""" + try: + sig = inspect.signature(func) + count = 0 + for p in sig.parameters.values(): + if p.kind in (inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD): + continue + if p.default is inspect.Parameter.empty: + count += 1 + return count + except (ValueError, TypeError): + return 0 + + +class FuncApp(Flask): + + def __init__(self, name, loglevel=logging.DEBUG): + super(FuncApp, self).__init__(name) + + self.userfunc = None + self.root = logging.getLogger() + self.ch = logging.StreamHandler(sys.stdout) + + self.root.setLevel(loglevel) + self.ch.setLevel(loglevel) + self.ch.setFormatter( + logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) + self.logger.addHandler(self.ch) + + if check_specialize_info_exists(): + self.logger.info('Found state.json') + specialize_info = read_specialize_info() + self.userfunc = self._load_v2(specialize_info) + self.logger.info('Loaded user function {}'.format(specialize_info)) + + def load(self): + self.logger.info('/specialize called') + self.userfunc = import_src('/userfunc/user').main + return "" + + def loadv2(self): + specialize_info = request.get_json() + if check_specialize_info_exists(): + self.logger.warning("Found state.json, overwriting") + self.userfunc = self._load_v2(specialize_info) + store_specialize_info(specialize_info) + return "" + + def healthz(self): + return "", 200 + + def userfunc_call(self, *args, **kwargs): + """ + Call user function with compatible signature detection: + def main() -> main() + def main(event, context) -> main(request, {}) + def main(request) -> main(request) + def main(event=None, ...) -> main() (all defaults) + """ + if self.userfunc is None: + self.logger.error('userfunc is None') + return abort(500) + + required = _count_required_params(self.userfunc) + + if required == 0: + # def main() or def main(event=None, context=None) — call with no args + return self.userfunc() + elif required == 1: + # def main(request) style + return self.userfunc(request) + elif required >= 2: + # def main(event, context) — AWS Lambda / Kubeless style + # pass flask request as event, empty dict as context + return self.userfunc(request, {}) + else: + return self.userfunc(*args) + + def _load_v2(self, specialize_info): + filepath = specialize_info['filepath'] + handler = specialize_info['functionName'] + self.logger.info( + 'specialize called with filepath = "{}" handler = "{}"'.format( + filepath, handler)) + parts = handler.rsplit(".", 1) + if len(handler) == 0: + moduleName = 'main' + funcName = 'main' + elif len(parts) == 1: + moduleName = 'main' + funcName = parts[0] + else: + moduleName = parts[0] + funcName = parts[1] + self.logger.debug('moduleName = "{}" funcName = "{}"'.format( + moduleName, funcName)) + + if os.path.isdir(filepath): + sys.path.append(filepath) + self.logger.debug('__package__ = "{}"'.format(__package__)) + if __package__: + mod = importlib.import_module(moduleName, __package__) + else: + mod = importlib.import_module(moduleName) + else: + mod = import_src(filepath) + + return getattr(mod, funcName) + + def signal_handler(self, signalnum, frame): + self.logger.info('Received signal {}'.format( + signal.strsignal(signalnum))) + if check_specialize_info_exists(): + self.logger.info('Found state.json, removing') + remove_specialize_info() + signal.signal(signalnum, signal.SIG_DFL) + raise SignalExit(signalnum) + + +def main(): + app = FuncApp(__name__, logging.DEBUG) + sockets = Sockets(app) + register_signal_handlers(app.signal_handler) + + app.add_url_rule('/specialize', 'load', app.load, methods=['POST']) + app.add_url_rule('/v2/specialize', 'loadv2', app.loadv2, methods=['POST']) + app.add_url_rule('/healthz', 'healthz', app.healthz, methods=['GET']) + app.add_url_rule( + '/', + 'userfunc_call', + app.userfunc_call, + methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']) + app.add_url_rule( + '/', + 'userfunc_call', + app.userfunc_call, + methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']) + sockets.add_url_rule( + '/', + 'userfunc_call', + app.userfunc_call, + methods=['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']) + + if os.environ.get("WSGI_FRAMEWORK") == "GEVENT": + app.logger.info("Starting gevent based server") + from gevent_ws import WebSocketHandler + svc = WSGIServer(('0.0.0.0', RUNTIME_PORT), + app, + handler_class=WebSocketHandler) + svc.serve_forever() + else: + app.logger.info("Starting bjoern based server") + bjoern.run(app, '0.0.0.0', RUNTIME_PORT, reuse_port=True) + + +main() diff --git a/test-archives/js-demo-bad-entrypoint.zip b/test-archives/js-demo-bad-entrypoint.zip new file mode 100644 index 0000000..f4ba302 --- /dev/null +++ b/test-archives/js-demo-bad-entrypoint.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBub3RNYWluKCkgeyByZXR1cm4gIk5vdCBNYWluIjt9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA diff --git a/test-archives/js-demo-bad-syntax.zip b/test-archives/js-demo-bad-syntax.zip new file mode 100644 index 0000000..e82c562 --- /dev/null +++ b/test-archives/js-demo-bad-syntax.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9CmZ1bmN0aW9uIGJhZCgpIHJldHVybiAiZXJyb3IiOwpQSwECFAAUAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5qc1VUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/js-demo-empty.zip b/test-archives/js-demo-empty.zip new file mode 100644 index 0000000..e69de29 diff --git a/test-archives/js-demo-ok.zip b/test-archives/js-demo-ok.zip new file mode 100644 index 0000000..05dd5e8 --- /dev/null +++ b/test-archives/js-demo-ok.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5qc1VUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAbW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiBtYWluKCkgeyByZXR1cm4gIkphdmFTY3JpcHQgT0siOyB9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLmpzVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA diff --git a/test-archives/php-demo-ok.zip b/test-archives/php-demo-ok.zip new file mode 100644 index 0000000..ea48202 --- /dev/null +++ b/test-archives/php-demo-ok.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAZGVtby5waHBVVAkAA0MEG2dDBRtnYXgLAAEE9QEAAAQcADw/cGhwCmlmKCFmdW5jdGlvbl9leGlzdHMoIm1haW4iKSkgewogIGZ1bmN0aW9uIG1haW4oKSB7IHJldHVybiAiUEhQIG9rIjsgfQp9ClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAKAAQAAAAAAAAAAACkgQAAAABkZW1vLnBocFVUBQADQgQbd2F4CwABBPUBAAAEHAAAAFBLAwQUAAAACACtW+pUAAAAAAAAAAAAAAAAAAwAAAHB5LWhlbGxvLnB5VVQJAANCBBtnQwUbZ3V4CwABBPUBAAAEHABkZWYgbWFpbigpOgogICAgcmV0dXJuICJIZWxsbyBvayIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAACkgQAAAHB5LWhlbGxvLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAWgAAAAAA diff --git a/test-archives/python-demo-100kb.zip b/test-archives/python-demo-100kb.zip new file mode 100644 index 0000000..7d4722c --- /dev/null +++ b/test-archives/python-demo-100kb.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA \ No newline at end of file diff --git a/test-archives/python-demo-bad-entrypoint.zip b/test-archives/python-demo-bad-entrypoint.zip new file mode 100644 index 0000000..ae9077d --- /dev/null +++ b/test-archives/python-demo-bad-entrypoint.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk5vdCBNYWluIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/python-demo-bad-syntax.zip b/test-archives/python-demo-bad-syntax.zip new file mode 100644 index 0000000..fa4ec67 --- /dev/null +++ b/test-archives/python-demo-bad-syntax.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/python-demo-bad-syntax2.zip b/test-archives/python-demo-bad-syntax2.zip new file mode 100644 index 0000000..fa4ec67 --- /dev/null +++ b/test-archives/python-demo-bad-syntax2.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIGJhZCgpCiAgICByZXR1cm4gIkVycm9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/python-demo-decorator.zip b/test-archives/python-demo-decorator.zip new file mode 100644 index 0000000..5e1f8c4 --- /dev/null +++ b/test-archives/python-demo-decorator.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICJkZWNvcmF0b3IiCkBkZWNvcmF0b3IKZGVmIG1haW4oKToKICAgIHJldHVybiAiZGVjb3JhdG9yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/python-demo-empty.zip b/test-archives/python-demo-empty.zip new file mode 100644 index 0000000..e69de29 diff --git a/test-archives/python-demo-main-colon.zip b/test-archives/python-demo-main-colon.zip new file mode 100644 index 0000000..00edda5 --- /dev/null +++ b/test-archives/python-demo-main-colon.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW46CiAgICByZXR1cm4gIkNvbG9uIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/python-demo-main2.zip b/test-archives/python-demo-main2.zip new file mode 100644 index 0000000..cea01ed --- /dev/null +++ b/test-archives/python-demo-main2.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKZGVmIG1haW4yKCk6CiAgICByZXR1cm4gIk1haW4yIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAkABAAAAAAAAAAAApIEAAAAAZGVtby5weVVUBQADQgQbdXgLAAEE9QEAAAQcAAAAUEsFBgAAAAABAAEATgAAAFEAAAAAAA== diff --git a/test-archives/python-demo-non-ascii.zip b/test-archives/python-demo-non-ascii.zip new file mode 100644 index 0000000..2b633da --- /dev/null +++ b/test-archives/python-demo-non-ascii.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAY29kZSA9ICLQnNCw0YDQtdC00L7QvCIKZGVmIM6x0L7RgNC+0LkgKCk6CiAgICByZXR1cm4gIm9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA diff --git a/test-archives/python-demo-ok.zip b/test-archives/python-demo-ok.zip new file mode 100644 index 0000000..db359f8 --- /dev/null +++ b/test-archives/python-demo-ok.zip @@ -0,0 +1,2 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwA +ZGVmIG1haW4oKToKICAgIHJldHVybiAiS29ycmVrdCIKClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA diff --git a/test-archives/python-demo-subdir.zip b/test-archives/python-demo-subdir.zip new file mode 100644 index 0000000..9fc3113 --- /dev/null +++ b/test-archives/python-demo-subdir.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAwAAABzcmMvZGVtby5weVVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4oKToKICAgIHJldHVybiAiU3ViZGlyIgoKUEsBAhQAFAAAAAgArVvqVAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAACkgQAAAHNyYy9kZW1vLnB5VVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA diff --git a/test-archives/ruby-demo-ok.zip b/test-archives/ruby-demo-ok.zip new file mode 100644 index 0000000..123f5d1 --- /dev/null +++ b/test-archives/ruby-demo-ok.zip @@ -0,0 +1 @@ +UEsDBBQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAZGVtby5yYlVUCQADQwQbZ0MFG2d1eAsAAQT1AQAABBwAZGVmIG1haW4KICAgICJydWJ5IG9rIgogClBLAQIUABQAAAAIAK1b6lQAAAAAAAAAAAAAAAAJAAQAAAAAAAAAAACkgQAAAABkZW1vLnJiVVQFAANCBBt1eAsAAQT1AQAABBwAAABQSwUGAAAAAAEAAQBOAAAAUQAAAAAA