Files
capy-bearer/scripts/test_doc_example_runtime.py
T

407 lines
17 KiB
Python

#!/usr/bin/env python3
"""Small native runtime and sandbox acceptance tests. Does not use shared services."""
import json
import os
import re
from pathlib import Path
import subprocess
import sys
import tempfile
import time
import unittest
import uuid
from doc_example_runtime import IsolatedRuntime
from doc_example_contexts import execute
from doc_example_results import collect_document
class RuntimeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.runtime = IsolatedRuntime(Path(__file__).resolve().parents[1])
cls.runtime.__enter__()
cls.root = cls.runtime.root
(cls.runtime.site / "probe.capy").write_text('''function CLI(request : any)
{
var input := json_decode(string(request.body))
print("hello ", input.name)
}
function RENDER()
{
print("rendered")
}
function WS(request : any)
{
ws_send("echo:" + string(request.body), false)
}
''')
@classmethod
def tearDownClass(cls):
cls.runtime.__exit__(None, None, None)
assert not cls.root.exists(), cls.root
def test_real_handlers(self):
runtime = self.runtime
self.assertEqual(runtime.compile("/probe.capy").status, 0)
result = runtime.cli("/probe.capy", {"name": "Ada"})
self.assertEqual((result.status, result.stdout), (0, "hello Ada"))
result = runtime.render("/probe.capy")
self.assertEqual((result.status, result.stdout), (0, "rendered"))
result = runtime.ws("/probe.capy", "test")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "echo:test", ""))
def documented_example(self, name):
source = self.runtime.repo / "site/doc/source"
return collect_document(source / "api" / (name + ".txt"), source)[0]
def test_documented_pi_rpc(self):
result = execute(self.documented_example("port_spawn"), self.runtime)
self.assertEqual(result["status"], 0, result)
records = [json.loads(line.removeprefix("stdout: ")) for line in result["stdout"].splitlines() if line.startswith("stdout: ")]
responses = [record for record in records if record.get("id") == "state-1" and record.get("command") == "get_state"]
self.assertTrue(responses, result)
self.assertIs(responses[0]["success"], True, responses)
def test_documented_custom_http(self):
example = self.documented_example("server_start_http")
result = execute(example, self.runtime)
self.assertEqual((result["status"], result["stdout"]), (0, "true\n"), (result, self.runtime._logs()))
port = re.search(r'server_start_http\("[^"]+",\s*"([0-9]+)"', example["source"])[1]
url = json.dumps("http://127.0.0.1:" + port + "/")
(self.runtime.site / "custom_probe.capy").write_text('function CLI() { var response := http_request(url: ' + url + ', timeout_ms: 2000); print(response.status, " ", response.body) }\n')
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
response = self.runtime.cli("/custom_probe.capy")
if response.stdout.startswith("200 "):
break
time.sleep(0.05)
self.assertEqual((response.status, response.stdout), (0, "200 hello from the custom HTTP server\n"), (response, self.runtime._logs()))
def test_protocol_status_metadata(self):
(self.runtime.site / "status.capy").write_text('''function RENDER(request : any)
{
request.out.status = 400
print("bad request")
}
function WS()
{
ws_close()
}
''')
result = self.runtime.render("/status.capy")
self.assertEqual((result.status, result.http_status, result.stdout), (400, 400, "bad request"))
result = self.runtime.ws("/status.capy")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "", ""))
self.assertIn(result.close_code, (1000, 1001, 1005))
def test_loopback_native_http(self):
directory = self.runtime.site / "tests"
directory.mkdir(exist_ok=True)
(directory / "capy-render.capy").write_text('function RENDER() { print("capy-render-ok") }\n')
(self.runtime.site / "http_probe.capy").write_text('''function CLI()
{
var response := http_request(method: "GET", url: "http://127.0.0.1/tests/capy-render.capy", timeout_ms: 2000)
print(response.status, " ", response.body)
}
''')
result = self.runtime.cli("/http_probe.capy")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "200 capy-render-ok", ""))
def test_native_task_and_service_capture(self):
source = '''function TASK:RUN(props : any)
{
print("task:", props.name)
}
function SERVICE:START(props : any)
{
print("started:", props.name)
}
function SERVICE:RUN(props : any)
{
print("service:", props.name)
}
'''
path = self.runtime.site / "async.capy"
path.write_text(source)
task = self.runtime.task("/async.capy", "RUN", {"name": "Ada"})
self.assertEqual((task.status, task.stdout, task.stderr), (0, "task:Ada", ""))
service = self.runtime.service("/async.capy", "RUN", {"name": "Ada"})
self.assertEqual((service.status, service.stdout, service.stderr), (0, "started:Adaservice:Ada", ""))
start = self.runtime.service("/async.capy", "START", {"name": "Ada"})
self.assertEqual((start.status, start.stdout, start.stderr), (0, "started:Ada", ""))
self.assertEqual(path.read_text(), source)
def test_task_boolean_props(self):
(self.runtime.site / "task_boolean.capy").write_text('''function TASK:EXAMPLE(props : any)
{
if props.timed_out
{
print("report timed out after ", props.command, "\\n")
}
}
''')
result = self.runtime.task("/task_boolean.capy", "EXAMPLE", {"timed_out": True, "command": "make report"})
self.assertEqual((result.status, result.stdout, result.stderr), (0, "report timed out after make report\n", ""))
def test_service_failure_keeps_completed_output(self):
(self.runtime.site / "service_failure.capy").write_text('''function SERVICE:START(request : any)
{
print("before-error")
service_send(string(request.unit), "FAIL", {}, 50)
}
function SERVICE:FAIL() { trap() }
''')
result = self.runtime.service("/service_failure.capy", "START")
self.assertEqual(result.status, 1, result)
self.assertTrue(result.stdout.startswith("before-error"), result)
self.assertIn("service_failure.capy", result.stderr)
self.assertIn("failed", result.stderr)
def test_unrelated_service_failure_is_not_attributed(self):
(self.runtime.site / "unrelated_bad.capy").write_text('''function SERVICE:START() { trap() }
function CLI() { service_start("/unrelated_bad.capy") }
function CLI:STOP() { service_stop("/unrelated_bad.capy") }
''')
(self.runtime.site / "unrelated_good.capy").write_text('function SERVICE:START() { print("good") }\n')
try:
self.assertEqual(self.runtime.cli("/unrelated_bad.capy").status, 0)
result = self.runtime.service("/unrelated_good.capy", "START")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "good", ""))
finally:
self.runtime.cli("/unrelated_bad.capy:STOP")
def test_service_data_from_real_child(self):
(self.runtime.site / "service_data.capy").write_text('''function SERVICE:START()
{
port_spawn(["/usr/bin/printf", "child-data\\n"])
}
function SERVICE:DATA(props : any)
{
print(props.data)
}
''')
result = self.runtime.service("/service_data.capy", "START")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "child-data\n", ""))
def test_service_exit_and_delayed_respawn(self):
(self.runtime.site / "service_respawn.capy").write_text('''var replaced := false
function SERVICE:START()
{
port_spawn(["/usr/bin/printf", "first\\n"])
}
function SERVICE:DATA(props : any)
{
print(props.data)
}
function SERVICE:EXIT(request : any)
{
if !replaced
{
replaced = true
service_send(string(request.unit), "RESPAWN", {}, 2000)
}
}
function SERVICE:RESPAWN()
{
port_spawn(["/usr/bin/printf", "replacement\\n"])
}
''')
started = time.monotonic()
result = self.runtime.service("/service_respawn.capy", "START")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "first\nreplacement\n", ""))
self.assertGreaterEqual(time.monotonic() - started, 2)
def test_provenance(self):
provenance = self.runtime.provenance()
self.assertTrue({"compiler", "runtime", "core"} <= provenance.keys())
for key in ("compiler", "runtime", "core"):
self.assertEqual(len(provenance[key]), 64)
for key in ("pi", "node"):
self.assertEqual(len(provenance["tools"][key]), 64)
self.assertTrue(provenance["tools"]["pi_version"])
def test_compile_diagnostics(self):
(self.runtime.site / "broken.capy").write_text("function CLI() { missing() }\n")
result = self.runtime.compile("/broken.capy")
self.assertNotEqual(result.status, 0)
self.assertIn("missing", result.stdout + result.stderr)
def test_namespace_and_secrets(self):
os.environ["DOC_EXAMPLE_PRIVATE_TEST"] = "secret"
try:
result = self.runtime._command(["/usr/bin/python3", "-c", '''
import os, pathlib, socket
assert "DOC_EXAMPLE_PRIVATE_TEST" not in os.environ
assert not pathlib.Path("/root/.ssh").exists()
assert not pathlib.Path("/run/bearer/cli.sock").exists()
assert pathlib.Path("/etc/passwd").read_text() == "root:x:0:0:root:/work/home:/bin/sh\\n"
try:
socket.create_connection(("192.0.2.1", 80), timeout=0.2)
except OSError:
pass
else:
raise AssertionError("External network is reachable")
print(os.readlink("/proc/self/ns/net"))
'''])
finally:
del os.environ["DOC_EXAMPLE_PRIVATE_TEST"]
self.assertEqual(result.status, 0, result)
self.assertNotEqual(result.stdout.strip(), os.readlink("/proc/self/ns/net"))
def test_host_path_and_symlink_escape(self):
with tempfile.TemporaryDirectory(prefix="doc-example-host-secret-") as directory:
secret = Path(directory) / "private"
secret.write_text("host-only test sentinel")
link = self.runtime.site / "host-secret-link"
link.symlink_to(secret)
try:
with self.assertRaises(OSError):
self.runtime._site_file(link.name, os.O_RDONLY)
directory_link = self.runtime.site / "host-directory-link"
directory_link.symlink_to(directory)
try:
with self.assertRaises(OSError):
self.runtime._site_file("host-directory-link/private", os.O_RDONLY)
finally:
directory_link.unlink()
code = '''
import os, pathlib
for path in (%r, %r, "/work/site/host-secret-link"):
try:
pathlib.Path(path).read_bytes()
except OSError:
pass
else:
raise AssertionError("Host path escape: " + path)
for path in ("/usr/bin/true", "/work/bin/capyc", "/work/bin/bearer_fastcgi.linux.bin"):
try:
handle = os.open(path, os.O_WRONLY)
except OSError:
pass
else:
os.close(handle)
raise AssertionError("Read-only executable is writable: " + path)
''' % (str(secret), "/proc/1/root" + str(secret))
result = self.runtime._command(["/usr/bin/python3", "-c", code])
self.assertEqual(result.status, 0, result)
finally:
link.unlink()
self.assertEqual(self.runtime.compile("/../outside.capy").status, 2)
def test_fixture_startup_errors(self):
with tempfile.TemporaryDirectory(prefix="doc-example-bad-fixture-") as directory:
repo = Path(directory)
(repo / "bin").symlink_to(self.runtime.repo / "bin", target_is_directory=True)
fixture = repo / "site/doc/examples/runtime/broken"
fixture.mkdir(parents=True)
(fixture / "start.sh").write_text("exit 7\n")
with self.assertRaisesRegex(RuntimeError, "Missing runtime fixture readiness script"):
with IsolatedRuntime(repo):
pass
(fixture / "ready.sh").write_text("exit 1\n")
with self.assertRaisesRegex(RuntimeError, "Runtime fixture exited before readiness"):
with IsolatedRuntime(repo):
pass
def test_shared_namespace_resource_plugins(self):
with tempfile.TemporaryDirectory(prefix="doc-example-fixture-repo-") as directory:
repo = Path(directory)
(repo / "bin").symlink_to(self.runtime.repo / "bin", target_is_directory=True)
fixtures = repo / "site/doc/examples/runtime"
memcache = fixtures / "memcached"
memcache.mkdir(parents=True)
(memcache / "start.sh").write_text("exec /usr/bin/memcached -u root -l 127.0.0.1 -p 11211 -U 0 -m 8 -c 64\n")
(memcache / "ready.sh").write_text("exec /usr/bin/python3 -c 'import socket; socket.create_connection((\"127.0.0.1\", 11211), timeout=0.1).close()'\n")
php = fixtures / "php"
app = php / "root/srv/app"
app.mkdir(parents=True)
(app / "adapter.php").write_text('<?php echo file_get_contents("php://input");\n')
(php / "config.conf").write_text('''[global]
error_log = /proc/self/fd/2
[docs]
user = root
group = root
listen = /run/php/php-fpm.sock
pm = static
pm.max_children = 1
catch_workers_output = yes
''')
(php / "start.sh").write_text("mkdir -p /run/php\nexec /work/bin/php-fpm8.5 -n -F -R -y /work/site/doc/examples/runtime/php/config.conf\n")
(php / "ready.sh").write_text("test -S /run/php/php-fpm.sock\n")
with IsolatedRuntime(repo) as runtime:
(runtime.site / "resources.capy").write_text('''function CLI()
{
var handle := memcache_connect()
print(handle != 0, " ")
if handle != 0
{
print(memcache_set(handle, "docs", "hello"), " ", memcache_get(handle, "docs"))
}
var response := fastcgi_request(socket: "/run/php/php-fpm.sock", env: {
SCRIPT_FILENAME: "/srv/app/adapter.php",
REQUEST_METHOD: "POST",
CONTENT_TYPE: "application/octet-stream",
CONTENT_LENGTH: "5"
}, body: "hello", timeout_ms: 2000)
print(" fastcgi:", response.ok, " ", response.stdout, " ", response.error)
}
''')
result = runtime.cli("/resources.capy")
self.assertEqual(result.status, 0, result)
self.assertTrue(result.stdout.startswith("true true hello fastcgi:true "), (result, runtime._logs()))
self.assertIn("hello", result.stdout.split("fastcgi:", 1)[1])
self.assertNotIn("not found", result.stdout.lower())
def test_private_mysql_fixture(self):
seed = self.runtime.site / "doc/examples/mysql.sql"
seed.parent.mkdir(parents=True, exist_ok=True)
seed.write_text("CREATE DATABASE shop; CREATE USER 'app'@'localhost' IDENTIFIED BY 'secret'; "
"GRANT ALL ON shop.* TO 'app'@'localhost'; "
"CREATE TABLE shop.orders(id BIGINT AUTO_INCREMENT PRIMARY KEY, total BIGINT);\n")
started = self.runtime.enable_mysql()
self.assertEqual(started.status, 0, started)
(self.runtime.site / "mysql_probe.capy").write_text('''function CLI()
{
var database := mysql_connect("localhost", "app", "secret", "shop")
print(mysql_info(database, "connection"), " ")
mysql_query(database, "INSERT INTO orders(total) VALUES (500)")
print(mysql_info(database, "insert_id"), " ", mysql_info(database, "affected_rows"))
}
''')
result = self.runtime.cli("/mysql_probe.capy")
self.assertEqual((result.status, result.stdout, result.stderr), (0, "true 1 1", ""))
self.assertTrue((self.runtime.root / "run/mysqld/mysqld.sock").is_socket())
def test_output_bound(self):
result = self.runtime._command(["/usr/bin/python3", "-c", "import os; os.write(1, b'x' * 2000000)"])
self.assertEqual(result.status, 125)
self.assertLessEqual(len(result.stdout), self.runtime.output_limit)
def test_deadline_kills_detached_children(self):
marker = "doc-example-child-" + uuid.uuid4().hex
old_timeout = self.runtime.timeout
self.runtime.timeout = 0.4
try:
result = self.runtime._command(["/usr/bin/python3", "-c", '''
import subprocess, time
subprocess.Popen(["/usr/bin/python3", "-c", "import time; time.sleep(60)", %r], start_new_session=True)
time.sleep(60)
''' % marker])
finally:
self.runtime.timeout = old_timeout
self.assertEqual(result.status, 124)
deadline = time.monotonic() + 3
while True:
processes = subprocess.run(["ps", "-eo", "args="], capture_output=True, text=True, timeout=2).stdout
if marker not in processes:
break
if time.monotonic() >= deadline:
self.fail("A detached sandbox child survived its namespace")
time.sleep(0.05)
if __name__ == "__main__":
unittest.main()