Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/reference/configuration-backup.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ summary or dropping unknown fields.
The HTTP restore path uses the existing 64 MiB local-snapshot budget, without
changing the 64 kB ordinary request budget. Oversize or invalid envelopes reject
without partial recovery; CLI/effect transport keeps its existing bounds.
Restored files use owner-only permissions. POSIX checkpoint tests and the
packaged browser journey do not qualify native Windows execution, provider
promotion, a destination machine or long-duration SQLite operation.
Restored checkpoint files use owner-only POSIX modes. Windows ACL behavior
has not been independently qualified; choose a private destination directory.
Native Windows tests cover the configuration-only CLI export/verify/restore and
local HTTP checkpoint with UTF-8 values. The packaged browser journey, full
state backup, provider promotion, a destination machine and long-duration SQLite
operation remain separate qualifications.
19 changes: 10 additions & 9 deletions loopx/cli_commands/configuration_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,19 @@ def handle_configuration_backup(args, *, registry_path, print_payload, output_fo
path = Path(args.output).expanduser()
# Keep backups immutable, including dangling symlink destinations.
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as stream:
staging = Path(stream.name)
try:
stream = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False)
staging = Path(stream.name)
try:
with stream:
stream.write(json.dumps(backup, ensure_ascii=False, indent=2) + "\n")
stream.flush()
os.fsync(stream.fileno())
# An exclusive hard-link publication cannot replace a
# destination created by another exporter.
os.link(staging, path)
finally:
staging.unlink(missing_ok=True)
if json.loads(path.read_text()) != backup:
# Publish only after closing the file: Windows cannot unlink
# an open staging file, and the hard link never replaces a peer.
os.link(staging, path)
finally:
staging.unlink(missing_ok=True)
if json.loads(path.read_text(encoding="utf-8")) != backup:
raise RuntimeError("configuration backup export readback mismatch")
payload.update(status="exported", written=True)
else:
Expand Down
14 changes: 10 additions & 4 deletions loopx/control_plane/configuration_backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,16 @@ export async function restoreConfigurationBackup(value: unknown): Promise<JsonOb
const receipt = {...verification, status: "restored", written: true,
live_configuration_changed: false, configuration_files: files.map(([name]) => name)};
await durableWriteJson(join(staging, "restore-receipt.json"), receipt);
// Exclusive reservation prevents a competing restore from being overwritten.
await mkdir(destination, {mode: 0o700});
try { await rename(staging, destination); }
catch (error) { await rmdir(destination); throw error; }
if (process.platform === "win32") {
// Windows refuses a directory rename into an existing destination,
// so the absent-target rename publishes the complete checkpoint.
await rename(staging, destination);
} else {
// POSIX can replace an empty directory; reserve the name exclusively.
await mkdir(destination, {mode: 0o700});
try { await rename(staging, destination); }
catch (error) { await rmdir(destination); throw error; }
}
return receipt;
} finally { await rm(staging, {recursive: true, force: true}); }
}
Expand Down
22 changes: 19 additions & 3 deletions tests/control_plane_ts/configuration_backup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {test} from "node:test";
import assert from "node:assert/strict";
import {mkdtemp, mkdir, readFile, realpath, rm, symlink} from "node:fs/promises";
import {mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {captureConfigurationBackup, restoreConfigurationBackup, verifyConfigurationBackup} from "../../loopx/control_plane/configuration_backup.ts";
Expand All @@ -27,6 +27,22 @@ test("checkpoint preserves complete optional configuration and never certifies p
assert.deepEqual(JSON.parse(await readFile(join(destination, "machine/configuration.json"), "utf8")),
(backup.data as Record<string, unknown>).machine_configuration);
await assert.rejects(restoreConfigurationBackup({...request, execute: true}), /already exists/);
assert.deepEqual(JSON.parse(await readFile(join(destination, "configuration-backup.json"), "utf8")), backup);
} finally {await rm(root, {recursive: true, force: true});}
});

