|
# retoor <retoor@molodetz.nl>
|
|
|
|
from datetime import timedelta
|
|
import pytest
|
|
import requests
|
|
from tests.conftest import BASE_URL, run_async
|
|
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
|
from devplacepy.utils import generate_uid
|
|
from devplacepy import config, project_files
|
|
from starlette.requests import Request
|
|
from starlette.websockets import WebSocket
|
|
from devplacepy.services.containers import api, forward, store, runtime
|
|
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
|
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
|
|
from devplacepy.services.containers.backend.fake import FakeBackend
|
|
from devplacepy.services.containers.service import ContainerService
|
|
from devplacepy.services.containers.api import INSTANCE_LABEL
|
|
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
|
|
_CONTAINER_TABLES = (
|
|
"instances",
|
|
"instance_events",
|
|
"instance_metrics",
|
|
"instance_schedules",
|
|
)
|
|
@pytest.fixture(autouse=True)
|
|
def _init_db_containers():
|
|
init_db()
|
|
yield
|
|
@pytest.fixture
|
|
def env(tmp_path, monkeypatch):
|
|
fake = FakeBackend()
|
|
runtime.set_backend(fake)
|
|
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
|
|
pid = "ctest-p1"
|
|
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
|
|
user = {"uid": "ctest-u1", "username": "ctestadmin"}
|
|
get_table("users").insert(
|
|
{
|
|
"uid": user["uid"],
|
|
"username": user["username"],
|
|
"terms_version": "1",
|
|
"email": "ctestadmin@example.com",
|
|
"api_key": generate_uid(),
|
|
"password_hash": "",
|
|
"role": "Member",
|
|
"is_active": True,
|
|
"level": 1,
|
|
"xp": 0,
|
|
"stars": 0,
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
}
|
|
)
|
|
refresh_snapshot()
|
|
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
|
|
yield {"fake": fake, "project": project, "user": user}
|
|
runtime.set_backend(None)
|
|
get_table("users").delete(uid=user["uid"])
|
|
for table in _CONTAINER_TABLES:
|
|
if table in db.tables:
|
|
for row in [r for r in get_table(table).find()]:
|
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
|
get_table(table).delete(uid=row["uid"])
|
|
for row in list(get_table("project_files").find()):
|
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
|
get_table("project_files").delete(uid=row["uid"])
|
|
def _ready_instance(env, **kwargs):
|
|
return run_async(
|
|
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
|
)
|
|
def _promote_admin(username: str) -> None:
|
|
users = get_table("users")
|
|
user = users.find_one(username=username)
|
|
if user:
|
|
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
|
|
def _api_key(username: str) -> str:
|
|
refresh_snapshot()
|
|
return get_table("users").find_one(username=username)["api_key"]
|
|
|
|
|
|
def test_build_run_argv_exact():
|
|
spec = RunSpec(
|
|
image="ppy:latest",
|
|
name="inst",
|
|
labels={INSTANCE_LABEL: "u1"},
|
|
env={"A": "B"},
|
|
cpu_limit="1.5",
|
|
mem_limit="512m",
|
|
ports=[PortMapping(8080, 80)],
|
|
mounts=[Mount("/ws", "/app")],
|
|
restart_policy="on-failure",
|
|
command=["python", "app.py"],
|
|
)
|
|
argv = build_run_argv(spec)
|
|
assert argv[:5] == ["docker", "run", "-d", "--name", "inst"]
|
|
assert "--label" in argv and f"{INSTANCE_LABEL}=u1" in argv
|
|
assert "--cpus" in argv and "1.5" in argv
|
|
assert "-p" in argv and "8080:80/tcp" in argv
|
|
assert "-v" in argv and "/ws:/app:rw" in argv
|
|
assert "--restart" in argv and "on-failure" in argv
|
|
assert argv[-3:] == ["ppy:latest", "python", "app.py"]
|
|
|
|
|
|
def test_never_policy_not_passed_to_docker():
|
|
spec = RunSpec(image="ppy:latest", name="n", restart_policy="never")
|
|
assert "--restart" not in build_run_argv(spec)
|
|
|
|
|
|
def test_parse_size():
|
|
assert parse_size("1.0GiB") == 1024**3
|
|
assert parse_size("512MB") == 512 * 1024**2
|
|
|
|
|
|
def test_create_instance_uses_shared_image(env):
|
|
inst = run_async(
|
|
api.create_instance(
|
|
env["project"], name="inst", actor=("user", env["user"]["uid"])
|
|
)
|
|
)
|
|
assert inst["name"] == "inst"
|
|
assert inst["owner_uid"] == "ctest-u1"
|
|
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
|
assert spec.image == config.CONTAINER_IMAGE
|
|
assert any(m.container == "/app" for m in spec.mounts)
|
|
|
|
|
|
def test_create_instance_requires_built_image(env):
|
|
async def no_image(ref):
|
|
return False
|
|
|
|
env["fake"].image_exists = no_image
|
|
with pytest.raises(api.ContainerError):
|
|
run_async(api.create_instance(env["project"], name="inst"))
|
|
|
|
|
|
def test_reconcile_launches_and_stops(env):
|
|
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
|
assert inst["desired_state"] == store.DESIRED_RUNNING
|
|
service = ContainerService()
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
inst = store.get_instance(inst["uid"])
|
|
assert inst["status"] == store.ST_RUNNING and inst["container_id"]
|
|
assert [r.name for r in run_async(env["fake"].ps())] == [inst["slug"]]
|
|
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
assert store.get_instance(inst["uid"])["status"] == store.ST_STOPPED
|
|
|
|
|
|
def test_manual_start_relaunches_after_never_policy_exit(env):
|
|
fake = env["fake"]
|
|
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
|
service = ContainerService()
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
inst = store.get_instance(inst["uid"])
|
|
cid = inst["container_id"]
|
|
assert inst["status"] == store.ST_RUNNING and cid
|
|
|
|
fake._set_state(cid, "exited", 0)
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
inst = store.get_instance(inst["uid"])
|
|
assert inst["status"] == store.ST_STOPPED
|
|
assert inst["desired_state"] == store.DESIRED_STOPPED
|
|
|
|
api.set_desired_state(inst, store.DESIRED_RUNNING)
|
|
refresh_snapshot()
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
inst = store.get_instance(inst["uid"])
|
|
assert inst["status"] == store.ST_RUNNING
|
|
assert inst["desired_state"] == store.DESIRED_RUNNING
|
|
assert cid in fake.removed
|
|
assert inst["container_id"] and inst["container_id"] != cid
|
|
|
|
|
|
def test_reconcile_reaps_orphan(env):
|
|
fake = env["fake"]
|
|
run_async(
|
|
fake.run(
|
|
RunSpec(
|
|
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
|
|
)
|
|
)
|
|
)
|
|
service = ContainerService()
|
|
run_async(service.run_once())
|
|
assert not run_async(fake.ps())
|
|
assert fake.removed
|
|
|
|
|
|
def test_reconcile_removes_marked_instance(env):
|
|
inst = _ready_instance(env, autostart=True)
|
|
service = ContainerService()
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
inst = store.get_instance(inst["uid"])
|
|
api.mark_for_removal(inst)
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
assert store.get_instance(inst["uid"]) is None
|
|
assert not run_async(env["fake"].ps())
|
|
|
|
|
|
def test_schedule_fires(env):
|
|
inst = _ready_instance(env, autostart=False)
|
|
assert inst["desired_state"] == store.DESIRED_STOPPED
|
|
past = Schedule(kind="once", run_at=now_utc() - timedelta(hours=1))
|
|
api.add_schedule(inst, "start", past)
|
|
service = ContainerService()
|
|
run_async(service._fire_schedules())
|
|
refresh_snapshot()
|
|
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
|
|
|
|
|
def test_ingress_validation(env):
|
|
from devplacepy.services.containers.backend.base import PortMapping
|
|
|
|
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
|
|
"zwoeks",
|
|
8899,
|
|
)
|
|
assert api.validate_ingress("", None, []) == ("", 0)
|
|
with pytest.raises(api.ContainerError):
|
|
api.validate_ingress("BAD SLUG", None, [PortMapping(80, 80)])
|
|
with pytest.raises(api.ContainerError):
|
|
api.validate_ingress("x", 9999, [PortMapping(80, 80)])
|
|
store.create_instance(
|
|
{
|
|
"uid": "z",
|
|
"project_uid": "ctest-p1",
|
|
"name": "z",
|
|
"ports_json": "[]",
|
|
"ingress_slug": "taken",
|
|
}
|
|
)
|
|
with pytest.raises(api.ContainerError):
|
|
api.validate_ingress("taken", None, [PortMapping(80, 80)])
|
|
|
|
|
|
def test_validate_boot_languages():
|
|
assert api.validate_boot("none", "ignored") == ("none", "")
|
|
assert api.validate_boot("python", "print(1)") == ("python", "print(1)")
|
|
with pytest.raises(api.ContainerError):
|
|
api.validate_boot("ruby", "puts 1")
|
|
with pytest.raises(api.ContainerError):
|
|
api.validate_boot("bash", " ")
|
|
|
|
|
|
def test_boot_script_precedence_in_run_spec(env):
|
|
inst = _ready_instance(
|
|
env,
|
|
boot_language="python",
|
|
boot_script="print('boot')\n",
|
|
boot_command="python other.py",
|
|
autostart=False,
|
|
)
|
|
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
|
assert spec.command == ["python", "/app/.devplace_boot.py"]
|
|
inst2 = _ready_instance(
|
|
env, name="i2", boot_command="python serve.py", autostart=False
|
|
)
|
|
spec2 = api.run_spec_for(inst2, config.CONTAINER_IMAGE)
|
|
assert spec2.command == ["/bin/sh", "-c", "python serve.py"]
|
|
|
|
|
|
def test_materialize_boot_script_writes_file(env, tmp_path):
|
|
workspace = tmp_path / "boot-ws"
|
|
inst = _ready_instance(
|
|
env, name="boot", boot_language="bash", boot_script="echo hi\n", autostart=False
|
|
)
|
|
store.update_instance(inst["uid"], {"workspace_dir": str(workspace)})
|
|
inst = store.get_instance(inst["uid"])
|
|
api.materialize_boot_script(inst)
|
|
written = workspace / ".devplace_boot.sh"
|
|
assert written.is_file()
|
|
assert written.read_text() == "echo hi\n"
|
|
|
|
|
|
def test_run_as_uid_drives_pravda_env(env):
|
|
_promote_admin(env["user"]["username"])
|
|
key = _api_key(env["user"]["username"])
|
|
inst = _ready_instance(
|
|
env, name="runas", run_as_uid=env["user"]["uid"], autostart=False
|
|
)
|
|
pravda = api.pravda_env(inst)
|
|
assert pravda["DEVPLACE_API_KEY"] == key
|
|
assert pravda["DEVPLACE_USER_UID"] == env["user"]["uid"]
|
|
|
|
|
|
def test_run_as_uid_rejects_unknown_user(env):
|
|
with pytest.raises(api.ContainerError):
|
|
run_async(
|
|
api.create_instance(
|
|
env["project"], name="bad", run_as_uid="nope-uid", autostart=False
|
|
)
|
|
)
|
|
|
|
|
|
def _other_member(uid="ctest-u2", username="ctestmember"):
|
|
users = get_table("users")
|
|
if not users.find_one(uid=uid):
|
|
users.insert(
|
|
{
|
|
"uid": uid,
|
|
"username": username,
|
|
"terms_version": "1",
|
|
"email": f"{username}@example.com",
|
|
"api_key": generate_uid(),
|
|
"password_hash": "",
|
|
"role": "Member",
|
|
"is_active": True,
|
|
"created_at": "2020-01-01T00:00:00+00:00",
|
|
}
|
|
)
|
|
refresh_snapshot()
|
|
return uid
|
|
|
|
|
|
def test_run_as_another_member_needs_their_credential_consent(env):
|
|
from devplacepy.database import set_consent
|
|
|
|
other = _other_member()
|
|
set_consent("user", other, "container_credentials", False)
|
|
with pytest.raises(api.ContainerError) as refused:
|
|
run_async(
|
|
api.create_instance(
|
|
env["project"],
|
|
name="noconsent",
|
|
run_as_uid=other,
|
|
autostart=False,
|
|
actor=("user", env["user"]["uid"]),
|
|
)
|
|
)
|
|
assert "consent" in str(refused.value).lower()
|
|
|
|
|
|
def test_run_as_another_member_is_allowed_once_they_consent(env):
|
|
from devplacepy.database import set_consent
|
|
|
|
other = _other_member()
|
|
set_consent("user", other, "container_credentials", True)
|
|
instance = run_async(
|
|
api.create_instance(
|
|
env["project"],
|
|
name="withconsent",
|
|
run_as_uid=other,
|
|
autostart=False,
|
|
actor=("user", env["user"]["uid"]),
|
|
)
|
|
)
|
|
assert instance["run_as_uid"] == other
|
|
|
|
|
|
def test_run_as_yourself_never_needs_a_consent(env):
|
|
instance = run_async(
|
|
api.create_instance(
|
|
env["project"],
|
|
name="selfrunas",
|
|
run_as_uid=env["user"]["uid"],
|
|
autostart=False,
|
|
actor=("user", env["user"]["uid"]),
|
|
)
|
|
)
|
|
assert instance["run_as_uid"] == env["user"]["uid"]
|
|
|
|
|
|
def test_start_on_boot_pass_forces_running(env):
|
|
inst = _ready_instance(env, name="boot", start_on_boot=True, autostart=False)
|
|
assert inst["desired_state"] == store.DESIRED_STOPPED
|
|
service = ContainerService()
|
|
service._boot_pass(store.all_instances())
|
|
refresh_snapshot()
|
|
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
|
|
|
|
|
def test_status_change_records_event(env):
|
|
inst = _ready_instance(env, name="hist", restart_policy="never", autostart=True)
|
|
service = ContainerService()
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
inst = store.get_instance(inst["uid"])
|
|
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
|
run_async(service.run_once())
|
|
refresh_snapshot()
|
|
events = [e["event"] for e in store.list_events(inst["uid"])]
|
|
assert "status_change" in events
|
|
|
|
|
|
def test_bidirectional_sync_newer_wins(env, tmp_path):
|
|
workspace = tmp_path / "sync-ws"
|
|
workspace.mkdir()
|
|
pid = env["project"]["uid"]
|
|
user = env["user"]
|
|
project_files.write_text_file(pid, user, "shared.txt", "from project\n")
|
|
fs_only = workspace / "fromfs.txt"
|
|
fs_only.write_text("from fs\n")
|
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
|
assert counts["exported"] >= 1
|
|
assert counts["imported"] >= 1
|
|
assert (workspace / "shared.txt").read_text() == "from project\n"
|
|
imported = project_files.read_file(pid, "fromfs.txt")
|
|
assert imported["content"] == "from fs\n"
|
|
|
|
|
|
|
|
def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict:
|
|
return {
|
|
"type": kind,
|
|
"scheme": scheme,
|
|
"server": ("devplace.net", 443),
|
|
"path": "/projects/demo/containers/instances/abc/code/stable-1",
|
|
"query_string": query.encode(),
|
|
"headers": [
|
|
(name.encode(), value.encode()) for name, value in headers.items()
|
|
],
|
|
}
|
|
|
|
|
|
def _proxy_websocket(headers: dict, scheme: str = "wss", query: str = "") -> WebSocket:
|
|
return WebSocket(_proxy_scope("websocket", headers, scheme, query), None, None)
|
|
|
|
|
|
def _proxy_request(headers: dict, scheme: str = "https", query: str = "") -> Request:
|
|
return Request(_proxy_scope("http", headers, scheme, query))
|
|
|
|
|
|
def test_ws_headers_send_the_public_host_only_as_x_forwarded_host():
|
|
headers = forward.ws_headers(
|
|
_proxy_websocket(
|
|
{
|
|
"host": "devplace.net",
|
|
"origin": "https://devplace.net",
|
|
"cookie": "session=abc",
|
|
}
|
|
)
|
|
)
|
|
assert headers["X-Forwarded-Host"] == "devplace.net"
|
|
assert headers["origin"] == "https://devplace.net"
|
|
assert headers["cookie"] == "session=abc"
|
|
assert not [name for name in headers if name.lower() == "host"]
|
|
|
|
|
|
def test_ws_headers_drop_only_the_handshake_headers_the_client_regenerates():
|
|
headers = forward.ws_headers(
|
|
_proxy_websocket(
|
|
{
|
|
"host": "devplace.net",
|
|
"x-real-ip": "203.0.113.7",
|
|
"accept-language": "nl-NL",
|
|
"sec-websocket-key": "should-not-survive",
|
|
"sec-websocket-version": "13",
|
|
"sec-websocket-extensions": "permessage-deflate",
|
|
"sec-websocket-protocol": "v1",
|
|
}
|
|
)
|
|
)
|
|
assert headers["x-real-ip"] == "203.0.113.7"
|
|
assert headers["accept-language"] == "nl-NL"
|
|
for name in forward.WS_HANDSHAKE_HEADERS:
|
|
assert name not in headers
|
|
|
|
|
|
def test_ws_headers_derive_the_forwarded_proto_from_the_socket_scheme():
|
|
assert forward.ws_headers(_proxy_websocket({"host": "d.net"}))[
|
|
"X-Forwarded-Proto"
|
|
] == "https"
|
|
assert forward.ws_headers(_proxy_websocket({"host": "d.net"}, scheme="ws"))[
|
|
"X-Forwarded-Proto"
|
|
] == "http"
|
|
assert forward.ws_headers(
|
|
_proxy_websocket({"host": "d.net", "x-forwarded-proto": "https"}, scheme="ws")
|
|
)["X-Forwarded-Proto"] == "https"
|
|
|
|
|
|
def test_forward_headers_send_the_public_host_and_the_prefix():
|
|
headers = forward.forward_headers(
|
|
_proxy_request({"host": "devplace.net", "x-real-ip": "203.0.113.7"}),
|
|
prefix="/p/demo",
|
|
)
|
|
assert headers["Host"] == "devplace.net"
|
|
assert headers["X-Forwarded-Host"] == "devplace.net"
|
|
assert headers["X-Forwarded-Prefix"] == "/p/demo"
|
|
assert headers["X-Script-Name"] == "/p/demo"
|
|
assert headers["x-real-ip"] == "203.0.113.7"
|
|
|
|
|
|
def test_ws_subprotocols_are_parsed_for_negotiation():
|
|
assert forward.ws_subprotocols(_proxy_websocket({"host": "d.net"})) is None
|
|
assert forward.ws_subprotocols(
|
|
_proxy_websocket({"host": "d.net", "sec-websocket-protocol": "v2, v1"})
|
|
) == ["v2", "v1"]
|
|
|
|
|
|
def test_upstream_url_encodes_the_path_and_keeps_the_query_verbatim():
|
|
assert (
|
|
forward.upstream_url("http", "h:1", "dir/a b#c", "keep=1")
|
|
== "http://h:1/dir/a%20b%23c?keep=1"
|
|
)
|
|
assert forward.upstream_url("ws", "h:1", "", "") == "ws://h:1/"
|
|
|
|
|
|
def test_raw_query_survives_a_hash_in_the_path():
|
|
request = _proxy_request({"host": "d.net"}, query="keep=1")
|
|
request.scope["path"] = "/weird/a b#c"
|
|
assert forward.raw_query(request) == "keep=1"
|
|
assert request.url.query == ""
|