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:
2026-06-24 12:44:04 +02:00
parent b58191b23e
commit df658e7ef9
20 changed files with 913 additions and 39 deletions

View File

@@ -3,10 +3,17 @@
from __future__ import annotations
from sandboxer.extensions.registry import load_extension, resolve_backend
from sandboxer.lifecycle.expire import (
ExpireCandidate,
apply_expired_state,
find_expire_candidates,
)
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
from sandboxer.lifecycle.store import SandboxStore, utcnow
from sandboxer.lifecycle.ttl import expires_at_from, extend_expires_at, resolve_initial_ttl
from sandboxer.models import (
Consumer,
ExpireActionResult,
MeterRecord,
Reachability,
SandboxCreateRequest,
@@ -60,6 +67,18 @@ class SandboxManager:
return extension.config.get("provider", "saas")
return resolve_host(profile, override=host_override)
@staticmethod
def _assign_ttl(
status: SandboxStatus,
profile,
*,
request_ttl: str | None,
) -> None:
ttl_str = resolve_initial_ttl(profile, request_ttl)
anchor = status.ready_at or utcnow()
status.ttl = ttl_str
status.expires_at = expires_at_from(anchor, ttl_str)
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
profile = load_profile(request.profile)
extension = resolve_extension(profile, request.inputs, host_override=host)
@@ -119,6 +138,7 @@ class SandboxManager:
status.state = SandboxState.READY
status.ready_at = utcnow()
status.updated_at = status.ready_at
self._assign_ttl(status, profile, request_ttl=request.ttl)
if wants_telemetry and provision_before:
provision_after = collect_host_snapshot(resolved_host)
@@ -224,11 +244,98 @@ class SandboxManager:
profile=existing.profile_id,
inputs=dict(existing.inputs),
consumer=existing.consumer,
ttl=existing.ttl,
)
if existing.state != SandboxState.DESTROYED:
self.destroy(sandbox_id)
return self.create(request, host=existing.host)
def extend_ttl(self, sandbox_id: str, duration: str) -> SandboxStatus:
status = self.store.get(sandbox_id)
if not status:
raise KeyError(f"Sandbox not found: {sandbox_id}")
if status.state not in (SandboxState.READY, SandboxState.ACTIVE):
raise RuntimeError(
f"Cannot extend TTL for sandbox in state {status.state.value}"
)
if not status.expires_at or not status.ready_at:
raise RuntimeError("Sandbox has no expiry metadata")
profile = load_profile(status.profile_id)
new_expires, applied = extend_expires_at(
status.expires_at,
anchor=status.ready_at,
extension=duration,
max_duration=profile.ttl.max,
)
status.expires_at = new_expires
status.ttl = applied
status.updated_at = utcnow()
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"TTL extended by {applied} (expires {new_expires.isoformat()})",
event_type="note",
)
return status
def expire(
self,
*,
apply: bool = False,
now=None,
) -> list[ExpireActionResult]:
candidates = find_expire_candidates(self.store, now=now)
results: list[ExpireActionResult] = []
for candidate in candidates:
if not apply:
results.append(
ExpireActionResult(
sandbox_id=candidate.sandbox_id,
reason=candidate.reason,
action="dry-run",
)
)
continue
try:
status = self.store.get(candidate.sandbox_id)
if not status or status.state not in (
SandboxState.READY,
SandboxState.ACTIVE,
):
continue
status = apply_expired_state(status, now=now)
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Sandbox expired ({candidate.reason})",
event_type=event_type_for_state(status.state),
)
self.destroy(candidate.sandbox_id)
results.append(
ExpireActionResult(
sandbox_id=candidate.sandbox_id,
reason=candidate.reason,
action="destroyed",
)
)
except Exception as exc:
results.append(
ExpireActionResult(
sandbox_id=candidate.sandbox_id,
reason=candidate.reason,
action="failed",
error=str(exc),
)
)
return results
def list_expire_candidates(self, *, now=None) -> list[ExpireCandidate]:
return find_expire_candidates(self.store, now=now)
def snapshot(self, sandbox_id: str, *, name: str | None = None) -> SnapshotRecord:
status = self.store.get(sandbox_id)
if not status:
@@ -345,6 +452,7 @@ class SandboxManager:
status.state = SandboxState.READY
status.ready_at = utcnow()
status.updated_at = status.ready_at
self._assign_ttl(status, profile, request_ttl=None)
self.store.save(status)
emit_lifecycle_event(
status,