-
Notifications
You must be signed in to change notification settings - Fork 0
fix: atomically preserve reconciliation snapshots #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,12 @@ | |
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import stat | ||
| import tempfile | ||
| from collections.abc import Mapping | ||
| from importlib.resources import files | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import base_cli | ||
|
|
@@ -92,18 +96,77 @@ def _persist_reconciliation( | |
| return | ||
|
|
||
| state_path = context.state_dir / "last-reconciliation.json" | ||
| state_path.parent.mkdir(parents=True, exist_ok=True) | ||
| state_path.write_text( | ||
| json.dumps(dict(record), sort_keys=True) + "\n", | ||
| encoding="utf-8", | ||
| ) | ||
| had_previous_snapshot = state_path.exists() | ||
| staged_path: Path | None = None | ||
| try: | ||
| serialized = _serialize_reconciliation(record) | ||
| state_path.parent.mkdir(parents=True, exist_ok=True) | ||
| with tempfile.NamedTemporaryFile( | ||
| mode="w", | ||
| encoding="utf-8", | ||
| dir=state_path.parent, | ||
| prefix=f".{state_path.name}.", | ||
| suffix=".tmp", | ||
| delete=False, | ||
| ) as staged: | ||
| staged_path = Path(staged.name) | ||
| staged.write(serialized) | ||
| staged.flush() | ||
| os.fsync(staged.fileno()) | ||
|
|
||
| _preserve_state_mode(staged_path, state_path) | ||
| temporary_input = context.temp_dir / "reconciliation-input.json" | ||
| temporary_input.write_text(serialized, encoding="utf-8") | ||
| context.on_cleanup(lambda: temporary_input.unlink(missing_ok=True)) | ||
| _replace_state(staged_path, state_path) | ||
| staged_path = None | ||
| except (OSError, TypeError, ValueError) as exc: | ||
| snapshot_message = ( | ||
| "the previous snapshot was left unchanged." | ||
| if had_previous_snapshot | ||
| else "no reconciliation snapshot was published." | ||
| ) | ||
| raise click.ClickException( | ||
| f"Could not persist the reconciliation snapshot; {snapshot_message}" | ||
| ) from exc | ||
| finally: | ||
| if staged_path is not None: | ||
| try: | ||
| staged_path.unlink(missing_ok=True) | ||
| except OSError: | ||
| pass | ||
|
|
||
| temporary_input = context.temp_dir / "reconciliation-input.json" | ||
| temporary_input.write_text( | ||
| json.dumps(dict(record), sort_keys=True) + "\n", | ||
| encoding="utf-8", | ||
| ) | ||
| context.on_cleanup(lambda: temporary_input.unlink(missing_ok=True)) | ||
|
|
||
| def _serialize_reconciliation(record: Mapping[str, Any]) -> str: | ||
| """Serialize once so both local artifacts describe the same snapshot.""" | ||
|
|
||
| return json.dumps(dict(record), sort_keys=True) + "\n" | ||
|
|
||
|
|
||
| def _replace_state(staged_path: Path, state_path: Path) -> None: | ||
| """Atomically publish a complete snapshot from the same filesystem.""" | ||
|
|
||
| os.replace(staged_path, state_path) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Durability gap: rename isn't fsynced, so the "atomic" guarantee is incomplete
Notably, this codebase's own Suggested fix: after |
||
| try: | ||
| directory_fd = os.open(state_path.parent, os.O_RDONLY) | ||
| except OSError: | ||
| if os.name != "nt": | ||
| raise | ||
| return | ||
| try: | ||
| os.fsync(directory_fd) | ||
| finally: | ||
| os.close(directory_fd) | ||
|
|
||
|
|
||
| def _preserve_state_mode(staged_path: Path, state_path: Path) -> None: | ||
| """Keep an existing snapshot's permissions across atomic replacement.""" | ||
|
|
||
| try: | ||
| mode = stat.S_IMODE(state_path.stat().st_mode) | ||
| except FileNotFoundError: | ||
| return | ||
| os.chmod(staged_path, mode) | ||
|
|
||
|
|
||
| def _service_option(function: Any) -> Any: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correctness: file-permission regression on every reconcile
tempfile.NamedTemporaryFilecreates the staged file with mode0o600(owner-only), andos.replacemoves that file (with its own permission bits) ontolast-reconciliation.json— it does not preserve whatever mode the destination previously had. Before this PR,state_path.write_text(...)created/kept the file at the umask-derived mode (typically0o644) and that mode was stable across rewrites.Reproduced locally:
So the very first
reconcileafter this change (and every one after it) silently narrowslast-reconciliation.jsonto owner-only, breaking any other user/process/service that previously could read it (a monitoring sidecar, a different service account, a CI artifact collector, etc.), with no message or migration path.Fix: either
os.chmod(staged_path, 0o644)(or read+reapply the previous file's mode) before the_replace_statecall, or make the intended permissions explicit/documented if 0600 is actually desired.