test("competing restores publish one complete checkpoint without replacing it", async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), "configuration-backup-")));
try {
const backup = snapshot(), destination = join(root, "restored");
const request = {backup, destination, expected_sha256: backup.sha256, execute: true};
const attempts = await Promise.allSettled([
restoreConfigurationBackup(request), restoreConfigurationBackup(request),
]);
assert.equal(attempts.filter((attempt) => attempt.status === "fulfilled").length, 1);
assert.equal(attempts.filter((attempt) => attempt.status === "rejected").length, 1);
assert.deepEqual(JSON.parse(await readFile(join(destination, "configuration-backup.json"), "utf8")), backup);
assert.deepEqual((await readdir(root)).sort(), ["restored"]);
} finally {await rm(root, {recursive: true, force: true});}
});

Expand All @@ -46,8 +62,8 @@ test("dangling targets and symlink ancestors cannot redirect recovery", async ()
try {
const backup = snapshot();
await mkdir(join(root, "physical"));
await symlink(join(root, "physical"), join(root, "alias"), "dir");
await symlink(join(root, "missing"), join(root, "dangling"));
await symlink(join(root, "physical"), join(root, "alias"), process.platform === "win32" ? "junction" : "dir");
await symlink(join(root, "missing"), join(root, "dangling"), process.platform === "win32" ? "junction" : "file");
for (const destination of [join(root, "alias/new"), join(root, "dangling")]) {
await assert.rejects(restoreConfigurationBackup({backup, destination, expected_sha256: backup.sha256, execute: true}));
}
Expand Down
28 changes: 23 additions & 5 deletions tests/test_configuration_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,15 @@ def environment(tmp_path, monkeypatch):


def cli(*args):
environment = {**os.environ, "LOOPX_USAGE_PING": "0"}
if os.name == "nt":
# Exercise native Windows locale decoding even if pytest uses -X utf8.
environment["PYTHONUTF8"] = "0"
# Keep captured CLI JSON decodable when the parent pytest uses UTF-8.
environment["PYTHONIOENCODING"] = "utf-8"
result = subprocess.run([sys.executable, "-m", "loopx.cli", "--format", "json", *map(str, args)],
env={**os.environ, "LOOPX_USAGE_PING": "0"}, capture_output=True, text=True, timeout=60)
env=environment, capture_output=True, text=True,
encoding="utf-8" if os.name == "nt" else None, timeout=60)
return result.returncode, json.loads(result.stdout)


Expand All @@ -67,8 +74,11 @@ def test_cli_export_verify_restore_and_occupied_target(environment, tmp_path):
code, exported = cli(*arguments, "export", "--output", output, "--execute")
assert code == 0 and exported["goal_count"] == 1
original = output.read_bytes()
assert "完整".encode("utf-8") in original
assert cli(*arguments, "export", "--output", output, "--execute")[0] == 1
assert output.read_bytes() == original and output.stat().st_mode & 0o777 == 0o600
assert output.read_bytes() == original
if os.name != "nt":
assert output.stat().st_mode & 0o777 == 0o600
assert cli(*arguments, "verify", "--input", output)[0] == 0
restore = (*arguments, "restore", "--input", output, "--expected-sha256", exported["sha256"], "--destination", tmp_path / "restored")
assert cli(*restore)[1]["written"] is False
Expand Down Expand Up @@ -141,10 +151,18 @@ def post(operation, body):
status, response = post("restore", body)
assert status == 200, response
assert response["status"] == "preview"
assert not (runtime / "backups/configuration").exists()
assert post("restore", {**body, "execute": True})[1]["status"] == "restored"
checkpoint = runtime / "backups/configuration" / exported["sha256"]
assert not checkpoint.exists()
assert post("restore", {**body, "expected_sha256": "changed", "execute": True})[0] == 400
assert not checkpoint.exists()
status, restored = post("restore", {**body, "execute": True})
assert status == 200 and restored["status"] == "restored"
assert restored["checkpoint_ref"] == f"backups/configuration/{exported['sha256']}"
saved = (checkpoint / "configuration-backup.json").read_bytes()
assert json.loads(saved.decode("utf-8")) == exported["backup"]
assert "完整".encode("utf-8") in saved
assert post("restore", {**body, "execute": True})[0] == 400
assert post("restore", {**body, "expected_sha256": "changed"})[0] == 400
assert (checkpoint / "configuration-backup.json").read_bytes() == saved
finally:
server.shutdown()
server.server_close()
Expand Down
Loading