highflame-forge 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. highflame_forge/__init__.py +140 -0
  2. highflame_forge/backends/__init__.py +18 -0
  3. highflame_forge/backends/base.py +444 -0
  4. highflame_forge/backends/gcp_backend.py +239 -0
  5. highflame_forge/backends/gvisor_backend.py +258 -0
  6. highflame_forge/backends/local_backend.py +587 -0
  7. highflame_forge/backends/modal_backend.py +1023 -0
  8. highflame_forge/backends/runpod_backend.py +468 -0
  9. highflame_forge/cedar_resolver.py +264 -0
  10. highflame_forge/cli/__init__.py +5 -0
  11. highflame_forge/cli/api.py +372 -0
  12. highflame_forge/cli/credentials.py +295 -0
  13. highflame_forge/cli/device.py +275 -0
  14. highflame_forge/cli/ghconnect.py +165 -0
  15. highflame_forge/cli/lab.py +409 -0
  16. highflame_forge/cli/main.py +1489 -0
  17. highflame_forge/cli/oauth.py +386 -0
  18. highflame_forge/cli/repoinfo.py +70 -0
  19. highflame_forge/cli/settings.py +118 -0
  20. highflame_forge/cli/sshconn.py +220 -0
  21. highflame_forge/config.py +452 -0
  22. highflame_forge/credentials.py +80 -0
  23. highflame_forge/enforcement/__init__.py +49 -0
  24. highflame_forge/enforcement/seccomp.py +368 -0
  25. highflame_forge/enforcement/tetragon.py +269 -0
  26. highflame_forge/forge.py +542 -0
  27. highflame_forge/git.py +351 -0
  28. highflame_forge/identity.py +359 -0
  29. highflame_forge/jobs/__init__.py +8 -0
  30. highflame_forge/jobs/training.py +877 -0
  31. highflame_forge/monitoring/__init__.py +9 -0
  32. highflame_forge/monitoring/events.py +164 -0
  33. highflame_forge/policies.py +76 -0
  34. highflame_forge/sandbox.py +592 -0
  35. highflame_forge/service/__init__.py +10 -0
  36. highflame_forge/service/app.py +296 -0
  37. highflame_forge/service/auth.py +159 -0
  38. highflame_forge/service/config.py +410 -0
  39. highflame_forge/service/github.py +340 -0
  40. highflame_forge/service/github_connect.py +160 -0
  41. highflame_forge/service/mcp_enum.py +73 -0
  42. highflame_forge/service/models.py +402 -0
  43. highflame_forge/service/policy_sync.py +160 -0
  44. highflame_forge/service/reaper.py +179 -0
  45. highflame_forge/service/router.py +1723 -0
  46. highflame_forge/service/volumes.py +119 -0
  47. highflame_forge/service/zeroid_mint.py +262 -0
  48. highflame_forge/sshkeys.py +44 -0
  49. highflame_forge/workspaces.py +494 -0
  50. highflame_forge-0.0.3.dist-info/METADATA +460 -0
  51. highflame_forge-0.0.3.dist-info/RECORD +54 -0
  52. highflame_forge-0.0.3.dist-info/WHEEL +4 -0
  53. highflame_forge-0.0.3.dist-info/entry_points.txt +2 -0
  54. highflame_forge-0.0.3.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,140 @@
1
+ """Highflame Forge - Unified sandbox platform for ML training and secure execution.
2
+
3
+ Example:
4
+ ```python
5
+ from highflame_forge import Forge
6
+
7
+ forge = Forge()
8
+
9
+ # Simple sandbox usage
10
+ with forge.sandbox(gpu="A40", memory_gb=32) as sb:
11
+ sb.run_sync("python train.py --epochs 3")
12
+
13
+ # Training job with automatic tracking
14
+ from highflame_forge import TrainingJob
15
+
16
+ job = TrainingJob(
17
+ forge=forge,
18
+ script="train.py",
19
+ args={"epochs": 3, "lr": 2e-5},
20
+ gpu="A40",
21
+ )
22
+ result = await job.run()
23
+ print(f"Final loss: {result.final_loss}")
24
+ ```
25
+ """
26
+
27
+ from highflame_forge.backends.base import (
28
+ Backend,
29
+ CredentialPolicyError,
30
+ IsolationUnavailableError,
31
+ create_backend,
32
+ get_backend_class,
33
+ register_backend,
34
+ registered_backends,
35
+ )
36
+ from highflame_forge.backends.gvisor_backend import GVisorBackend, GVisorSandbox
37
+ from highflame_forge.backends.local_backend import LocalBackend, LocalSandbox
38
+ from highflame_forge.config import (
39
+ BackendType,
40
+ GPUType,
41
+ IsolationTier,
42
+ NetworkConfig,
43
+ Presets,
44
+ SandboxConfig,
45
+ SandboxStatus,
46
+ )
47
+ from highflame_forge.credentials import CredentialFinding, find_long_lived_credentials
48
+ from highflame_forge.enforcement import (
49
+ EnforcementSpec,
50
+ build_tracing_policy,
51
+ derive_enforcement,
52
+ )
53
+ from highflame_forge.forge import BackendSelector, Forge
54
+ from highflame_forge.git import GitAuthMethod, GitRepo
55
+ from highflame_forge.identity import (
56
+ MECHANISM_CAPABILITIES,
57
+ SCOPE_DEV_GPU,
58
+ SCOPE_FS_READ,
59
+ SCOPE_FS_WRITE,
60
+ SCOPE_NET_EGRESS,
61
+ SCOPE_SYS_BPF,
62
+ SCOPE_SYS_PTRACE,
63
+ SCOPE_SYS_RAW_SOCKET,
64
+ SCOPE_TIER_HARDWARE,
65
+ SCOPE_TIER_KERNEL,
66
+ SCOPE_TIER_OS,
67
+ SCOPE_TOOLS_EXECUTE,
68
+ SCOPE_VIRT_NESTED,
69
+ Capabilities,
70
+ CapabilityResolver,
71
+ SandboxIdentity,
72
+ ScopeCapabilityResolver,
73
+ )
74
+ from highflame_forge.jobs.training import (
75
+ HyperparameterSweep,
76
+ SweepResult,
77
+ TrainingEvent,
78
+ TrainingEventType,
79
+ TrainingJob,
80
+ TrainingResult,
81
+ )
82
+ from highflame_forge.sandbox import CommandResult, Sandbox, SandboxMetrics
83
+
84
+ __version__ = "0.1.0"
85
+
86
+ __all__ = [
87
+ "MECHANISM_CAPABILITIES",
88
+ "SCOPE_DEV_GPU",
89
+ "SCOPE_FS_READ",
90
+ "SCOPE_FS_WRITE",
91
+ "SCOPE_NET_EGRESS",
92
+ "SCOPE_SYS_BPF",
93
+ "SCOPE_SYS_PTRACE",
94
+ "SCOPE_SYS_RAW_SOCKET",
95
+ "SCOPE_TIER_HARDWARE",
96
+ "SCOPE_TIER_KERNEL",
97
+ "SCOPE_TIER_OS",
98
+ "SCOPE_TOOLS_EXECUTE",
99
+ "SCOPE_VIRT_NESTED",
100
+ "Backend",
101
+ "BackendSelector",
102
+ "BackendType",
103
+ "Capabilities",
104
+ "CapabilityResolver",
105
+ "CommandResult",
106
+ "CredentialFinding",
107
+ "CredentialPolicyError",
108
+ "EnforcementSpec",
109
+ "Forge",
110
+ "GPUType",
111
+ "GVisorBackend",
112
+ "GVisorSandbox",
113
+ "GitAuthMethod",
114
+ "GitRepo",
115
+ "HyperparameterSweep",
116
+ "IsolationTier",
117
+ "IsolationUnavailableError",
118
+ "LocalBackend",
119
+ "LocalSandbox",
120
+ "NetworkConfig",
121
+ "Presets",
122
+ "Sandbox",
123
+ "SandboxConfig",
124
+ "SandboxIdentity",
125
+ "SandboxMetrics",
126
+ "SandboxStatus",
127
+ "ScopeCapabilityResolver",
128
+ "SweepResult",
129
+ "TrainingEvent",
130
+ "TrainingEventType",
131
+ "TrainingJob",
132
+ "TrainingResult",
133
+ "build_tracing_policy",
134
+ "create_backend",
135
+ "derive_enforcement",
136
+ "find_long_lived_credentials",
137
+ "get_backend_class",
138
+ "register_backend",
139
+ "registered_backends",
140
+ ]
@@ -0,0 +1,18 @@
1
+ """Backend implementations for Highflame Forge."""
2
+
3
+ from highflame_forge.backends.base import Backend, CostEstimate, GPUAvailability
4
+ from highflame_forge.backends.gcp_backend import GCPBackend
5
+ from highflame_forge.backends.local_backend import LocalBackend, LocalSandbox
6
+ from highflame_forge.backends.modal_backend import ModalBackend
7
+ from highflame_forge.backends.runpod_backend import RunPodBackend
8
+
9
+ __all__ = [
10
+ "Backend",
11
+ "CostEstimate",
12
+ "GCPBackend",
13
+ "GPUAvailability",
14
+ "LocalBackend",
15
+ "LocalSandbox",
16
+ "ModalBackend",
17
+ "RunPodBackend",
18
+ ]
@@ -0,0 +1,444 @@
1
+ """Abstract base class for compute backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from highflame_forge.config import BackendType, GPUType, IsolationTier, SandboxConfig
11
+
12
+ if TYPE_CHECKING:
13
+ from datetime import datetime
14
+
15
+ from highflame_forge.identity import CapabilityResolver
16
+ from highflame_forge.sandbox import Sandbox
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class IsolationUnavailableError(RuntimeError):
22
+ """The isolation tier an execution requires is not available on this
23
+ backend. Raised at provision time — the boundary fails closed rather than
24
+ silently downgrading to a weaker tier (ADR 0007 D5 / ADR 0016 D1)."""
25
+
26
+
27
+ class CredentialPolicyError(ValueError):
28
+ """A strongly-isolated (Tier 2+) sandbox was asked to carry a long-lived
29
+ credential in its environment, violating INV-ENF-004 (ADR 0016). Raised at
30
+ provision time — fail closed. The message names the offending variables and
31
+ the credential kind, never the secret value."""
32
+
33
+
34
+ @dataclass
35
+ class GPUAvailability:
36
+ """GPU availability information from a backend."""
37
+
38
+ gpu_type: GPUType
39
+ available: int
40
+ total: int
41
+ price_per_hour: float
42
+ regions: list[str]
43
+
44
+
45
+ @dataclass
46
+ class CostEstimate:
47
+ """Cost estimate for running a sandbox."""
48
+
49
+ backend: BackendType
50
+ hourly_cost: float
51
+ estimated_total: float
52
+ duration_hours: float
53
+ breakdown: dict[str, float] # e.g., {"gpu": 2.0, "cpu": 0.5, "storage": 0.1}
54
+
55
+
56
+ # ── Backend registry (open-closed: add a provider without editing core) ───────
57
+ # Keyed by string name so a new adapter self-registers — no BackendType enum
58
+ # edit, no Forge if/elif branch. An entry is either a Backend subclass or a lazy
59
+ # "module:ClassName" string (so a built-in like modal doesn't import its SDK
60
+ # until actually used). A third-party/plugin backend calls
61
+ # register_backend("daytona", DaytonaBackend) and becomes usable via
62
+ # Forge(backend="daytona").
63
+ _BACKEND_REGISTRY: dict[str, type[Backend] | str] = {}
64
+
65
+
66
+ def register_backend(name, cls=None): # type: ignore[no-untyped-def]
67
+ """Register a Backend under ``name`` — a class or a lazy ``"module:Class"``
68
+ string. Usable as a decorator (``@register_backend("daytona")``) or a call
69
+ (``register_backend("daytona", DaytonaBackend)``)."""
70
+
71
+ def _register(target): # type: ignore[no-untyped-def]
72
+ _BACKEND_REGISTRY[name] = target
73
+ return target
74
+
75
+ return _register if cls is None else _register(cls)
76
+
77
+
78
+ def get_backend_class(name: str) -> type[Backend]:
79
+ """Resolve a registered Backend class by name (lazily importing a
80
+ ``"module:Class"`` entry). Raises ValueError if unknown."""
81
+ try:
82
+ entry = _BACKEND_REGISTRY[name]
83
+ except KeyError:
84
+ known = ", ".join(sorted(_BACKEND_REGISTRY)) or "(none registered)"
85
+ raise ValueError(f"unknown backend {name!r}; registered: {known}") from None
86
+ if isinstance(entry, str):
87
+ import importlib
88
+
89
+ module_name, _, class_name = entry.partition(":")
90
+ entry = getattr(importlib.import_module(module_name), class_name)
91
+ _BACKEND_REGISTRY[name] = entry # cache the resolved class — import once
92
+ return entry
93
+
94
+
95
+ def create_backend(name: str, **kwargs: object) -> Backend:
96
+ """Instantiate a registered backend by name."""
97
+ return get_backend_class(name)(**kwargs)
98
+
99
+
100
+ def registered_backends() -> list[str]:
101
+ """Names of all registered backends."""
102
+ return sorted(_BACKEND_REGISTRY)
103
+
104
+
105
+ # Built-in backends, registered lazily (no SDK import until used).
106
+ for _name, _path in {
107
+ "local": "highflame_forge.backends.local_backend:LocalBackend",
108
+ "gvisor": "highflame_forge.backends.gvisor_backend:GVisorBackend",
109
+ "modal": "highflame_forge.backends.modal_backend:ModalBackend",
110
+ "runpod": "highflame_forge.backends.runpod_backend:RunPodBackend",
111
+ "gcp": "highflame_forge.backends.gcp_backend:GCPBackend",
112
+ }.items():
113
+ register_backend(_name, _path)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class VolumeRecord:
118
+ """A durable workspace volume, as seen from the control plane."""
119
+
120
+ #: Backend-scoped volume name. For Forge-created volumes this is the hash
121
+ #: of the tenancy tuple (see ``service.volumes.volume_name_for``).
122
+ name: str
123
+ #: When this workspace was last launched, read from the ``.forge/last_used``
124
+ #: marker inside it. ``None`` when the marker is absent or unparseable —
125
+ #: which ALSO describes a volume Forge did not create, so callers must treat
126
+ #: None as "not ours / unknown", never as "infinitely old".
127
+ last_used: datetime | None = None
128
+ #: The repo this workspace holds, from ``.forge/repo``. Display only —
129
+ #: ownership is proved by recomputing the name from it, never by trusting
130
+ #: it. ``None`` when absent.
131
+ repo: str | None = None
132
+
133
+
134
+ class Backend(ABC):
135
+ """Abstract interface for compute backends.
136
+
137
+ Each backend (Modal, RunPod, GCP, Vertex AI) implements this interface
138
+ to provide a unified API for creating and managing sandboxes.
139
+ """
140
+
141
+ #: Absolute in-sandbox path that is writable, is the agent's working area,
142
+ #: and is where a persistent volume gets mounted. Declared by the BACKEND
143
+ #: because only it knows: Modal makes /workspace its scratch and mounts
144
+ #: volumes there, but another substrate may confine writes elsewhere
145
+ #: entirely — the local bwrap backend mounts /workspace READ-ONLY, so a
146
+ #: clone into it fails.
147
+ #:
148
+ #: Callers must read this rather than assuming "/workspace". The checkout
149
+ #: path derives from it, and that path has to stay stable across launches
150
+ #: of the same repo on the same backend, because harnesses key their
151
+ #: session state by cwd.
152
+ workspace_root: str = "/workspace"
153
+
154
+ async def list_workspace_volumes(self, prefix: str) -> list[VolumeRecord]:
155
+ """Durable workspace volumes whose name starts with ``prefix``.
156
+
157
+ The prefix is supplied by the caller rather than known here: naming is
158
+ a service-layer concern (``service.volumes``), and a backend that
159
+ invented its own would put the two out of step.
160
+
161
+ Must be a CONTROL-PLANE listing: the reaper runs on a schedule over
162
+ every volume in the deployment, so needing a sandbox to inspect one
163
+ would make garbage collection cost more than the garbage.
164
+
165
+ Only called on a backend declaring ``supports_volumes``.
166
+ """
167
+ raise NotImplementedError(
168
+ f"{type(self).__name__} declares supports_volumes but cannot list them"
169
+ )
170
+
171
+ async def reset_volume(self, name: str) -> None:
172
+ """Destroy the durable volume ``name`` so the next launch starts empty.
173
+
174
+ This is what ``--fresh`` means. Only ever called on a backend that
175
+ declares ``supports_volumes``, and only once the caller has established
176
+ that nothing is currently attached to it.
177
+
178
+ A CONTROL-PLANE delete, deliberately, rather than deleting files from
179
+ inside a sandbox. Doing it in-band needs compute just to erase, runs
180
+ shell commands over a mount whose semantics the backend controls (Modal
181
+ mounts a volume as a SYMLINK, which silently defeats `find`), and can
182
+ only run AFTER the sandbox has booted — leaving a window in which the
183
+ agent is already looking at the tree the user asked to destroy.
184
+ Deleting the volume before provisioning has none of those problems.
185
+
186
+ Must be idempotent: ``--fresh`` on a repo that has never been launched
187
+ is a normal request, not an error.
188
+ """
189
+ raise NotImplementedError(
190
+ f"{type(self).__name__} declares supports_volumes but cannot reset one"
191
+ )
192
+
193
+ #: Whether this backend can mount a durable volume for
194
+ #: ``SandboxConfig.volume_name`` (forge#56 slice 3). Declared rather than
195
+ #: inferred so callers can tell "no persistence here" from "persistence
196
+ #: requested and silently dropped" — the latter would let `--fresh` report
197
+ #: success while wiping nothing, and let a resume quietly start blank.
198
+ supports_volumes: bool = False
199
+
200
+ def __init__(self, resolver: CapabilityResolver | None = None, **kwargs: object):
201
+ """Initialize the backend.
202
+
203
+ Args:
204
+ resolver: Capability resolver to derive per-sandbox capabilities from
205
+ the acting identity. Injected by the service layer with the
206
+ Cedar-backed resolver in production (ADR 0019). Library/CLI callers
207
+ (and tests) leave it ``None`` and get the process-wide scope
208
+ resolver (``DEFAULT_RESOLVER``).
209
+ **kwargs: Backend-specific configuration.
210
+ """
211
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
212
+ self._initialized = False
213
+ #: May be None here; backends that derive capabilities fall back to
214
+ #: ``DEFAULT_RESOLVER`` at use so no import-time global is captured.
215
+ self._resolver: CapabilityResolver | None = resolver
216
+
217
+ @property
218
+ @abstractmethod
219
+ def backend_type(self) -> BackendType:
220
+ """Return the backend type identifier."""
221
+ pass
222
+
223
+ @property
224
+ @abstractmethod
225
+ def is_available(self) -> bool:
226
+ """Check if the backend is available and properly configured."""
227
+ pass
228
+
229
+ @property
230
+ def max_isolation(self) -> IsolationTier:
231
+ """The strongest isolation tier this backend can currently provide.
232
+
233
+ Cloud container backends (Modal/RunPod/GCP) are OS-tier equivalent per
234
+ ADR 0016 — override when a backend provides a kernel or hardware
235
+ boundary (gVisor → KERNEL, Firecracker/TEE → HARDWARE), or, like the
236
+ local backend, when the achievable tier depends on the host.
237
+ """
238
+ return IsolationTier.OS
239
+
240
+ def check_isolation_floor(self, required: IsolationTier) -> None:
241
+ """Refuse to provision below the required isolation floor (fail closed).
242
+
243
+ Called by every backend at sandbox-creation time. Never downgrade:
244
+ an unavailable tier is a refusal, not a warning (ADR 0007 D5).
245
+
246
+ Raises:
247
+ IsolationUnavailableError: If this backend cannot meet the floor.
248
+ """
249
+ if required > self.max_isolation:
250
+ raise IsolationUnavailableError(
251
+ f"execution requires isolation tier {required.name} but backend "
252
+ f"{self.backend_type.value!r} provides at most "
253
+ f"{self.max_isolation.name} — refusing to run (fail closed; "
254
+ "use a backend that meets the floor)"
255
+ )
256
+
257
+ def check_credential_policy(self, config: SandboxConfig, tier: IsolationTier) -> None:
258
+ """Enforce INV-ENF-004: a Tier 2+ sandbox MUST NOT carry a long-lived
259
+ credential in its declared env/secrets. Called by every backend at
260
+ creation time, after the isolation floor is resolved.
261
+
262
+ Below KERNEL this is a no-op — trusted-local / OS-tier execution on the
263
+ operator's own machine may legitimately pass a key through. At KERNEL+
264
+ (untrusted / multi-tenant) a match fails closed: the credential belongs
265
+ on an SVID-authenticated channel (ADR 0016 D3), not in the environment.
266
+
267
+ Raises:
268
+ CredentialPolicyError: If a long-lived credential shape is found.
269
+ """
270
+ if tier < IsolationTier.KERNEL:
271
+ return
272
+ from highflame_forge.credentials import find_long_lived_credentials
273
+
274
+ findings = find_long_lived_credentials({**config.env, **config.secrets})
275
+ if findings:
276
+ offenders = ", ".join(str(f) for f in findings)
277
+ # INV-ENF-004 — keep the invariant id in code, NOT in the API message.
278
+ raise CredentialPolicyError(
279
+ f"tier {tier.name} sandbox may not carry long-lived credentials "
280
+ f"in its environment: {offenders}. Deliver these over an "
281
+ "SVID-authenticated channel, or scope them to a short-lived "
282
+ "(<=15m) token."
283
+ )
284
+
285
+ @abstractmethod
286
+ async def initialize(self) -> None:
287
+ """Initialize the backend (authenticate, validate config, etc.)
288
+
289
+ This is called lazily on first use.
290
+
291
+ Raises:
292
+ RuntimeError: If initialization fails
293
+ """
294
+ pass
295
+
296
+ def _effective_floor(self, config: SandboxConfig) -> IsolationTier:
297
+ """The minimum isolation tier this execution requires. Delegates to the
298
+ SINGLE shared floor computation so this pre-provision guard and the
299
+ capability resolver can never disagree (they'd otherwise be two copies of
300
+ the same logic — a latent way for the guard to under-check)."""
301
+ from highflame_forge.identity import resolve_isolation_floor
302
+
303
+ return resolve_isolation_floor(config, config.identity)
304
+
305
+ async def create_sandbox(self, config: SandboxConfig) -> Sandbox:
306
+ """Create a new sandbox — TEMPLATE METHOD (do not override).
307
+
308
+ Runs the fail-closed security guards that apply to EVERY backend, then
309
+ delegates provisioning to :meth:`_provision`. This makes the guards
310
+ unskippable by construction: a new adapter implements ``_provision`` and
311
+ inherits the isolation-floor + credential-policy enforcement — it cannot
312
+ forget to call them (ADR 0016 D1 / INV-ENF-004 / ADR 0017).
313
+
314
+ Raises:
315
+ IsolationUnavailableError: backend can't meet the required tier.
316
+ CredentialPolicyError: long-lived credential in a Tier 2+ env.
317
+ """
318
+ floor = self._effective_floor(config)
319
+ self.check_isolation_floor(floor)
320
+ self.check_credential_policy(config, floor)
321
+ return await self._provision(config)
322
+
323
+ @abstractmethod
324
+ async def _provision(self, config: SandboxConfig) -> Sandbox:
325
+ """Provision the sandbox on this backend's substrate. Called by
326
+ :meth:`create_sandbox` AFTER the universal guards have passed — so an
327
+ implementation only does provisioning, never security enforcement.
328
+
329
+ Args:
330
+ config: Sandbox configuration (guards already applied)
331
+
332
+ Returns:
333
+ Sandbox handle for the created instance
334
+ """
335
+ ...
336
+
337
+ @abstractmethod
338
+ async def get_sandbox(self, sandbox_id: str) -> Sandbox | None:
339
+ """Get an existing sandbox by ID.
340
+
341
+ Args:
342
+ sandbox_id: Unique sandbox identifier
343
+
344
+ Returns:
345
+ Sandbox handle if found, None otherwise
346
+ """
347
+ pass
348
+
349
+ @abstractmethod
350
+ async def list_sandboxes(self) -> list[Sandbox]:
351
+ """List all active sandboxes for this backend.
352
+
353
+ Returns:
354
+ List of active sandbox handles
355
+ """
356
+ pass
357
+
358
+ @abstractmethod
359
+ async def terminate_sandbox(self, sandbox_id: str) -> bool:
360
+ """Terminate a sandbox by ID.
361
+
362
+ Args:
363
+ sandbox_id: Unique sandbox identifier
364
+
365
+ Returns:
366
+ True if terminated, False if not found
367
+ """
368
+ pass
369
+
370
+ @abstractmethod
371
+ def get_available_gpus(self) -> list[GPUAvailability]:
372
+ """Get list of available GPU types and their availability.
373
+
374
+ Returns:
375
+ List of GPU availability information
376
+ """
377
+ pass
378
+
379
+ @abstractmethod
380
+ def estimate_cost(
381
+ self,
382
+ config: SandboxConfig,
383
+ duration_hours: float,
384
+ ) -> CostEstimate:
385
+ """Estimate cost for running a sandbox.
386
+
387
+ Args:
388
+ config: Sandbox configuration
389
+ duration_hours: Expected duration in hours
390
+
391
+ Returns:
392
+ Cost estimate with breakdown
393
+ """
394
+ pass
395
+
396
+ @abstractmethod
397
+ def map_gpu_type(self, gpu: GPUType | str) -> str:
398
+ """Map generic GPU type to backend-specific identifier.
399
+
400
+ Args:
401
+ gpu: Generic GPU type
402
+
403
+ Returns:
404
+ Backend-specific GPU identifier
405
+ """
406
+ pass
407
+
408
+ async def ensure_initialized(self) -> None:
409
+ """Ensure backend is initialized, initializing if needed."""
410
+ if not self._initialized:
411
+ await self.initialize()
412
+ self._initialized = True
413
+
414
+ def validate_config(self, config: SandboxConfig) -> list[str]:
415
+ """Validate configuration for this backend.
416
+
417
+ Args:
418
+ config: Sandbox configuration to validate
419
+
420
+ Returns:
421
+ List of validation error messages (empty if valid)
422
+ """
423
+ errors = []
424
+
425
+ # Check GPU availability
426
+ if config.gpu and config.gpu != GPUType.NONE:
427
+ available_gpus = {g.gpu_type for g in self.get_available_gpus()}
428
+ gpu_type = config.gpu if isinstance(config.gpu, GPUType) else GPUType(config.gpu)
429
+ if gpu_type not in available_gpus:
430
+ errors.append(f"GPU type {config.gpu} not available on {self.backend_type.value}")
431
+
432
+ # Check memory limits (backend-specific, override in subclass)
433
+ if config.memory_gb > 1024:
434
+ errors.append(f"Memory {config.memory_gb}GB exceeds maximum")
435
+
436
+ # Check timeout
437
+ if config.timeout_seconds > 86400: # 24 hours
438
+ errors.append("Timeout exceeds maximum of 24 hours")
439
+
440
+ return errors
441
+
442
+ def __repr__(self) -> str:
443
+ """String representation."""
444
+ return f"{self.__class__.__name__}(type={self.backend_type.value})"