Private
Public Access
407 lines
17 KiB
Python
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()
|