bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
212 lines
8.0 KiB
Python
212 lines
8.0 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
|
|
from contextlib import ExitStack
|
|
|
|
import pytest
|
|
|
|
from mirage.resource.ram import RAMResource
|
|
from mirage.resource.s3 import S3Config, S3Resource
|
|
from mirage.types import DriftPolicy, MountMode
|
|
from mirage.workspace import Workspace
|
|
from mirage.workspace.snapshot import ContentDriftError, install_fingerprints
|
|
from tests.integration.s3_mock import patch_s3_multi
|
|
|
|
|
|
def _load(*args, **kwargs):
|
|
return asyncio.run(Workspace.load(*args, **kwargs))
|
|
|
|
|
|
def test_install_fingerprints_pins_revision_and_queues_drift():
|
|
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
entries = [
|
|
{
|
|
"path": "/m/pinned.txt",
|
|
"mount_prefix": "/m/",
|
|
"revision": "v9"
|
|
},
|
|
{
|
|
"path": "/m/checked.txt",
|
|
"mount_prefix": "/m/",
|
|
"fingerprint": "fp1"
|
|
},
|
|
]
|
|
|
|
install_fingerprints(ws, entries, DriftPolicy.STRICT)
|
|
|
|
mount = ws._registry.mount_for("/m/pinned.txt")
|
|
assert mount.revisions["/m/pinned.txt"] == "v9"
|
|
assert ws._drift_check_pending is True
|
|
queued = [path for (_, path, _) in ws._pending_drift]
|
|
assert "/m/checked.txt" in queued
|
|
|
|
|
|
def _config() -> S3Config:
|
|
return S3Config(
|
|
bucket="test-bucket",
|
|
region="us-east-1",
|
|
aws_access_key_id="fake",
|
|
aws_secret_access_key="fake",
|
|
)
|
|
|
|
|
|
def test_strict_load_raises_when_s3_etag_drifts(tmp_path):
|
|
"""Snapshot a workspace with one read on /s3, mutate the underlying
|
|
object so its ETag changes, load with strict policy: the next read
|
|
must raise ContentDriftError rather than silently serve drifted
|
|
bytes.
|
|
"""
|
|
store = {"data.csv": b"version 1 bytes\n"}
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
|
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
result = asyncio.run(src.execute("cat /s3/data.csv"))
|
|
assert b"version 1" in result.stdout
|
|
|
|
snap = tmp_path / "snap.tar"
|
|
asyncio.run(src.snapshot(snap))
|
|
store["data.csv"] = b"VERSION 2 DRIFTED\n"
|
|
|
|
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
|
with pytest.raises(ContentDriftError) as exc_info:
|
|
asyncio.run(dst.execute("cat /s3/data.csv"))
|
|
assert exc_info.value.path == "/s3/data.csv"
|
|
live = exc_info.value.live_fingerprint
|
|
recorded = exc_info.value.snapshot_fingerprint
|
|
assert live != recorded
|
|
|
|
|
|
def test_off_load_serves_drifted_bytes_silently(tmp_path):
|
|
"""drift_policy=OFF disables the check: the load returns the new
|
|
bytes with no error. This is the only opt-out from drift detection.
|
|
"""
|
|
store = {"data.csv": b"version 1 bytes\n"}
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
|
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
asyncio.run(src.execute("cat /s3/data.csv"))
|
|
|
|
snap = tmp_path / "snap.tar"
|
|
asyncio.run(src.snapshot(snap))
|
|
store["data.csv"] = b"VERSION 2 DRIFTED\n"
|
|
|
|
dst = _load(snap,
|
|
resources={"/s3": S3Resource(_config())},
|
|
drift_policy=DriftPolicy.OFF)
|
|
result = asyncio.run(dst.execute("cat /s3/data.csv"))
|
|
assert b"VERSION 2 DRIFTED" in result.stdout
|
|
|
|
|
|
def test_strict_load_passes_when_etag_unchanged(tmp_path):
|
|
"""The control case: same bytes still in S3, strict load must succeed
|
|
and serve them. Verifies the check is precise (no false positives).
|
|
"""
|
|
store = {"data.csv": b"stable bytes\n"}
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
|
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
asyncio.run(src.execute("cat /s3/data.csv"))
|
|
|
|
snap = tmp_path / "snap.tar"
|
|
asyncio.run(src.snapshot(snap))
|
|
|
|
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
|
result = asyncio.run(dst.execute("cat /s3/data.csv"))
|
|
assert b"stable bytes" in result.stdout
|
|
|
|
|
|
def test_unrecorded_path_skips_drift_check(tmp_path):
|
|
"""A path the agent did not read at snapshot time has no recorded
|
|
fingerprint, so it must NOT be drift-checked at load — just live-
|
|
served. Tests that drift-check is opt-in per recorded path.
|
|
"""
|
|
store = {
|
|
"read-me.txt": b"recorded\n",
|
|
"added-later.txt": b"not in snapshot\n"
|
|
}
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
|
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
asyncio.run(src.execute("cat /s3/read-me.txt"))
|
|
|
|
snap = tmp_path / "snap.tar"
|
|
asyncio.run(src.snapshot(snap))
|
|
|
|
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
|
result = asyncio.run(dst.execute("cat /s3/added-later.txt"))
|
|
assert b"not in snapshot" in result.stdout
|
|
|
|
|
|
def test_version_pin_serves_original_bytes_on_versioned_bucket(tmp_path):
|
|
"""On a versioned S3 bucket, snapshot captures the object's VersionId
|
|
alongside its ETag. After the live bytes drift, load with default
|
|
STRICT policy pins reads to the recorded VersionId and serves the
|
|
ORIGINAL bytes the agent saw, instead of raising drift or returning
|
|
the current head.
|
|
"""
|
|
store = {"data.csv": b"original\n"}
|
|
with ExitStack() as stack:
|
|
stack.enter_context(
|
|
patch_s3_multi({"test-bucket": store}, versioned={"test-bucket"}))
|
|
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
asyncio.run(src.execute("cat /s3/data.csv"))
|
|
|
|
snap = tmp_path / "snap.tar"
|
|
asyncio.run(src.snapshot(snap))
|
|
store["data.csv"] = b"mutated bytes\n"
|
|
|
|
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
|
# Cache holds snapshot bytes; clear so we hit S3 and exercise the
|
|
# pin path, not the cache path.
|
|
_drop_path_from_cache(dst, "/s3/data.csv")
|
|
result = asyncio.run(dst.execute("cat /s3/data.csv"))
|
|
assert result.stdout == b"original\n"
|
|
|
|
|
|
def _drop_path_from_cache(ws, path: str) -> None:
|
|
cache = ws._cache
|
|
cache._entries.pop(path, None)
|
|
cache._store.files.pop(path, None)
|
|
|
|
|
|
def test_live_only_mount_does_not_block_snapshot(tmp_path, caplog):
|
|
"""Workspaces with non-SUPPORTS_SNAPSHOT mounts (RAM here as a stand-
|
|
in for Gmail/Slack/Linear) snapshot fine; no fingerprints are
|
|
captured for those paths and the load layer logs an honest warning
|
|
surfacing the live-only mount list.
|
|
"""
|
|
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
|
mode=MountMode.WRITE)
|
|
asyncio.run(src.execute("echo body > /m/note.txt"))
|
|
asyncio.run(src.execute("cat /m/note.txt"))
|
|
|
|
snap = tmp_path / "snap.tar"
|
|
asyncio.run(src.snapshot(snap))
|
|
|
|
with caplog.at_level("WARNING"):
|
|
_load(snap)
|
|
assert any("live-only" in r.message.lower()
|
|
or "live-only" in r.getMessage().lower()
|
|
for r in caplog.records) or any(
|
|
"no drift" in r.message.lower()
|
|
or "no drift" in r.getMessage().lower()
|
|
for r in caplog.records)
|