2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
2026-06-09 06:41:27 +02:00
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
from datetime import timedelta
|
2026-06-09 06:41:27 +02:00
|
|
|
import pytest
|
|
|
|
|
import requests
|
2026-06-09 20:02:50 +02:00
|
|
|
from tests.conftest import BASE_URL, run_async
|
2026-06-09 06:41:27 +02:00
|
|
|
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
2026-06-14 16:46:36 +02:00
|
|
|
from devplacepy.utils import generate_uid
|
2026-06-09 16:06:02 +02:00
|
|
|
from devplacepy import config, project_files
|
2026-06-09 06:41:27 +02:00
|
|
|
from devplacepy.services.containers import api, 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
|
2026-06-09 20:02:50 +02:00
|
|
|
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
|
2026-06-09 18:48:08 +02:00
|
|
|
_CONTAINER_TABLES = (
|
|
|
|
|
"instances",
|
|
|
|
|
"instance_events",
|
|
|
|
|
"instance_metrics",
|
|
|
|
|
"instance_schedules",
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
@pytest.fixture(autouse=True)
|
2026-06-13 16:32:33 +02:00
|
|
|
def _init_db_containers():
|
2026-06-09 06:41:27 +02:00
|
|
|
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"}
|
2026-06-14 16:46:36 +02:00
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": user["uid"],
|
|
|
|
|
"username": user["username"],
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-14 16:46:36 +02:00
|
|
|
"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()
|
2026-06-09 06:41:27 +02:00
|
|
|
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
|
|
|
|
|
yield {"fake": fake, "project": project, "user": user}
|
|
|
|
|
runtime.set_backend(None)
|
2026-06-14 16:46:36 +02:00
|
|
|
get_table("users").delete(uid=user["uid"])
|
2026-06-09 16:06:02 +02:00
|
|
|
for table in _CONTAINER_TABLES:
|
2026-06-09 06:41:27 +02:00
|
|
|
if table in db.tables:
|
2026-06-09 16:06:02 +02:00
|
|
|
for row in [r for r in get_table(table).find()]:
|
|
|
|
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
2026-06-09 06:41:27 +02:00
|
|
|
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"])
|
2026-06-13 16:32:33 +02:00
|
|
|
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"]
|
2026-06-09 06:41:27 +02:00
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def test_build_run_argv_exact():
|
2026-06-09 18:48:08 +02:00
|
|
|
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"],
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
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
|
2026-06-09 16:06:02 +02:00
|
|
|
assert argv[-3:] == ["ppy:latest", "python", "app.py"]
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_never_policy_not_passed_to_docker():
|
2026-06-09 16:06:02 +02:00
|
|
|
spec = RunSpec(image="ppy:latest", name="n", restart_policy="never")
|
2026-06-09 06:41:27 +02:00
|
|
|
assert "--restart" not in build_run_argv(spec)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_size():
|
2026-06-09 18:48:08 +02:00
|
|
|
assert parse_size("1.0GiB") == 1024**3
|
|
|
|
|
assert parse_size("512MB") == 512 * 1024**2
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
def test_create_instance_uses_shared_image(env):
|
2026-06-09 18:48:08 +02:00
|
|
|
inst = run_async(
|
|
|
|
|
api.create_instance(
|
|
|
|
|
env["project"], name="inst", actor=("user", env["user"]["uid"])
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-06-09 16:06:02 +02:00
|
|
|
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)
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
def test_create_instance_requires_built_image(env):
|
|
|
|
|
async def no_image(ref):
|
|
|
|
|
return False
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-06-09 16:06:02 +02:00
|
|
|
env["fake"].image_exists = no_image
|
2026-06-09 06:41:27 +02:00
|
|
|
with pytest.raises(api.ContainerError):
|
2026-06-09 16:06:02 +02:00
|
|
|
run_async(api.create_instance(env["project"], name="inst"))
|
2026-06-09 06:41:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints
Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes:
- `tables.py`: list all tables and inspect table schemas
- `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge
- `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService`
- `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes
Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
2026-06-15 01:00:30 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
def test_reconcile_reaps_orphan(env):
|
|
|
|
|
fake = env["fake"]
|
2026-06-09 18:48:08 +02:00
|
|
|
run_async(
|
|
|
|
|
fake.run(
|
|
|
|
|
RunSpec(
|
|
|
|
|
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
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
|
2026-06-09 18:48:08 +02:00
|
|
|
|
|
|
|
|
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
|
|
|
|
|
"zwoeks",
|
|
|
|
|
8899,
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
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)])
|
2026-06-09 18:48:08 +02:00
|
|
|
store.create_instance(
|
|
|
|
|
{
|
|
|
|
|
"uid": "z",
|
|
|
|
|
"project_uid": "ctest-p1",
|
|
|
|
|
"name": "z",
|
|
|
|
|
"ports_json": "[]",
|
|
|
|
|
"ingress_slug": "taken",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-06-09 06:41:27 +02:00
|
|
|
with pytest.raises(api.ContainerError):
|
|
|
|
|
api.validate_ingress("taken", None, [PortMapping(80, 80)])
|
2026-06-14 16:46:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-06 05:57:47 +02:00
|
|
|
assert pravda["DEVPLACE_API_KEY"] == key
|
|
|
|
|
assert pravda["DEVPLACE_USER_UID"] == env["user"]["uid"]
|
2026-06-14 16:46:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
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"
|