Keep non-production runs out of the budget audit log

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-28 19:55:04 +02:00
parent e594db5fd7
commit a85e8fec71
5 changed files with 70 additions and 7 deletions

View File

@ -209,7 +209,7 @@ def do_enforce(config: Config, state: State, *, demo: bool = False) -> None:
_echo(" Press Ctrl+C to stop.\n")
# One session for the whole daemon: the engagement backdate needs to see
# the previous tick's verdict, so it cannot be rebuilt per iteration.
session = new_session()
session = new_session(demo=demo)
try:
while True:
# Reload state from disk so CLI changes (e.g. new game

View File

@ -36,6 +36,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
BUDGET_LOG_FILE: Final = Path("/var/log/steam-backlog-enforcer/budget.jsonl")
BUDGET_DEMO_LOG_FILE: Final = Path("/var/log/steam-backlog-enforcer/budget-demo.jsonl")
_MAX_BYTES: Final = 5 * 1024 * 1024
_BACKUP_COUNT: Final = 5
@ -47,14 +48,38 @@ EVENT_DETECTOR_FAILURE: Final = "detector_failure"
EVENT_MANUAL_ADJUSTMENT: Final = "manual_adjustment"
def budget_log_path(*, demo: bool) -> Path:
"""Return the audit log for a demo or production run.
Demo runs get their own file for the same reason they get their own state
file: this log is the record used to reconstruct real incidents, and a run
that carries no enforcement weight must not be able to plant records in it
that describe a budget nobody was ever charged. A 60-second demo billing
700 seconds is indistinguishable, after the fact, from the production
daemon having done something inexplicable.
Args:
demo: Whether this is a demo run.
Returns:
The destination JSONL file.
"""
return BUDGET_DEMO_LOG_FILE if demo else BUDGET_LOG_FILE
class BudgetLog:
"""Append-only JSONL sink, rotated by size."""
def __init__(self, *, path: Path = BUDGET_LOG_FILE) -> None:
def __init__(self, *, path: Path) -> None:
"""Prepare a sink; the file is opened on first write.
*path* is required on purpose. It used to default to the production
log, so a throwaway probe run as root — ``BudgetLog()`` in a scratch
script — wrote invented records straight into the trail this module
exists to keep trustworthy. Naming the destination is now unavoidable.
Args:
path: Destination JSONL file.
path: Destination JSONL file, from :func:`budget_log_path`.
"""
self._path = path
self._sink: logging.Logger | None = None

View File

@ -19,7 +19,11 @@ from typing import TYPE_CHECKING, Protocol
from steam_backlog_enforcer._desktop_env import desktop_uid, resolve_desktop_user
from steam_backlog_enforcer._playtime_engagement import EngagementTracker
from steam_backlog_enforcer._playtime_log import BudgetLog, TickJournal
from steam_backlog_enforcer._playtime_log import (
BudgetLog,
TickJournal,
budget_log_path,
)
if TYPE_CHECKING:
from steam_backlog_enforcer._engagement_types import EngagementVerdict
@ -70,14 +74,19 @@ class PlaytimeSession:
journal: TickRecorder
def new_session() -> PlaytimeSession:
def new_session(*, demo: bool = False) -> PlaytimeSession:
"""Build the session the enforce loop threads through every tick.
Args:
demo: Whether this is a short-budget demo run. Demo runs journal to
their own file, so they cannot plant records in the production
audit trail.
Returns:
A session bound to the desktop user's X display and runtime dir.
"""
uid = desktop_uid(resolve_desktop_user())
return PlaytimeSession(
tracker=EngagementTracker(uid=uid),
journal=TickJournal(BudgetLog()),
journal=TickJournal(BudgetLog(path=budget_log_path(demo=demo))),
)

View File

@ -21,11 +21,14 @@ from steam_backlog_enforcer._engagement_types import (
EngagementVerdict,
)
from steam_backlog_enforcer._playtime_log import (
BUDGET_DEMO_LOG_FILE,
BUDGET_LOG_FILE,
EVENT_DETECTOR_FAILURE,
EVENT_HEARTBEAT,
EVENT_VERDICT_CHANGE,
BudgetLog,
TickJournal,
budget_log_path,
)
from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState
@ -52,6 +55,18 @@ def _records(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text().splitlines() if line]
class TestBudgetLogPath:
def test_production_run_journals_to_the_real_trail(self) -> None:
assert budget_log_path(demo=False) == BUDGET_LOG_FILE
def test_demo_run_cannot_reach_the_production_trail(self) -> None:
# A demo bills against a 60-second budget. Letting those records land
# in the production log makes the log unusable for the one job it has:
# reconstructing what the real budget actually did.
assert budget_log_path(demo=True) == BUDGET_DEMO_LOG_FILE
assert budget_log_path(demo=True) != BUDGET_LOG_FILE
class TestBudgetLog:
def test_creates_its_directory_on_first_write(self, tmp_path: Path) -> None:
target = tmp_path / "deep" / "nested" / "budget.jsonl"

View File

@ -5,7 +5,11 @@ from __future__ import annotations
from unittest.mock import patch
from steam_backlog_enforcer._playtime_engagement import EngagementTracker
from steam_backlog_enforcer._playtime_log import TickJournal
from steam_backlog_enforcer._playtime_log import (
BUDGET_DEMO_LOG_FILE,
BUDGET_LOG_FILE,
TickJournal,
)
from steam_backlog_enforcer._playtime_session import new_session
PS = "steam_backlog_enforcer._playtime_session"
@ -31,3 +35,13 @@ class TestNewSession:
):
session = new_session()
assert "/run/user/1000/" in str(session.tracker._holder_path)
def test_demo_journals_away_from_the_production_trail(self) -> None:
with (
patch(f"{PS}.resolve_desktop_user", return_value="kuhy"),
patch(f"{PS}.desktop_uid", return_value=1000),
):
live = new_session()
demo = new_session(demo=True)
assert demo.journal._log._path == BUDGET_DEMO_LOG_FILE
assert live.journal._log._path == BUDGET_LOG_FILE