from __future__ import annotations import os from pathlib import Path import pytest from k1link.artifact_gateway import ( ArtifactCacheCapacityError, ArtifactGateway, ArtifactIntegrityError, ArtifactStoreUnavailable, CentralArtifactStore, LocalArtifactCache, ) def _gateway( tmp_path: Path, *, max_bytes: int = 1024, ) -> tuple[ArtifactGateway, Path, Path]: central_root = tmp_path / "central" local_root = tmp_path / "local" return ( ArtifactGateway( CentralArtifactStore(central_root, create=True), LocalArtifactCache( local_root, max_bytes=max_bytes, free_space_reserve_bytes=0, ), ), central_root, local_root, ) def test_gateway_publishes_deduplicated_objects_and_resolves_through_local_cache( tmp_path: Path, ) -> None: gateway, central_root, _local_root = _gateway(tmp_path) first = tmp_path / "first.rrd" duplicate = tmp_path / "duplicate.rrd" first.write_bytes(b"RRF2same-content") duplicate.write_bytes(first.read_bytes()) manifest = gateway.publish( namespace="sessions", key="session-1", artifact_type="recorded-session", subject_id="session-1", sources=( ("base-rrd", "application/vnd.rerun.rrd", first), ("overlay-rrd", "application/vnd.rerun.rrd", duplicate), ), metadata={"display-name": "Session 1"}, ) assert len(manifest.members) == 2 assert manifest.members[0].sha256 == manifest.members[1].sha256 objects = tuple((central_root / "objects" / "sha256").glob("*/*")) assert len(objects) == 1 cold = gateway.resolve_role("sessions", "session-1", "overlay-rrd") assert cold.path.read_bytes() == first.read_bytes() assert cold.cache_hit is False assert cold.central_available is True warm = gateway.resolve_role("sessions", "session-1", "overlay-rrd") assert warm.path == cold.path assert warm.cache_hit is True def test_persistent_pin_survives_restart_and_resolves_with_central_offline( tmp_path: Path, ) -> None: gateway, central_root, local_root = _gateway(tmp_path) source = tmp_path / "overlay.rrd" source.write_bytes(b"RRF2offline-ready") gateway.publish( namespace="sessions", key="session-1", artifact_type="recorded-session", subject_id="session-1", sources=(("overlay-rrd", "application/vnd.rerun.rrd", source),), ) gateway.pin_reference("sessions", "session-1", pin_id="session-1") offline_root = tmp_path / "central-offline" os.replace(central_root, offline_root) restarted = ArtifactGateway( gateway.store, LocalArtifactCache( local_root, max_bytes=1024, free_space_reserve_bytes=0, ), ) resolved = restarted.resolve_role("sessions", "session-1", "overlay-rrd") assert resolved.path.read_bytes() == source.read_bytes() assert resolved.cache_hit is True assert resolved.central_available is False status = restarted.cache.status() assert status.pinned_object_count == 1 assert status.pinned_bytes == len(source.read_bytes()) def test_offline_cache_miss_is_explicit(tmp_path: Path) -> None: gateway, central_root, local_root = _gateway(tmp_path) source = tmp_path / "overlay.rrd" source.write_bytes(b"RRF2not-fetched") gateway.publish( namespace="sessions", key="session-1", artifact_type="recorded-session", subject_id="session-1", sources=(("overlay-rrd", "application/vnd.rerun.rrd", source),), ) os.replace(central_root, tmp_path / "central-offline") restarted = ArtifactGateway( gateway.store, LocalArtifactCache(local_root, max_bytes=1024, free_space_reserve_bytes=0), ) with pytest.raises(ArtifactStoreUnavailable, match="offline cache"): restarted.resolve_role("sessions", "session-1", "overlay-rrd") def test_unpinned_lru_is_evicted_but_pinned_object_is_preserved(tmp_path: Path) -> None: gateway, _central_root, _local_root = _gateway(tmp_path, max_bytes=18) first = tmp_path / "first.bin" second = tmp_path / "second.bin" third = tmp_path / "third.bin" first.write_bytes(b"a" * 9) second.write_bytes(b"b" * 9) third.write_bytes(b"c" * 9) for key, source in (("first", first), ("second", second), ("third", third)): gateway.publish( namespace="objects", key=key, artifact_type="test-object", subject_id=key, sources=(("payload", "application/octet-stream", source),), ) gateway.pin_reference("objects", "first", pin_id="keep-first") second_resolved = gateway.resolve_role("objects", "second", "payload") assert second_resolved.path.is_file() gateway.resolve_role("objects", "third", "payload") first_member = gateway.store.resolve_reference("objects", "first").member("payload") second_member = gateway.store.resolve_reference("objects", "second").member("payload") third_member = gateway.store.resolve_reference("objects", "third").member("payload") assert gateway.cache.get(first_member) is not None assert gateway.cache.get(second_member) is None assert gateway.cache.get(third_member) is not None def test_cache_refuses_object_larger_than_its_quota(tmp_path: Path) -> None: gateway, _central_root, _local_root = _gateway(tmp_path, max_bytes=8) source = tmp_path / "large.bin" source.write_bytes(b"x" * 9) gateway.publish( namespace="objects", key="large", artifact_type="test-object", subject_id="large", sources=(("payload", "application/octet-stream", source),), ) with pytest.raises(ArtifactCacheCapacityError, match="exceeds"): gateway.resolve_role("objects", "large", "payload") def test_same_size_local_corruption_is_detected_after_stat_identity_changes( tmp_path: Path, ) -> None: gateway, _central_root, _local_root = _gateway(tmp_path) source = tmp_path / "payload.bin" source.write_bytes(b"original") gateway.publish( namespace="objects", key="payload", artifact_type="test-object", subject_id="payload", sources=(("payload", "application/octet-stream", source),), ) resolved = gateway.resolve_role("objects", "payload", "payload") resolved.path.write_bytes(b"tampered") os.utime(resolved.path, ns=(resolved.path.stat().st_atime_ns, 1)) member = gateway.store.resolve_reference("objects", "payload").member("payload") assert gateway.cache.get(member) is None repaired = gateway.resolve_role("objects", "payload", "payload") assert repaired.path.read_bytes() == b"original" def test_store_rejects_corrupt_existing_content_addressed_object(tmp_path: Path) -> None: store = CentralArtifactStore(tmp_path / "central", create=True) source = tmp_path / "source.bin" source.write_bytes(b"original") published = store.publish_file(source) store.object_path(published.sha256).write_bytes(b"tampered") with pytest.raises(ArtifactIntegrityError, match="corrupt"): store.publish_file(source)