generated from coulomb/repo-seed
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user