bcbd1bdb22
Test (Python) / test-python-gate (push) Blocked by required conditions
Test (TypeScript) / test-typescript-gate (push) Blocked by required conditions
CLI exit codes / cli-gate (push) Blocked by required conditions
Test (Install) / test-install-gate (push) Blocked by required conditions
Integ / integ-gate (push) Blocked by required conditions
CLI exit codes / Python CLI (push) Waiting to run
Integ / changes (push) Has been skipped
Test (Install) / python-minimal (3.11) (push) Waiting to run
Test (Install) / python-minimal (3.12) (push) Waiting to run
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Waiting to run
Integ / integ-ts (push) Waiting to run
Integ / integ (push) Waiting to run
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Waiting to run
Integ / integ-database (push) Waiting to run
Test (Install) / python-extra (email, mirage.resource.email) (push) Waiting to run
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Waiting to run
Integ / integ-ssh (push) Waiting to run
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
CLI exit codes / TypeScript CLI (push) Waiting to run
CLI exit codes / Cross-language snapshot interop (push) Waiting to run
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Waiting to run
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Waiting to run
Test (Python) / changes (push) Has been skipped
Integ / integ-database-ts (push) Waiting to run
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Waiting to run
Integ / integ-data (push) Waiting to run
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Waiting to run
Test (Python) / test (push) Waiting to run
Integ / integ-fuse (push) Waiting to run
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Waiting to run
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Waiting to run
Test (Python) / audit (push) Waiting to run
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Waiting to run
Integ / integ-ssh-ts (push) Waiting to run
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Waiting to run
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Waiting to run
Test (TypeScript) / changes (push) Has been skipped
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Waiting to run
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Waiting to run
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Waiting to run
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Waiting to run
Test (TypeScript) / test (push) Waiting to run
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Waiting to run
Test (TypeScript) / python-fs-shim (push) Waiting to run
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Waiting to run
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Waiting to run
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Waiting to run
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Waiting to run
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Waiting to run
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Waiting to run
Test (Install) / ts-minimal (push) Waiting to run
416 lines
15 KiB
Python
416 lines
15 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from mirage.server import build_app
|
|
from mirage.server.registry import WorkspaceRegistry
|
|
|
|
|
|
def _minimal_config() -> dict:
|
|
return {
|
|
"config": {
|
|
"mounts": {
|
|
"/": {
|
|
"resource": "ram",
|
|
"mode": "WRITE"
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _make_app_with_short_grace(grace: float = 0.2, snapshot_root=None):
|
|
exit_event = asyncio.Event()
|
|
app = build_app(idle_grace_seconds=grace,
|
|
exit_event=exit_event,
|
|
snapshot_root=snapshot_root)
|
|
return app, exit_event
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_list_get_delete_round_trip():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
assert r.status_code == 201, r.text
|
|
detail = r.json()
|
|
wid = detail["id"]
|
|
assert wid.startswith("ws_")
|
|
assert detail["mode"] == "write"
|
|
assert any(m["prefix"] == "/" for m in detail["mounts"])
|
|
|
|
r = await client.get("/v1/workspaces")
|
|
assert r.status_code == 200
|
|
briefs = r.json()
|
|
assert len(briefs) == 1
|
|
assert briefs[0]["id"] == wid
|
|
assert briefs[0]["mount_count"] == 1
|
|
|
|
r = await client.get(f"/v1/workspaces/{wid}")
|
|
assert r.status_code == 200
|
|
assert r.json()["id"] == wid
|
|
|
|
r = await client.delete(f"/v1/workspaces/{wid}")
|
|
assert r.status_code == 200
|
|
assert r.json()["id"] == wid
|
|
|
|
r = await client.get(f"/v1/workspaces/{wid}")
|
|
assert r.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_explicit_id():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
body = {**_minimal_config(), "id": "myws"}
|
|
r = await client.post("/v1/workspaces", json=body)
|
|
assert r.status_code == 201
|
|
assert r.json()["id"] == "myws"
|
|
|
|
r = await client.post("/v1/workspaces", json=body)
|
|
assert r.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_verbose_includes_internals():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
|
|
r = await client.get(f"/v1/workspaces/{wid}")
|
|
assert r.json()["internals"] is None
|
|
|
|
r = await client.get(f"/v1/workspaces/{wid}?verbose=true")
|
|
internals = r.json()["internals"]
|
|
assert internals is not None
|
|
assert "cache_bytes" in internals
|
|
assert "cache_entries" in internals
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(not os.environ.get("REDIS_URL"),
|
|
reason="REDIS_URL not set")
|
|
async def test_get_verbose_internals_with_redis_cache():
|
|
body = {
|
|
"config": {
|
|
"mounts": {
|
|
"/": {
|
|
"resource": "ram",
|
|
"mode": "WRITE"
|
|
}
|
|
},
|
|
"cache": {
|
|
"type": "redis",
|
|
"url": os.environ["REDIS_URL"],
|
|
"key_prefix": "test:summary:",
|
|
},
|
|
},
|
|
}
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=body)
|
|
assert r.status_code == 201, r.text
|
|
wid = r.json()["id"]
|
|
|
|
r = await client.get(f"/v1/workspaces/{wid}?verbose=true")
|
|
assert r.status_code == 200, r.text
|
|
internals = r.json()["internals"]
|
|
assert internals is not None
|
|
# Redis cache does not track size or entries; the summary must
|
|
# report them as untracked instead of reaching into RAM-store
|
|
# internals or conflating "not tracked" with an empty cache.
|
|
assert internals["cache_bytes"] is None
|
|
assert internals["cache_entries"] is None
|
|
|
|
await client.delete(f"/v1/workspaces/{wid}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clone_returns_new_workspace_with_same_mounts():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
|
|
r = await client.post(f"/v1/workspaces/{wid}/clone", json={})
|
|
assert r.status_code == 201, r.text
|
|
clone = r.json()
|
|
assert clone["id"] != wid
|
|
assert clone["id"].startswith("ws_")
|
|
assert {m["prefix"] for m in clone["mounts"]} == {"/"}
|
|
|
|
r = await client.get("/v1/workspaces")
|
|
assert len(r.json()) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clone_with_explicit_id_409_on_collision():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
body = {**_minimal_config(), "id": "src"}
|
|
await client.post("/v1/workspaces", json=body)
|
|
|
|
body2 = {**_minimal_config(), "id": "other"}
|
|
await client.post("/v1/workspaces", json=body2)
|
|
|
|
r = await client.post("/v1/workspaces/src/clone", json={"id": "other"})
|
|
assert r.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_endpoint():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.get("/v1/health")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "ok"
|
|
assert body["workspaces"] == 0
|
|
assert body["uptime_s"] >= 0
|
|
|
|
await client.post("/v1/workspaces", json=_minimal_config())
|
|
r = await client.get("/v1/health")
|
|
assert r.json()["workspaces"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_writes_tar_to_path(tmp_path):
|
|
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
target = tmp_path / "snap.tar"
|
|
r = await client.post(f"/v1/workspaces/{wid}/snapshot",
|
|
json={"path": str(target)})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["path"] == str(target)
|
|
assert body["size"] > 0
|
|
assert target.exists()
|
|
assert target.stat().st_size == body["size"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_load_round_trip(tmp_path):
|
|
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
target = tmp_path / "snap.tar"
|
|
await client.post(f"/v1/workspaces/{wid}/snapshot",
|
|
json={"path": str(target)})
|
|
|
|
r = await client.post("/v1/workspaces/load",
|
|
json={"path": str(target)})
|
|
assert r.status_code == 201, r.text
|
|
new_id = r.json()["id"]
|
|
assert new_id != wid
|
|
|
|
r = await client.get(f"/v1/workspaces/{new_id}")
|
|
assert r.status_code == 200
|
|
assert {m["prefix"] for m in r.json()["mounts"]} == {"/"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_rejects_path_outside_root(tmp_path):
|
|
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
r = await client.post(f"/v1/workspaces/{wid}/snapshot",
|
|
json={"path": "../escape.tar"})
|
|
assert r.status_code == 400, r.text
|
|
assert not (tmp_path.parent / "escape.tar").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_missing_path_returns_400(tmp_path):
|
|
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces/load",
|
|
json={"path": str(tmp_path / "nope.tar")})
|
|
assert r.status_code == 400, r.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_two_workspaces_run_in_isolation():
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid_a = r.json()["id"]
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid_b = r.json()["id"]
|
|
registry = app.state.registry
|
|
runner_a = registry.get(wid_a).runner
|
|
runner_b = registry.get(wid_b).runner
|
|
|
|
slow = asyncio.create_task(
|
|
runner_a.call(runner_a.ws.execute("sleep 1.0")))
|
|
await asyncio.sleep(0.05)
|
|
start = time.monotonic()
|
|
result = await runner_b.call(runner_b.ws.execute("echo quick"))
|
|
elapsed = time.monotonic() - start
|
|
assert result.exit_code == 0
|
|
assert elapsed < 0.5, (
|
|
f"workspace B took {elapsed:.2f}s while A was sleeping; "
|
|
"isolation violated")
|
|
await slow
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_shutdown_event_fires_after_grace():
|
|
app, exit_event = _make_app_with_short_grace(grace=0.2)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
await client.delete(f"/v1/workspaces/{wid}")
|
|
await asyncio.wait_for(exit_event.wait(), timeout=2.0)
|
|
assert exit_event.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_timer_canceled_when_new_workspace_created():
|
|
app, exit_event = _make_app_with_short_grace(grace=0.5)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
r = await client.post("/v1/workspaces", json=_minimal_config())
|
|
wid = r.json()["id"]
|
|
await client.delete(f"/v1/workspaces/{wid}")
|
|
await asyncio.sleep(0.1)
|
|
assert not exit_event.is_set()
|
|
await client.post("/v1/workspaces", json=_minimal_config())
|
|
await asyncio.sleep(0.6)
|
|
assert not exit_event.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_workspace_bridges_fuse_through_manager(monkeypatch):
|
|
calls = []
|
|
|
|
def _fake_add(self, prefix, mountpoint=None):
|
|
calls.append((prefix, mountpoint))
|
|
return mountpoint or "/tmp/fake"
|
|
|
|
monkeypatch.setattr("mirage.workspace.workspace.Workspace.add_fuse_mount",
|
|
_fake_add)
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
body = {
|
|
"config": {
|
|
"mounts": {
|
|
"/data/": {
|
|
"resource": "ram",
|
|
"fuse": True
|
|
},
|
|
"/pinned/": {
|
|
"resource": "ram",
|
|
"fuse": "/tmp/pinned"
|
|
},
|
|
},
|
|
},
|
|
}
|
|
r = await client.post("/v1/workspaces", json=body)
|
|
assert r.status_code == 201, r.text
|
|
assert ("/data/", None) in calls
|
|
assert any(mp == "/tmp/pinned" for _, mp in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_workspace_rolls_back_on_fuse_failure(monkeypatch):
|
|
closed = []
|
|
|
|
def _boom_add(self, prefix, mountpoint=None):
|
|
if not closed:
|
|
orig = self.close
|
|
|
|
async def _spy():
|
|
closed.append(True)
|
|
await orig()
|
|
|
|
self.close = _spy
|
|
raise ValueError("boom collision")
|
|
|
|
monkeypatch.setattr("mirage.workspace.workspace.Workspace.add_fuse_mount",
|
|
_boom_add)
|
|
app, _ = _make_app_with_short_grace(grace=10.0)
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport,
|
|
base_url="http://test") as client:
|
|
body = {
|
|
"config": {
|
|
"mounts": {
|
|
"/data/": {
|
|
"resource": "ram",
|
|
"fuse": True
|
|
},
|
|
},
|
|
},
|
|
}
|
|
r = await client.post("/v1/workspaces", json=body)
|
|
assert r.status_code == 409, r.text
|
|
|
|
r = await client.get("/v1/workspaces")
|
|
assert r.json() == []
|
|
assert closed == [True]
|
|
|
|
|
|
def test_registry_zero_grace_fires_immediately():
|
|
|
|
async def _run():
|
|
registry = WorkspaceRegistry(idle_grace_seconds=0)
|
|
from mirage import MountMode, Workspace
|
|
from mirage.resource.ram import RAMResource
|
|
ws = Workspace({"/": (RAMResource(), MountMode.WRITE)})
|
|
entry = registry.add(ws)
|
|
await registry.remove(entry.id)
|
|
assert registry.exit_event.is_set()
|
|
|
|
asyncio.run(_run())
|