chore(integ): check the metadata scenarios against a JSON truth
metadata.py/.ts now return a dict per scenario and print one JSON line for check_json.py, replacing the byte diff against truth_metadata.txt. The moto and from_state chatter can no longer break the check, and before/after are real booleans instead of the TypeScript side printing Python's True/False spelling to satisfy a shared file.
This commit is contained in:
@@ -56,7 +56,6 @@ jobs:
|
||||
- 'integ/targets.json'
|
||||
- 'integ/runners/**'
|
||||
- 'integ/resources/chroma/**'
|
||||
- 'integ/check_lines.sh'
|
||||
- 'python/mirage/accessor/mongodb.py'
|
||||
- 'python/mirage/core/mongodb/**'
|
||||
- 'python/mirage/commands/builtin/mongodb/**'
|
||||
@@ -107,7 +106,6 @@ jobs:
|
||||
fuse:
|
||||
- 'integ/fuse/**'
|
||||
- 'integ/check_json.py'
|
||||
- 'integ/check_lines.sh'
|
||||
- 'integ/package.json'
|
||||
- 'python/mirage/fuse/**'
|
||||
- 'python/mirage/workspace/fuse.py'
|
||||
@@ -152,10 +150,10 @@ jobs:
|
||||
|
||||
# Snapshot roundtrip and out-of-band-delete GC only: the per-command
|
||||
# metadata cases live in integ/unix/meta{,_overlay} now.
|
||||
- name: Run metadata snapshot/GC scenarios and diff against truth
|
||||
- name: Run metadata snapshot/GC scenarios and check against truth
|
||||
run: |
|
||||
./python/.venv/bin/python integ/metadata.py > /tmp/metadata.out
|
||||
diff integ/truth_metadata.txt /tmp/metadata.out
|
||||
./python/.venv/bin/python integ/metadata.py 2>&1 \
|
||||
| ./python/.venv/bin/python integ/check_json.py integ/truth_metadata.json
|
||||
|
||||
- name: Run lancedb integ (embedded, JSON harness)
|
||||
env:
|
||||
@@ -287,11 +285,11 @@ jobs:
|
||||
|
||||
# Snapshot roundtrip and out-of-band-delete GC only: the per-command
|
||||
# metadata cases live in integ/unix/meta{,_overlay} now.
|
||||
- name: Run metadata snapshot/GC scenarios and diff against truth
|
||||
# check_json.py is stdlib-only, so the runner's preinstalled python3
|
||||
# serves it; this job sets up no venv of its own.
|
||||
- name: Run metadata snapshot/GC scenarios and check against truth
|
||||
working-directory: integ
|
||||
run: |
|
||||
pnpm exec tsx metadata.ts > /tmp/ts-metadata.out
|
||||
diff truth_metadata.txt /tmp/ts-metadata.out
|
||||
run: pnpm exec tsx metadata.ts 2>&1 | python3 check_json.py truth_metadata.json
|
||||
|
||||
- name: Run cross-mount commands (ram -> ram/s3 via MinIO)
|
||||
working-directory: integ
|
||||
|
||||
+46
-28
@@ -19,8 +19,13 @@
|
||||
# stat paths, but it cannot snapshot a workspace, reload it onto a fresh
|
||||
# resource, or mutate a backend out of band. Retiring these needs snapshot
|
||||
# and namespace support in the harness, not another case file.
|
||||
#
|
||||
# Emits its result as one JSON line for integ/check_json.py, so the moto
|
||||
# server's own chatter cannot break the check and the TypeScript twin
|
||||
# reports `before`/`after` as real booleans rather than Python spellings.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -51,6 +56,11 @@ from mirage.types import ConsistencyPolicy, FileStat, PathSpec # noqa: E402
|
||||
if _ON_PATH:
|
||||
sys.path.insert(0, _INTEG_DIR)
|
||||
|
||||
# Every value the truth file asserts: text for the overlay attributes and the
|
||||
# ls row, a real boolean for the GC pair. Mirrors the TypeScript twin's
|
||||
# Record<string, string | boolean | null>.
|
||||
MetaValue = str | bool | None
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||
BUCKET = "mirage-integ-meta"
|
||||
CREDS = dict(aws_access_key_id="testing",
|
||||
@@ -58,26 +68,32 @@ CREDS = dict(aws_access_key_id="testing",
|
||||
region_name="us-east-1")
|
||||
|
||||
|
||||
def _meta_field(st: FileStat, field: str) -> str:
|
||||
if field == "mode":
|
||||
value = oct(st.mode)[2:] if st.mode is not None else "-"
|
||||
elif field == "uid":
|
||||
value = str(st.uid) if st.uid is not None else "-"
|
||||
elif field == "gid":
|
||||
value = str(st.gid) if st.gid is not None else "-"
|
||||
else:
|
||||
def overlay_stat_fields(st: FileStat) -> dict[str, MetaValue]:
|
||||
"""Render the four overlay attributes as truth-file values.
|
||||
|
||||
`uid` and `gid` are `int | str | None` in both languages, so they are
|
||||
normalized to text rather than left to serialize as whichever the
|
||||
backend happened to store.
|
||||
|
||||
Args:
|
||||
st (FileStat): the stat of the restored file.
|
||||
|
||||
Returns:
|
||||
dict[str, MetaValue]: the asserted keys for this scenario.
|
||||
"""
|
||||
return {
|
||||
"overlay_snapshot_mode":
|
||||
oct(st.mode)[2:] if st.mode is not None else None,
|
||||
"overlay_snapshot_uid": str(st.uid) if st.uid is not None else None,
|
||||
"overlay_snapshot_gid": str(st.gid) if st.gid is not None else None,
|
||||
# First 19 chars ("2026-01-02T15:30:00") so the Z vs +00:00 suffix
|
||||
# never reaches the byte-diffed truth file.
|
||||
value = st.modified[:19] if st.modified else "-"
|
||||
return f"{field}={value}"
|
||||
# never reaches the truth file.
|
||||
"overlay_snapshot_mtime": st.modified[:19] if st.modified else None,
|
||||
}
|
||||
|
||||
|
||||
def meta_stat_line(st: FileStat, fields: tuple[str, ...]) -> str:
|
||||
return " ".join(_meta_field(st, field) for field in fields)
|
||||
|
||||
|
||||
async def run_overlay_snapshot_roundtrip(ws: Workspace,
|
||||
fresh: S3Resource) -> None:
|
||||
async def run_overlay_snapshot_roundtrip(
|
||||
ws: Workspace, fresh: S3Resource) -> dict[str, MetaValue]:
|
||||
# Overlay attrs live in namespace NODES, so they must survive a
|
||||
# snapshot even though the s3 resource is rebuilt fresh at load
|
||||
# (s3 snapshots redact creds and require a resources= override).
|
||||
@@ -89,13 +105,12 @@ async def run_overlay_snapshot_roundtrip(ws: Workspace,
|
||||
restored = await Workspace.load(str(snap), resources={"/data": fresh})
|
||||
st, _ = await restored.dispatch("stat",
|
||||
PathSpec.from_str_path("/data/f.txt"))
|
||||
print("=== overlay_snapshot_roundtrip ===")
|
||||
print(meta_stat_line(st, ("mode", "uid", "gid", "mtime")))
|
||||
await restored.execute("rm /data/f.txt")
|
||||
shutil.rmtree(snap.parent)
|
||||
return overlay_stat_fields(st)
|
||||
|
||||
|
||||
async def run_overlay_orphan_gc(config: S3Config) -> None:
|
||||
async def run_overlay_orphan_gc(config: S3Config) -> dict[str, MetaValue]:
|
||||
# A chmod on a slot-less backend (s3) creates an attribute overlay in
|
||||
# the namespace. When the object is deleted out-of-band (another agent,
|
||||
# the raw API), the overlay is orphaned. Under ALWAYS, a stat that the
|
||||
@@ -109,11 +124,10 @@ async def run_overlay_orphan_gc(config: S3Config) -> None:
|
||||
await mount.execute_op("unlink", "/data/g.txt")
|
||||
await ws.execute("stat /data/g.txt")
|
||||
after = ws.namespace.meta_for("/data/g.txt") is not None
|
||||
print("=== overlay_orphan_gc ===")
|
||||
print(f"before={before} after={after}")
|
||||
return {"overlay_orphan_before": before, "overlay_orphan_after": after}
|
||||
|
||||
|
||||
async def run_snapshot_roundtrip() -> None:
|
||||
async def run_snapshot_roundtrip() -> dict[str, MetaValue]:
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("echo alpha > /data/f.txt")
|
||||
await ws.execute("chmod 601 /data/f.txt && chown 500:dev /data/f.txt"
|
||||
@@ -122,9 +136,9 @@ async def run_snapshot_roundtrip() -> None:
|
||||
await ws.snapshot(str(snap))
|
||||
restored = await Workspace.load(str(snap))
|
||||
result = await restored.execute("ls -l /data")
|
||||
print("=== snapshot_meta_roundtrip ===")
|
||||
print((await result.stdout_str()).rstrip())
|
||||
line = (await result.stdout_str()).rstrip()
|
||||
shutil.rmtree(snap.parent)
|
||||
return {"snapshot_ls_line": line}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -140,16 +154,20 @@ async def main() -> None:
|
||||
aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
path_style=True)
|
||||
result: dict[str, MetaValue] = {}
|
||||
try:
|
||||
boto3.client("s3", endpoint_url=endpoint,
|
||||
**CREDS).create_bucket(Bucket=bucket)
|
||||
s3_ws = Workspace({"/data": S3Resource(config)}, mode=MountMode.WRITE)
|
||||
await run_overlay_snapshot_roundtrip(s3_ws, S3Resource(config))
|
||||
await run_overlay_orphan_gc(config)
|
||||
overlay = await run_overlay_snapshot_roundtrip(s3_ws,
|
||||
S3Resource(config))
|
||||
result.update(overlay)
|
||||
result.update(await run_overlay_orphan_gc(config))
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
await run_snapshot_roundtrip()
|
||||
result.update(await run_snapshot_roundtrip())
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+32
-23
@@ -19,6 +19,11 @@
|
||||
// stat paths, but it cannot snapshot a workspace, reload it onto a fresh
|
||||
// resource, or mutate a backend out of band. Retiring these needs snapshot
|
||||
// and namespace support in the harness, not another case file.
|
||||
//
|
||||
// Emits its result as one JSON line for integ/check_json.py, the same truth
|
||||
// file the Python twin is checked against. That is why `before`/`after` are
|
||||
// real booleans here: the byte-diffed truth file this replaced forced this
|
||||
// side to print Python's `True`/`False` spelling.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
@@ -52,20 +57,24 @@ function s3ResourceFromEnv(keyPrefix: string): S3Resource {
|
||||
})
|
||||
}
|
||||
|
||||
// Kept local now that cases.ts is gone: four fields, and the mtime slice keeps
|
||||
// the Z vs +00:00 suffix out of the byte-diffed truth file.
|
||||
function metaStatLine(st: FileStat, fields: ReadonlyArray<string>): string {
|
||||
return fields
|
||||
.map((field) => {
|
||||
if (field === 'mode') return `mode=${st.mode !== undefined ? st.mode.toString(8) : '-'}`
|
||||
if (field === 'uid') return `uid=${st.uid !== undefined ? String(st.uid) : '-'}`
|
||||
if (field === 'gid') return `gid=${st.gid !== undefined ? String(st.gid) : '-'}`
|
||||
return `mtime=${st.modified !== undefined ? st.modified.slice(0, 19) : '-'}`
|
||||
})
|
||||
.join(' ')
|
||||
// uid and gid are `number | string | null` in both languages, so they are
|
||||
// normalized to text rather than left to serialize as whichever the backend
|
||||
// happened to store. The mtime slice keeps the Z vs +00:00 suffix out of the
|
||||
// truth file.
|
||||
function overlayStatFields(st: FileStat): Record<string, string | null> {
|
||||
return {
|
||||
overlay_snapshot_mode: st.mode !== undefined && st.mode !== null ? st.mode.toString(8) : null,
|
||||
overlay_snapshot_uid: st.uid !== undefined && st.uid !== null ? String(st.uid) : null,
|
||||
overlay_snapshot_gid: st.gid !== undefined && st.gid !== null ? String(st.gid) : null,
|
||||
overlay_snapshot_mtime:
|
||||
st.modified !== undefined && st.modified !== null ? st.modified.slice(0, 19) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function runOverlaySnapshotRoundtrip(ws: Workspace, fresh: S3Resource): Promise<void> {
|
||||
async function runOverlaySnapshotRoundtrip(
|
||||
ws: Workspace,
|
||||
fresh: S3Resource,
|
||||
): Promise<Record<string, string | null>> {
|
||||
// Overlay attrs live in namespace NODES, so they must survive a
|
||||
// snapshot even though the s3 resource is rebuilt fresh at load
|
||||
// (s3 snapshots redact creds and require a resource override).
|
||||
@@ -78,14 +87,13 @@ async function runOverlaySnapshotRoundtrip(ws: Workspace, fresh: S3Resource): Pr
|
||||
await ws.snapshot(snap)
|
||||
const restored = await Workspace.load(snap, {}, { '/data': fresh })
|
||||
const st = (await restored.dispatch('stat', '/data/f.txt')) as FileStat
|
||||
console.log('=== overlay_snapshot_roundtrip ===')
|
||||
console.log(metaStatLine(st, ['mode', 'uid', 'gid', 'mtime']))
|
||||
await restored.execute('rm /data/f.txt')
|
||||
await restored.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return overlayStatFields(st)
|
||||
}
|
||||
|
||||
async function runOverlayOrphanGc(keyPrefix: string): Promise<void> {
|
||||
async function runOverlayOrphanGc(keyPrefix: string): Promise<Record<string, boolean>> {
|
||||
// A chmod on a slot-less backend (s3) creates an attribute overlay. When
|
||||
// the object is deleted out-of-band (raw op, another agent), the overlay
|
||||
// is orphaned. Under ALWAYS, a single-mount shell stat the backend reports
|
||||
@@ -102,14 +110,13 @@ async function runOverlayOrphanGc(keyPrefix: string): Promise<void> {
|
||||
await ws.dispatch('unlink', '/data/g.txt')
|
||||
await ws.execute('stat /data/g.txt')
|
||||
const after = ws.namespace.metaFor('/data/g.txt') !== null
|
||||
console.log('=== overlay_orphan_gc ===')
|
||||
console.log(`before=${before ? 'True' : 'False'} after=${after ? 'True' : 'False'}`)
|
||||
return { overlay_orphan_before: before, overlay_orphan_after: after }
|
||||
} finally {
|
||||
await ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function runSnapshotRoundtrip(): Promise<void> {
|
||||
async function runSnapshotRoundtrip(): Promise<Record<string, string>> {
|
||||
const ws = new Workspace({ '/data': new RAMResource() }, { mode: MountMode.WRITE })
|
||||
await ws.execute('echo alpha > /data/f.txt')
|
||||
await ws.execute(
|
||||
@@ -120,11 +127,11 @@ async function runSnapshotRoundtrip(): Promise<void> {
|
||||
await ws.snapshot(snap)
|
||||
const restored = await Workspace.load(snap)
|
||||
const result = await restored.execute('ls -l /data')
|
||||
console.log('=== snapshot_meta_roundtrip ===')
|
||||
console.log(new TextDecoder().decode(result.stdout).trimEnd())
|
||||
const line = new TextDecoder().decode(result.stdout).trimEnd()
|
||||
await ws.close()
|
||||
await restored.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return { snapshot_ls_line: line }
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -133,13 +140,15 @@ async function main(): Promise<void> {
|
||||
{ '/data': s3ResourceFromEnv(prefix) },
|
||||
{ mode: MountMode.WRITE },
|
||||
)
|
||||
const result: Record<string, string | boolean | null> = {}
|
||||
try {
|
||||
await runOverlaySnapshotRoundtrip(s3Ws, s3ResourceFromEnv(prefix))
|
||||
Object.assign(result, await runOverlaySnapshotRoundtrip(s3Ws, s3ResourceFromEnv(prefix)))
|
||||
} finally {
|
||||
await s3Ws.close()
|
||||
}
|
||||
await runOverlayOrphanGc(`${prefix}gc/`)
|
||||
await runSnapshotRoundtrip()
|
||||
Object.assign(result, await runOverlayOrphanGc(`${prefix}gc/`))
|
||||
Object.assign(result, await runSnapshotRoundtrip())
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
void main()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"overlay_snapshot_mode": "601",
|
||||
"overlay_snapshot_uid": "500",
|
||||
"overlay_snapshot_gid": "dev",
|
||||
"overlay_snapshot_mtime": "2026-01-02T15:30:00",
|
||||
"overlay_orphan_before": true,
|
||||
"overlay_orphan_after": false,
|
||||
"snapshot_ls_line": "-rw------x 1 500 dev 6 Jan 2 15:30 f.txt"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
=== overlay_snapshot_roundtrip ===
|
||||
mode=601 uid=500 gid=dev mtime=2026-01-02T15:30:00
|
||||
=== overlay_orphan_gc ===
|
||||
before=True after=False
|
||||
=== snapshot_meta_roundtrip ===
|
||||
-rw------x 1 500 dev 6 Jan 2 15:30 f.txt
|
||||
Reference in New Issue
Block a user