feat: TTL enforcement and operational hardening (SAND-WP-0009)

Add TTL parser, expires_at on create, extend_ttl and expire/reap APIs,
activity-core integration doc, repo classification, registry refresh,
HTTP parity, and 69 tests.
This commit is contained in:
tegwick 2026-06-24 12:44:04 +02:00
parent b58191b23e
commit df658e7ef9
20 changed files with 913 additions and 39 deletions

View file

@ -0,0 +1,77 @@
"""TTL and idle-reap expiry candidate selection."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.lifecycle.ttl import is_idle_expired, is_past_expiry
from sandboxer.models import SandboxState, SandboxStatus
from sandboxer.profiles.loader import load_profile
ExpireReason = Literal["ttl", "idle"]
@dataclass
class ExpireCandidate:
sandbox_id: str
profile_id: str
reason: ExpireReason
expires_at: datetime | None = None
updated_at: datetime | None = None
_LIVE_STATES = frozenset({SandboxState.READY, SandboxState.ACTIVE})
def find_expire_candidates(
store: SandboxStore,
*,
now: datetime | None = None,
) -> list[ExpireCandidate]:
ref = now or datetime.now(UTC)
candidates: list[ExpireCandidate] = []
seen: set[str] = set()
for status in store.list_all():
if status.state not in _LIVE_STATES:
continue
if is_past_expiry(status.expires_at, now=ref):
candidates.append(
ExpireCandidate(
sandbox_id=status.sandbox_id,
profile_id=status.profile_id,
reason="ttl",
expires_at=status.expires_at,
)
)
seen.add(status.sandbox_id)
continue
try:
profile = load_profile(status.profile_id)
except FileNotFoundError:
continue
if is_idle_expired(status.updated_at, profile.ttl.idle_reap, now=ref):
candidates.append(
ExpireCandidate(
sandbox_id=status.sandbox_id,
profile_id=status.profile_id,
reason="idle",
updated_at=status.updated_at,
)
)
seen.add(status.sandbox_id)
return sorted(candidates, key=lambda c: c.sandbox_id)
def apply_expired_state(status: SandboxStatus, *, now: datetime | None = None) -> SandboxStatus:
ref = now or datetime.now(UTC)
status.state = SandboxState.EXPIRED
status.updated_at = ref
return status

View file

@ -38,6 +38,8 @@ def emit_lifecycle_event(
"consumer": status.consumer.model_dump(),
"actor_type": status.consumer.actor.value,
"state": status.state.value,
"ttl": status.ttl,
"expires_at": status.expires_at.isoformat() if status.expires_at else None,
"reachability": status.reachability.model_dump() if status.reachability else None,
"telemetry": status.telemetry,
"timestamps": {
@ -58,6 +60,6 @@ def emit_lifecycle_event(
def event_type_for_state(state: SandboxState) -> str:
if state in (SandboxState.READY, SandboxState.DESTROYED):
if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED):
return "milestone"
return "note"

View file

@ -0,0 +1,121 @@
"""TTL duration parsing and expiry calculation."""
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta
from sandboxer.models import Profile
_DURATION_RE = re.compile(r"^(\d+)([smhd])$", re.IGNORECASE)
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
def parse_duration(value: str) -> timedelta:
"""Parse a duration string like ``4h``, ``30m``, ``1d``."""
raw = value.strip()
match = _DURATION_RE.match(raw)
if not match:
raise ValueError(f"Invalid duration: {value!r} (expected e.g. 4h, 30m, 1d)")
amount = int(match.group(1))
if amount <= 0:
raise ValueError(f"Duration must be positive: {value!r}")
unit = match.group(2).lower()
return timedelta(seconds=amount * _UNIT_SECONDS[unit])
def duration_seconds(value: str) -> int:
return int(parse_duration(value).total_seconds())
def resolve_initial_ttl(profile: Profile, request_ttl: str | None) -> str:
"""Pick create TTL from request override or profile default, capped at profile max."""
requested = request_ttl or profile.ttl.default
return cap_duration(requested, profile.ttl.max)
def cap_duration(requested: str, maximum: str) -> str:
"""Return ``requested`` if within ``maximum``; otherwise return ``maximum``."""
req_s = duration_seconds(requested)
max_s = duration_seconds(maximum)
if req_s > max_s:
return maximum
return requested
def expires_at_from(base: datetime, duration: str) -> datetime:
if base.tzinfo is None:
base = base.replace(tzinfo=UTC)
return base + parse_duration(duration)
def cap_expires_at(
candidate: datetime,
*,
anchor: datetime,
max_duration: str,
) -> datetime:
"""Cap ``candidate`` so it does not exceed ``anchor + max_duration``."""
ceiling = expires_at_from(anchor, max_duration)
if candidate.tzinfo is None:
candidate = candidate.replace(tzinfo=UTC)
return min(candidate, ceiling)
def extend_expires_at(
current: datetime,
*,
anchor: datetime,
extension: str,
max_duration: str,
) -> tuple[datetime, str]:
"""Add ``extension`` to ``current`` and cap at ``anchor + max_duration``."""
now = datetime.now(UTC)
base = max(current, now)
proposed = expires_at_from(base, extension)
capped = cap_expires_at(proposed, anchor=anchor, max_duration=max_duration)
applied = extension
if capped < proposed:
remaining = capped - base
if remaining.total_seconds() <= 0:
raise ValueError(f"Cannot extend: already at profile max ({max_duration})")
applied = format_timedelta(remaining)
return capped, applied
def format_timedelta(delta: timedelta) -> str:
seconds = int(delta.total_seconds())
if seconds <= 0:
raise ValueError("Duration must be positive")
if seconds >= 86400 and seconds % 86400 == 0:
return f"{seconds // 86400}d"
if seconds >= 3600 and seconds % 3600 == 0:
return f"{seconds // 3600}h"
if seconds >= 60 and seconds % 60 == 0:
return f"{seconds // 60}m"
return f"{seconds}s"
def is_past_expiry(expires_at: datetime | None, *, now: datetime | None = None) -> bool:
if expires_at is None:
return False
ref = now or datetime.now(UTC)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
return expires_at <= ref
def is_idle_expired(
updated_at: datetime,
idle_reap: str | None,
*,
now: datetime | None = None,
) -> bool:
if not idle_reap:
return False
ref = now or datetime.now(UTC)
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=UTC)
return updated_at + parse_duration(idle_reap) <= ref