mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
feat(sandbox): expose services during creation (#3439)
* feat(sandbox): expose services during creation Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(providers): refresh Codex credentials in gateway Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(cli): normalize create-time service URLs Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(server): roll back failed service exposure Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(example): simplify Codex provider setup Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(example): separate provider setup commands Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(sandbox): harden create-time service exposure Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(example): bundle Codex provider profile Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(example): allow npm-installed Codex binary Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
@@ -21,6 +21,7 @@ from .sandbox import (
|
||||
SandboxStatusRef,
|
||||
SandboxTemplateClient,
|
||||
SandboxWorkloadTemplateProvenanceRef,
|
||||
ServiceExposure,
|
||||
TlsConfig,
|
||||
WorkspaceClient,
|
||||
WorkspaceRef,
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"SandboxStatusRef",
|
||||
"SandboxTemplateClient",
|
||||
"SandboxWorkloadTemplateProvenanceRef",
|
||||
"ServiceExposure",
|
||||
"TlsConfig",
|
||||
"WorkspaceClient",
|
||||
"WorkspaceRef",
|
||||
|
||||
@@ -80,6 +80,18 @@ def _all_workspaces_scope() -> datamodel_pb2.WorkspaceSelector:
|
||||
return datamodel_pb2.WorkspaceSelector(all_workspaces=datamodel_pb2.AllWorkspaces())
|
||||
|
||||
|
||||
def _service_exposure_messages(
|
||||
exposures: Sequence[ServiceExposure] | None,
|
||||
) -> list[openshell_pb2.SandboxServiceExposure]:
|
||||
return [
|
||||
openshell_pb2.SandboxServiceExposure(
|
||||
service=exposure.service,
|
||||
target_port=exposure.target_port,
|
||||
)
|
||||
for exposure in exposures or ()
|
||||
]
|
||||
|
||||
|
||||
class _ClientCallDetails(_ClientCallDetailsBase, grpc.ClientCallDetails):
|
||||
pass
|
||||
|
||||
@@ -410,6 +422,14 @@ class SandboxStatusRef:
|
||||
exit_code: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceExposure:
|
||||
"""A loopback HTTP service to expose during sandbox creation."""
|
||||
|
||||
target_port: int
|
||||
service: str = ""
|
||||
|
||||
|
||||
class _ImmutableLabels(dict[str, str]):
|
||||
"""A read-only, copy- and pickle-safe label mapping."""
|
||||
|
||||
@@ -451,9 +471,14 @@ class SandboxRef:
|
||||
# immutable mapping remains safe for deepcopy, pickle, and asdict.
|
||||
labels: Mapping[str, str] = field(default_factory=_ImmutableLabels, compare=False)
|
||||
created_from_workload_template: SandboxWorkloadTemplateProvenanceRef | None = None
|
||||
# Populated by create operations. The empty key identifies the unnamed service.
|
||||
service_urls: Mapping[str, str] = field(
|
||||
default_factory=_ImmutableLabels, compare=False
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "labels", _ImmutableLabels(self.labels))
|
||||
object.__setattr__(self, "service_urls", _ImmutableLabels(self.service_urls))
|
||||
|
||||
@property
|
||||
def phase(self) -> int:
|
||||
@@ -764,6 +789,7 @@ class SandboxClient:
|
||||
spec: openshell_pb2.SandboxSpec | None = None,
|
||||
name: str | None = None,
|
||||
labels: Mapping[str, str] | None = None,
|
||||
service_exposures: Sequence[ServiceExposure] | None = None,
|
||||
) -> SandboxRef:
|
||||
request_spec = spec if spec is not None else _default_spec()
|
||||
response = self._stub.CreateSandbox(
|
||||
@@ -772,10 +798,11 @@ class SandboxClient:
|
||||
spec=request_spec,
|
||||
name=name or "",
|
||||
labels=dict(labels) if labels else {},
|
||||
service_exposures=_service_exposure_messages(service_exposures),
|
||||
),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
sandbox_ref = _sandbox_ref(response.sandbox)
|
||||
sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls)
|
||||
if sandbox_ref.id == "":
|
||||
raise SandboxError("CreateSandbox returned empty sandbox id")
|
||||
return sandbox_ref
|
||||
@@ -788,6 +815,7 @@ class SandboxClient:
|
||||
spec: openshell_pb2.SandboxSpec | None = None,
|
||||
name: str | None = None,
|
||||
labels: Mapping[str, str] | None = None,
|
||||
service_exposures: Sequence[ServiceExposure] | None = None,
|
||||
) -> SandboxRef:
|
||||
if not workload_template.strip():
|
||||
raise SandboxError("workload_template is required")
|
||||
@@ -799,10 +827,11 @@ class SandboxClient:
|
||||
name=name or "",
|
||||
labels=dict(labels) if labels else {},
|
||||
workload_template=workload_template,
|
||||
service_exposures=_service_exposure_messages(service_exposures),
|
||||
),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
sandbox_ref = _sandbox_ref(response.sandbox)
|
||||
sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls)
|
||||
if sandbox_ref.id == "":
|
||||
raise SandboxError("CreateSandbox returned empty sandbox id")
|
||||
return sandbox_ref
|
||||
@@ -814,9 +843,17 @@ class SandboxClient:
|
||||
spec: openshell_pb2.SandboxSpec | None = None,
|
||||
name: str | None = None,
|
||||
labels: Mapping[str, str] | None = None,
|
||||
service_exposures: Sequence[ServiceExposure] | None = None,
|
||||
) -> SandboxSession:
|
||||
return SandboxSession(
|
||||
self, self.create(workspace=workspace, spec=spec, name=name, labels=labels)
|
||||
self,
|
||||
self.create(
|
||||
workspace=workspace,
|
||||
spec=spec,
|
||||
name=name,
|
||||
labels=labels,
|
||||
service_exposures=service_exposures,
|
||||
),
|
||||
)
|
||||
|
||||
def create_session_from_template(
|
||||
@@ -827,6 +864,7 @@ class SandboxClient:
|
||||
spec: openshell_pb2.SandboxSpec | None = None,
|
||||
name: str | None = None,
|
||||
labels: Mapping[str, str] | None = None,
|
||||
service_exposures: Sequence[ServiceExposure] | None = None,
|
||||
) -> SandboxSession:
|
||||
return SandboxSession(
|
||||
self,
|
||||
@@ -836,6 +874,7 @@ class SandboxClient:
|
||||
spec=spec,
|
||||
name=name,
|
||||
labels=labels,
|
||||
service_exposures=service_exposures,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1677,7 +1716,10 @@ def _serialize_python_callable(
|
||||
return base64.b64encode(payload).decode("ascii")
|
||||
|
||||
|
||||
def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef:
|
||||
def _sandbox_ref(
|
||||
sandbox: openshell_pb2.Sandbox,
|
||||
service_urls: Mapping[str, str] | None = None,
|
||||
) -> SandboxRef:
|
||||
status = sandbox.status if sandbox.HasField("status") else None
|
||||
provenance = (
|
||||
SandboxWorkloadTemplateProvenanceRef(
|
||||
@@ -1700,6 +1742,7 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef:
|
||||
),
|
||||
labels=sandbox.metadata.labels if sandbox.metadata else {},
|
||||
created_from_workload_template=provenance,
|
||||
service_urls=service_urls or {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from openshell.sandbox import (
|
||||
SandboxRef,
|
||||
SandboxStatusRef,
|
||||
SandboxTemplateClient,
|
||||
ServiceExposure,
|
||||
TlsConfig,
|
||||
_atomic_replace,
|
||||
_BearerAuthInterceptor,
|
||||
@@ -2073,7 +2074,11 @@ class _FakeSandboxStub:
|
||||
request.name or "generated",
|
||||
dict(request.labels),
|
||||
workspace=_request_workspace(request) or "default",
|
||||
)
|
||||
),
|
||||
service_urls={
|
||||
exposure.service: f"https://{exposure.service}.example.test/"
|
||||
for exposure in request.service_exposures
|
||||
},
|
||||
)
|
||||
|
||||
def ListSandboxes(
|
||||
@@ -2205,6 +2210,30 @@ def test_create_forwards_name_and_labels() -> None:
|
||||
assert dict(ref.labels) == {"aiq": "deep-research"}
|
||||
|
||||
|
||||
def test_create_forwards_service_exposures() -> None:
|
||||
stub = _FakeSandboxStub()
|
||||
client = _client_with_fake_stub(stub)
|
||||
|
||||
ref = client.create(
|
||||
workspace="default",
|
||||
name="app-server",
|
||||
service_exposures=[
|
||||
ServiceExposure(target_port=4500),
|
||||
ServiceExposure(service="metrics", target_port=9090),
|
||||
],
|
||||
)
|
||||
|
||||
assert stub.create_request is not None
|
||||
assert [
|
||||
(exposure.service, exposure.target_port)
|
||||
for exposure in stub.create_request.service_exposures
|
||||
] == [("", 4500), ("metrics", 9090)]
|
||||
assert dict(ref.service_urls) == {
|
||||
"": "https://.example.test/",
|
||||
"metrics": "https://metrics.example.test/",
|
||||
}
|
||||
|
||||
|
||||
def test_create_from_template_forwards_workload_template() -> None:
|
||||
stub = _FakeSandboxStub()
|
||||
client = _client_with_fake_stub(stub)
|
||||
|
||||
Reference in New Issue
Block a user