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:
parent
b58191b23e
commit
df658e7ef9
20 changed files with 913 additions and 39 deletions
77
src/sandboxer/lifecycle/expire.py
Normal file
77
src/sandboxer/lifecycle/expire.py
Normal 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
|
||||
|
|
@ -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"
|
||||
121
src/sandboxer/lifecycle/ttl.py
Normal file
121
src/sandboxer/lifecycle/ttl.py
Normal 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
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue