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.
- highflame_forge/__init__.py +140 -0
- highflame_forge/backends/__init__.py +18 -0
- highflame_forge/backends/base.py +444 -0
- highflame_forge/backends/gcp_backend.py +239 -0
- highflame_forge/backends/gvisor_backend.py +258 -0
- highflame_forge/backends/local_backend.py +587 -0
- highflame_forge/backends/modal_backend.py +1023 -0
- highflame_forge/backends/runpod_backend.py +468 -0
- highflame_forge/cedar_resolver.py +264 -0
- highflame_forge/cli/__init__.py +5 -0
- highflame_forge/cli/api.py +372 -0
- highflame_forge/cli/credentials.py +295 -0
- highflame_forge/cli/device.py +275 -0
- highflame_forge/cli/ghconnect.py +165 -0
- highflame_forge/cli/lab.py +409 -0
- highflame_forge/cli/main.py +1489 -0
- highflame_forge/cli/oauth.py +386 -0
- highflame_forge/cli/repoinfo.py +70 -0
- highflame_forge/cli/settings.py +118 -0
- highflame_forge/cli/sshconn.py +220 -0
- highflame_forge/config.py +452 -0
- highflame_forge/credentials.py +80 -0
- highflame_forge/enforcement/__init__.py +49 -0
- highflame_forge/enforcement/seccomp.py +368 -0
- highflame_forge/enforcement/tetragon.py +269 -0
- highflame_forge/forge.py +542 -0
- highflame_forge/git.py +351 -0
- highflame_forge/identity.py +359 -0
- highflame_forge/jobs/__init__.py +8 -0
- highflame_forge/jobs/training.py +877 -0
- highflame_forge/monitoring/__init__.py +9 -0
- highflame_forge/monitoring/events.py +164 -0
- highflame_forge/policies.py +76 -0
- highflame_forge/sandbox.py +592 -0
- highflame_forge/service/__init__.py +10 -0
- highflame_forge/service/app.py +296 -0
- highflame_forge/service/auth.py +159 -0
- highflame_forge/service/config.py +410 -0
- highflame_forge/service/github.py +340 -0
- highflame_forge/service/github_connect.py +160 -0
- highflame_forge/service/mcp_enum.py +73 -0
- highflame_forge/service/models.py +402 -0
- highflame_forge/service/policy_sync.py +160 -0
- highflame_forge/service/reaper.py +179 -0
- highflame_forge/service/router.py +1723 -0
- highflame_forge/service/volumes.py +119 -0
- highflame_forge/service/zeroid_mint.py +262 -0
- highflame_forge/sshkeys.py +44 -0
- highflame_forge/workspaces.py +494 -0
- highflame_forge-0.0.3.dist-info/METADATA +460 -0
- highflame_forge-0.0.3.dist-info/RECORD +54 -0
- highflame_forge-0.0.3.dist-info/WHEEL +4 -0
- highflame_forge-0.0.3.dist-info/entry_points.txt +2 -0
- 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})"
|