mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 12:27:33 +01:00
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:
parent
e594db5fd7
commit
a85e8fec71
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))),
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user