From b26062e45306561e5581ea5234a62e900da9b540 Mon Sep 17 00:00:00 2001 From: Gordon Sim Date: Fri, 17 Jul 2026 16:04:40 +0100 Subject: [PATCH] feat(kubernetes): warm pool support Signed-off-by: Gordon Sim --- .agents/skills/helm-dev-environment/SKILL.md | 8 +- .github/workflows/branch-e2e.yml | 4 + Cargo.lock | 2 + architecture/compute-runtimes.md | 175 +- architecture/gateway.md | 57 +- .../tests/ensure_providers_integration.rs | 11 + .../openshell-cli/tests/mtls_integration.rs | 11 + .../tests/provider_commands_integration.rs | 11 + .../sandbox_create_lifecycle_integration.rs | 11 + .../sandbox_name_fallback_integration.rs | 11 + crates/openshell-core/src/driver_utils.rs | 42 +- .../src/dynamic_string_allowlist.rs | 73 +- crates/openshell-core/src/error.rs | 5 + crates/openshell-core/src/grpc_client.rs | 103 +- crates/openshell-core/src/lib.rs | 1 + crates/openshell-core/src/sandbox_env.rs | 55 +- .../src/supervisor_bootstrap.rs | 88 + crates/openshell-driver-docker/src/lib.rs | 2 + crates/openshell-driver-docker/src/tests.rs | 5 +- crates/openshell-driver-kubernetes/Cargo.toml | 2 + crates/openshell-driver-kubernetes/README.md | 75 +- .../src/bootstrap.rs | 974 +++++ .../openshell-driver-kubernetes/src/config.rs | 170 +- .../openshell-driver-kubernetes/src/driver.rs | 3308 ++++++++++++++--- .../src/extension_api.rs | 305 ++ .../openshell-driver-kubernetes/src/grpc.rs | 59 +- crates/openshell-driver-kubernetes/src/lib.rs | 14 +- .../openshell-driver-kubernetes/src/main.rs | 27 +- .../src/sandboxclaim.rs | 1527 ++++++++ .../src/warm_pool.rs | 2410 ++++++++++++ crates/openshell-driver-mxc/src/driver.rs | 3 + crates/openshell-driver-podman/src/driver.rs | 2 + crates/openshell-driver-vm/src/driver.rs | 4 + crates/openshell-gateway/Cargo.toml | 2 + crates/openshell-gateway/src/lib.rs | 45 +- crates/openshell-otel/src/grpc.rs | 23 +- crates/openshell-otel/src/lib.rs | 5 +- crates/openshell-sandbox/src/lib.rs | 345 +- crates/openshell-sandbox/src/main.rs | 45 +- .../openshell-sandbox/src/sidecar_control.rs | 300 +- crates/openshell-sdk/tests/client_mock.rs | 10 + crates/openshell-server-macros/src/lib.rs | 11 +- .../src/auth/authenticator.rs | 4 +- .../src/auth/compute_driver.rs | 26 +- .../src/auth/descriptor_authz.rs | 1 + crates/openshell-server/src/auth/guard.rs | 7 +- crates/openshell-server/src/auth/k8s_sa.rs | 245 ++ .../openshell-server/src/auth/method_authz.rs | 17 +- crates/openshell-server/src/auth/mod.rs | 2 +- crates/openshell-server/src/auth/principal.rs | 15 +- .../openshell-server/src/auth/sandbox_jwt.rs | 2 +- .../src/auth/sandbox_methods.rs | 3 + .../src/auth/workspace_authz.rs | 6 + crates/openshell-server/src/compute/mod.rs | 967 ++++- crates/openshell-server/src/config_file.rs | 2 +- crates/openshell-server/src/grpc/auth_rpc.rs | 243 +- crates/openshell-server/src/grpc/mod.rs | 30 +- crates/openshell-server/src/grpc/policy.rs | 3 + crates/openshell-server/src/grpc/sandbox.rs | 148 +- crates/openshell-server/src/grpc/workspace.rs | 3 + crates/openshell-server/src/lib.rs | 76 +- crates/openshell-server/src/multiplex.rs | 80 +- .../src/supervisor_pod_registration.rs | 564 +++ .../src/template_reconciliation.rs | 186 + crates/openshell-server/src/test_support.rs | 68 +- .../src/warm_pod_activation.rs | 612 +++ crates/openshell-server/tests/common/mod.rs | 16 +- .../tests/supervisor_relay_integration.rs | 10 +- .../src/log_push.rs | 112 +- .../openshell-workspace/templates/role.yaml | 1 + .../tests/workspace_test.yaml | 6 + deploy/helm/openshell/README.md | 12 +- deploy/helm/openshell/README.md.gotmpl | 6 + .../helm/openshell/templates/clusterrole.yaml | 51 +- .../openshell/templates/gateway-config.yaml | 7 + deploy/helm/openshell/templates/role.yaml | 52 +- .../openshell/tests/clusterrole_test.yaml | 11 + .../openshell/tests/gateway_config_test.yaml | 22 + .../tests/sandbox_namespace_test.yaml | 10 + .../openshell/tests/warm_pool_rbac_test.yaml | 102 + deploy/helm/openshell/values.yaml | 19 +- docs/kubernetes/access-control.mdx | 4 +- docs/kubernetes/setup.mdx | 24 +- docs/reference/gateway-auth.mdx | 2 +- docs/reference/gateway-config.mdx | 20 +- docs/reference/sandbox-compute-drivers.mdx | 63 +- e2e/rust/Cargo.toml | 5 + e2e/rust/src/harness/kubernetes.rs | 219 ++ e2e/rust/src/harness/mod.rs | 1 + e2e/rust/tests/kubernetes_warm_pool.rs | 701 ++++ e2e/with-kube-gateway.sh | 8 + .../kubernetes-warm-pool-config/README.md | 28 + proto/compute_driver.proto | 82 +- proto/openshell.proto | 34 + .../v1/internal/converter/coverage_test.go | 14 + .../v1/internal/converter/sandbox.go | 1 - sdk/go/proto/openshellv1/openshell.pb.go | 2641 ++++++------- sdk/go/proto/openshellv1/openshell_grpc.pb.go | 51 + skills/debug-openshell-cluster/SKILL.md | 19 +- tasks/scripts/helm-k3s-local.sh | 6 + tasks/test.toml | 12 + 101 files changed, 15775 insertions(+), 2248 deletions(-) create mode 100644 crates/openshell-core/src/supervisor_bootstrap.rs create mode 100644 crates/openshell-driver-kubernetes/src/bootstrap.rs create mode 100644 crates/openshell-driver-kubernetes/src/extension_api.rs create mode 100644 crates/openshell-driver-kubernetes/src/sandboxclaim.rs create mode 100644 crates/openshell-driver-kubernetes/src/warm_pool.rs create mode 100644 crates/openshell-server/src/auth/k8s_sa.rs create mode 100644 crates/openshell-server/src/supervisor_pod_registration.rs create mode 100644 crates/openshell-server/src/template_reconciliation.rs create mode 100644 crates/openshell-server/src/warm_pod_activation.rs create mode 100644 deploy/helm/openshell/tests/warm_pool_rbac_test.yaml create mode 100644 e2e/rust/src/harness/kubernetes.rs create mode 100644 e2e/rust/tests/kubernetes_warm_pool.rs create mode 100644 examples/kubernetes-warm-pool-config/README.md diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 780cf8b9c6..1e43fe4b94 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -30,13 +30,19 @@ mise run helm:k3s:create Creates a k3d cluster and merges its kubeconfig into the worktree-local `kubeconfig` file. When the named cluster already exists, the task starts any stopped containers and refreshes same-named kubeconfig entries so a recreated load balancer's current API port takes effect. -Also applies the upstream agent-sandbox CRDs/controller (pinned via `AGENT_SANDBOX_VERSION` +Also applies the upstream agent-sandbox CRDs/controller and, for v0.5 and later, +the `SandboxClaim`, `SandboxTemplate`, and `SandboxWarmPool` extension APIs +(pinned via `AGENT_SANDBOX_VERSION` in `tasks/scripts/helm-k3s-local.sh`, fetched from `github.com/kubernetes-sigs/agent-sandbox` releases), enables its OTLP tracing on v0.5 and later, installs an OTLP trace collector and UI in the `observability` namespace, and preloads the default community sandbox image into k3d so the first sandbox create does not wait on a large registry pull. Traefik is disabled at cluster creation time. +For a v0.4.x Agent Sandbox release, deploy OpenShell with +`server.warmPooling.enabled=false`. Verify a warm-capable installation with +`kubectl api-resources --api-group=extensions.agents.x-k8s.io`. + **Multi-worktree support:** the cluster name is derived from the last component of the current git branch (e.g. branch `kube-support/local-dev/tmutch` → cluster `openshell-dev-tmutch`). Each worktree therefore gets its own isolated cluster and its diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 2e316f88bd..c5a79a0d1e 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -371,14 +371,17 @@ jobs: agent_sandbox_version: v0.5.0 topology: combined extra_helm_values: "" + e2e_task: e2e:kubernetes:warm-pool - agent_sandbox_api: v1alpha1 agent_sandbox_version: v0.4.6 topology: combined extra_helm_values: "" + e2e_task: e2e:kubernetes:v1alpha1 - agent_sandbox_api: v1beta1 agent_sandbox_version: v0.5.0 topology: sidecar extra_helm_values: deploy/helm/openshell/ci/values-sidecar.yaml + e2e_task: e2e:kubernetes:warm-pool-sidecar permissions: actions: read contents: read @@ -389,6 +392,7 @@ jobs: job-name: Kubernetes E2E (Rust smoke, ${{ matrix.topology }}, Agent Sandbox ${{ matrix.agent_sandbox_api }}) agent-sandbox-version: ${{ matrix.agent_sandbox_version }} extra-helm-values: ${{ matrix.extra_helm_values }} + e2e-task: ${{ matrix.e2e_task }} conformance-artifact-prefix: openshell-conformance kubernetes-workspace-managed-e2e: diff --git a/Cargo.lock b/Cargo.lock index 50cca2ed41..cf43077481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4095,6 +4095,7 @@ dependencies = [ "prost-types", "serde", "serde_json", + "sha2 0.10.9", "temp-env", "thiserror 2.0.18", "tokio", @@ -4266,6 +4267,7 @@ version = "0.0.0" dependencies = [ "async-trait", "hyper-util", + "kube", "miette", "nix 0.29.0", "openshell-core", diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index e1e731a0ce..681cbf8900 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -190,13 +190,17 @@ in-process and external drivers. Older drivers omit the field and retain the conservative operator-managed behavior. Drivers that can verify a platform-native sandbox credential advertise -`GetCapabilities.supports_sandbox_authentication`. On the path-scoped -`IssueSandboxToken` exchange, the gateway forwards the opaque bearer credential -to that selected driver through `AuthenticateSandbox`. The driver returns only -the authenticated sandbox ID. The gateway then verifies that its durable -sandbox record exists and mints the gateway JWT. The driver socket is therefore -a sandbox-identity trust boundary, but it does not grant user or administrator -authority. +`GetCapabilities.supports_sandbox_authentication`. The gateway uses that +capability to route supervisor `RegisterSupervisor` bootstrap credentials to +the selected driver's `AuthenticateSandbox` RPC. Drivers that also advertise +`GetCapabilities.supports_warm_supervisor_bootstrap` may return a warm-pending +instance and opaque activation guard instead of a bound sandbox ID. Bound +identities must still resolve to a durable gateway sandbox record before the +gateway mints a sandbox JWT; warm-pending identities remain scoped to later +activation. The legacy `IssueSandboxToken` compatibility path calls the same +driver authentication RPC, but accepts only bound sandbox IDs. The driver +boundary is therefore a sandbox-identity trust boundary, but it does not grant +user or administrator authority. ## Deletion Lifecycle @@ -249,7 +253,7 @@ delete, reconciliation removes the row; otherwise it can remain `Deleting`. |---|---|---|---| | Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. Advertises the combined-supervisor policy-DNS and transparent-TCP substrate. | | Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). Advertises the combined-supervisor policy-DNS and transparent-TCP substrate. | -| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. | +| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, GPU resources, and optionally Agent Sandbox v1beta1 warm-pool claims. | | VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. | | Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a custom `compute_drivers = [""]` entry with `[openshell.drivers.].socket_path`, or at launch time by pairing `--drivers ` with `--compute-driver-socket=`. A launch-time endpoint may use a canonical built-in name to preserve its driver-config key while replacing in-process construction. The gateway connects to an operator-provisioned UDS, snapshots `GetCapabilities`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. | @@ -258,15 +262,20 @@ template resource limits. Docker and Podman apply them as runtime limits. Kubernetes mirrors each limit into the matching request. VM accepts the fields but currently ignores them. -Reusable sandbox workload templates are resolved before the compute-driver -boundary. Drivers do not receive a separate template resource; the gateway -lowers the selected `SandboxWorkloadTemplate` into the existing sandbox spec -and validates that spec before calling `ValidateSandboxCreate` or -`CreateSandbox`. Template CPU and memory become the same typed resource limits -described above. Template GPU settings become `ResourceRequirements`, preserving -the driver's default GPU assignment when the count is omitted. Template -`driver_config` remains a driver-keyed envelope until the compute layer selects -the active driver block and forwards only that block to the driver. +Reusable sandbox workload templates are resolved before the per-sandbox +compute-driver boundary. The gateway lowers a selected +`SandboxWorkloadTemplate` into the existing sandbox spec and validates that spec +before calling `ValidateSandboxCreate` or `CreateSandbox`. Drivers that opt into +template reconciliation implement the optional `SandboxTemplateReconciler` +service and additionally receive the complete desired template set for backend +pre-provisioning. The required `ComputeDriver` service does not include this +method, so drivers without reusable backend resources need no reconciliation +stub. Template CPU and memory become the same typed resource limits described +above. Template GPU settings become +`ResourceRequirements`, preserving the driver's default GPU assignment when the +count is omitted. Template `driver_config` remains a driver-keyed envelope until +the compute layer selects the active driver block and forwards only that block +to the driver. Docker and Podman also accept per-sandbox driver-config mounts for existing runtime-managed named volumes and tmpfs mounts. Podman additionally accepts @@ -391,6 +400,108 @@ identity is absent, malformed, unknown, ambiguous, or resolves to UID/GID 0. The supervisor itself remains root so it can establish isolation before starting unprivileged children. +Kubernetes supervisors authenticate to the gateway in two stages. They first +call `RegisterSupervisor` with the projected ServiceAccount token; the +gateway validates the pod-bound token and live Agent Sandbox owner state, then +activates already-bound cold pods by streaming back a gateway-minted sandbox +JWT. The supervisor installs that JWT in memory and starts the normal +`ConnectSupervisor` session as the activated sandbox. The gateway does not dial +pod IPs or require an inbound activation port; activation is supervisor +initiated over the existing outbound gRPC connection. + +When compatible OpenShell-generated Agent Sandbox warm pools exist, the +Kubernetes driver can create a `SandboxClaim` instead of a direct `Sandbox`. +OpenShell sets the claim's lifecycle shutdown policy to `Delete`. +The driver watches warm pools and templates into a local cache so create +requests do not list or fetch extension objects on the hot path. This remains +behind the compute-driver boundary: other drivers and the driver-agnostic +gateway lifecycle continue to operate on OpenShell sandbox identity and status. +Lifecycle operations resolve a claim-backed OpenShell sandbox ID through the +claim's selected `Sandbox` name and namespace before mutating the selected +resource. The selected warm-pool `Sandbox` does not need to carry the OpenShell +sandbox-ID label because the claim remains the identity mapping. +Warm-pool activation uses the same trust boundary as direct Kubernetes +activation. The direct path validates `pod token -> live pod -> owning Sandbox +CR -> Sandbox sandbox-id metadata`; the warm path validates `pod token -> live +pod -> owning Sandbox CR -> associated SandboxClaim -> SandboxClaim sandbox-id +metadata`. In both paths, sandbox-id metadata is not cryptographic proof by +itself. It is trusted because the gateway and Agent Sandbox controllers own the +relevant Kubernetes objects, and RBAC must prevent sandbox workloads and +untrusted users from creating or mutating trusted `Sandbox`, `SandboxClaim`, pod +metadata, or sandbox service-account identity in the gateway-managed namespace. +Under that model, warm pooling does not require a separate anti-spoofing token +or gateway-side claim mapping beyond the same live Kubernetes ownership and +metadata consistency checks used by the direct path. If an operator grants +untrusted principals write access to those objects, both warm and direct +activation require a broader common proof model. +Claim activation is level-triggered rather than dependent on a single watch +event. The controller periodically relists OpenShell-managed claims and retries +failed watch streams, because a claim can become visible before its selected +`Sandbox`, pod, or outbound supervisor registration. A newly authenticated +warm-supervisor registration clears completed-claim hints and triggers an immediate +relist, so both a restarted process with the same pod UID and a replacement pod +with a new UID can reactivate against an existing claim. It reconciles different +claims concurrently, deduplicates work by claim UID, and caps concurrency so +one late registration or slow Kubernetes lookup cannot block activation for +the namespace or create unbounded work. +The gateway retains activated pod-UID tombstones for one hour to reject +duplicate registration and activation races, then prunes them opportunistically +on registry access so long-running gateways do not accumulate one entry per +historical pod. A new authenticated registration supersedes its pod-UID +tombstone and receives a new local session ID. Activation and failure delivery +are conditional on that session ID, preventing work started for an old process +from consuming or terminating its replacement stream. +Warm claim creation is idempotent by the deterministic claim name. After a +create timeout, transport failure, server error, or conflict, the driver reads +that name back and retries the same claim create when it is not yet visible. It +never switches to direct Sandbox creation after an ambiguous claim write. An +existing claim is accepted only when its OpenShell identity, workspace, +allocation marker, and warm-pool reference match the request. If Kubernetes +still cannot determine whether the claim exists, the driver returns +`Unavailable` and the gateway retains the durable `Provisioning` record. This +prevents another create from assigning a new sandbox ID to the same name while +the original Kubernetes write may still be live; the normal driver watcher can +subsequently reconcile an accepted claim. +Drivers opt into OpenShell `SandboxTemplate` desired-state reconciliation +through the compute-driver capability snapshot. The gateway periodically sends +the complete authoritative template set and also triggers a sweep after +template mutations. The Kubernetes driver uses the desired set to generate Agent +Sandbox `SandboxTemplate` and `SandboxWarmPool` resources for templates whose +`desired_service_level.startup.ready_within` is strictly below the configured +threshold. The generated pool size follows +`desired_service_level.startup.max_burst` capped by the Kubernetes driver +configuration. Generated resource names combine a source-template-ID hash with +the rendered-spec fingerprint. They remain distinct when same-named templates +from different workspaces share the configured namespace. Sandbox creation and +template reconciliation share one workspace-to-namespace mapping boundary: +shared mode uses the configured namespace, managed mode derives a gateway-owned +workspace namespace, and operator mode requires the workspace namespace in the +live allowlist. Generated resources carry the stable gateway identity. After +applying the desired set, the driver prunes owned resources whose source +template is absent, so retries and deletes need no per-template delivery state. +Invalid templates are isolated during gateway translation and driver rendering: +the reconciler logs and excludes each invalid template from the desired set, +continues with the remaining templates, and prunes any generated resources for +the invalid entry. Kubernetes API failures still abort the sweep before global +pruning. The warm-pool cache runs on every replica so local create requests do +not depend on a separate writer lease. + +Warm allocation requires both the extension APIs and a local claim-activation +controller. In-process Kubernetes drivers install that controller for both +in-cluster and inferred kubeconfig clients. Driver capabilities describe this +structural support rather than a startup observation of CRD availability. The +driver dynamically discovers `SandboxClaim`, `SandboxTemplate`, and +`SandboxWarmPool`, caches successful discovery results for 30 seconds, and does +not cache discovery failures. Resource absence suppresses extension calls; +transient discovery and authorization failures do not become an authoritative +empty claim inventory. This keeps existing claim lifecycle and cleanup active +while allowing newly installed CRDs to take effect without a gateway restart. +The standalone remote Kubernetes driver uses direct `Sandbox` creation until +the remote compute-driver protocol gains an activation callback. Template +reconciliation remains structurally available so disabling warm pooling, or +running without local activation, prunes existing gateway-owned warm-pool +resources when the APIs are present. + Kubernetes can run the supervisor in the default combined topology or in a sidecar topology. Combined mode keeps network and process supervision in the agent container. Sidecar mode runs network enforcement, the proxy, and gateway @@ -398,12 +509,17 @@ session in a dedicated sidecar, while the agent container runs only the process-supervision leaf and launches the user workload after the sidecar serves bootstrap state over a local control socket. The network sidecar owns gateway credentials and sends policy plus workload-facing provider environment -state to the process leaf over that socket. It also streams provider -environment updates after settings polls so future process sessions see -updated provider env without giving the process leaf gateway access. The -pre-workload process supervisor is the only accepted control client: the -network sidecar verifies its UID, GID, and PID with peer credentials, removes -the listener after accepting it, and ignores workload-supplied relay targets. +state to the process leaf over that socket. It binds the control socket before +waiting for gateway activation so a warm process leaf remains connected and +idle instead of timing out. The sidecar authenticates that peer immediately but +withholds the bootstrap response until the activated identity, main-process +configuration, and policy are available as one complete snapshot. It also +streams provider environment updates after settings polls so future process +sessions see updated provider env without giving the process leaf gateway +access. The pre-workload process supervisor is the only accepted control +client: the network sidecar verifies its UID, GID, and PID with peer +credentials, removes the listener after accepting it, and ignores +workload-supplied relay targets. SSH relays use a Linux abstract socket and verify its peer PID against that authenticated process-supervisor connection, so workload filesystem access cannot replace the relay endpoint. Either supervisor exits when this control @@ -514,12 +630,13 @@ externally. RBAC uses the same ClusterRole as managed mode but without namespace ### Watching and Querying -Managed and operator modes set `is_multi_namespace() == true`, which switches -sandbox CR watchers from namespace-scoped `Api::namespaced` to cluster-wide -`Api::all_with`. In managed mode the driver scopes cluster-wide queries with a -`LABEL_GATEWAY_ID` label selector to support multiple gateways on the same -cluster. K8s Events are not watched in cluster-wide mode — the cluster-wide -watcher emits only sandbox CR changes, not platform events. +Managed and operator modes set `is_multi_namespace() == true` and use +cluster-wide `Api::all_with` watchers for both `Sandbox` and `SandboxClaim` +resources. The driver scopes both streams with a `LABEL_GATEWAY_ID` label +selector to support multiple gateways on the same cluster. Claim events +preserve the OpenShell identity of warm-pooled sandboxes as their allocation +status changes. K8s Events are not watched in cluster-wide mode, so the watcher +emits sandbox and claim resource changes but not platform events. ### SA Token Authentication diff --git a/architecture/gateway.md b/architecture/gateway.md index 4b0e70c730..df8ddd7244 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -219,8 +219,9 @@ token is reported as connected but unauthenticated. Sandbox supervisor RPCs authenticate with explicit sandbox credentials; mTLS does not grant sandbox identity. Kubernetes deployments use the gateway-minted JWT bootstrap path: the supervisor starts with a projected -ServiceAccount token, exchanges it for a gateway-minted sandbox JWT, and uses -that JWT on subsequent gateway RPCs. +ServiceAccount token, registers the pod with the gateway, receives a +gateway-minted sandbox JWT after activation, and uses that JWT on subsequent +gateway RPCs. User-facing RPCs are authorized by descriptor-declared role and scope policy when OIDC or edge identity is enabled. The OIDC admin role grants platform-wide access and bypasses workspace membership checks. Workspace Admin and Workspace @@ -234,22 +235,43 @@ identity inspection without client-side token decoding. Sandbox secrets are gateway-signed JWTs bound to a single sandbox ID. Docker, Podman, and VM drivers deliver the initial token through supervisor-only runtime material; Kubernetes supervisors exchange a projected ServiceAccount -token through `IssueSandboxToken`. The gateway delegates that opaque credential -to the selected compute driver's `AuthenticateSandbox` RPC. A capable driver is -trusted to return the authenticated sandbox ID, while the gateway still requires -a matching durable sandbox record before minting a JWT. The Kubernetes driver -uses its own named configuration to run TokenReview and verify the live pod and -controlling Sandbox CR. The bootstrap path accepts -both `agents.x-k8s.io/v1beta1` ownerReferences from newer Agent Sandbox -controllers and `agents.x-k8s.io/v1alpha1` ownerReferences from existing -deployments. Supervisors renew gateway JWTs in memory before expiry only while -the sandbox record still exists. Older tokens are not server-revoked; shared -deployments bound replay exposure with short `gateway_jwt.ttl_secs` lifetimes. +token through `RegisterSupervisor`. The gateway delegates that opaque +credential to the selected compute driver's `AuthenticateSandbox` RPC. The +driver verifies the credential and returns either a bound sandbox ID or a +warm-pending supervisor instance with an opaque activation guard. For +already-bound pods, the gateway still requires a matching durable sandbox record +before sending an activation message with the gateway JWT. For warm-pooled +Kubernetes sandboxes, the same check is re-anchored through the claim that +adopted the warm pod: the live pod must still be controlled by a live +`Sandbox`, that `Sandbox` must be associated with the live `SandboxClaim`, and +the sandbox-id metadata on that claim must identify the same OpenShell sandbox +record before activation can complete. Activation must present the same opaque +guard returned during registration. Both paths depend on the same Kubernetes +RBAC boundary: sandbox workloads and untrusted users must not be able to create +or mutate trusted Agent Sandbox objects, pod metadata, or the configured +sandbox ServiceAccount in the gateway-managed namespace. `IssueSandboxToken` +remains as a compatibility shim for older supervisor images and mints through +the same driver authentication path, but only for already-bound responses. +Supervisors renew gateway JWTs in +memory before expiry only while the sandbox record still exists. Older tokens +are not server-revoked; shared deployments bound replay exposure with short +`gateway_jwt.ttl_secs` lifetimes. The config default is `gateway_jwt.ttl_secs = 0` for local single-player Docker, Podman, and VM gateways; those tokens carry `exp = 0` and do not expire. Kubernetes and other shared deployments should set a positive TTL. +Warm supervisors begin capturing logs before activation but do not open the log +stream until registration supplies the authoritative sandbox ID and installs +the gateway JWT. A bounded pre-activation queue is rebound to that identity, +which avoids both missing startup records and a second bootstrap race. In +sidecar topology, the registering network supervisor also sends the activated +sandbox ID and name together with the main-process command, TTY, and attachment +state to the process supervisor over the authenticated local control socket. +The process supervisor applies that identity before initializing its OCSF +context and rejects conflicts with any identity already present in its +environment. + Gateway JWT signing-key rotation is currently an offline operator action. The runtime loads one active signing key and one matching public verification key from the configured secret at startup. To rotate that key material today, @@ -373,6 +395,15 @@ created from a template resolves that resource once and persists an ordinary name, labels, annotations, provider attachments, and policy. The sandbox stores template provenance as the template name and resource version used for the snapshot, so later template edits or deletes do not mutate existing sandboxes. +For drivers that opt into template reconciliation, a background worker reads +the complete template set and sends it as authoritative desired state through +the optional `SandboxTemplateReconciler` driver service. The required +`ComputeDriver` service remains limited to common sandbox lifecycle operations. +The worker runs at startup, after template mutations, and periodically, so a +failed driver call or missed notification is retried from the source of truth. +The driver applies the supplied set idempotently and prunes backend resources it +owns for absent templates. Template create and delete therefore need no hidden +delivery rows or tombstones. OAuth refresh failures retain a gateway-owned recovery classification alongside the refresh state. The gateway reads only a bounded error response and maps diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7545786a12..bd4427b3bf 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -636,6 +636,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn register_supervisor( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn refresh_sandbox_token( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 29de1fe6fe..bc6de8da3b 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -499,6 +499,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn register_supervisor( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn refresh_sandbox_token( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 2d1b3f25d3..36230fdae2 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1066,6 +1066,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn register_supervisor( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn refresh_sandbox_token( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index d9e3cdad98..50218b3e52 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -895,6 +895,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn register_supervisor( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn refresh_sandbox_token( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index fdb0ebbd0d..792d2eb5db 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -587,6 +587,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn register_supervisor( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn refresh_sandbox_token( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index ad1f42db6c..7421b347aa 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; -use crate::proto::compute::v1::DriverSandbox; +use crate::proto::compute::v1::{DriverSandbox, GetCapabilitiesResponse}; // --------------------------------------------------------------------------- // Sandbox container/pod label keys (openshell.ai/ namespace) @@ -698,6 +698,46 @@ pub fn sandbox_token_path( Ok(path.join(sandbox_id).join("sandbox.jwt")) } +/// Build a [`GetCapabilitiesResponse`] from the common driver capability fields. +/// +/// Every compute driver constructs this response with the same fields. Shared +/// here to avoid repeating the struct literal in each driver crate. +pub fn build_capabilities_response( + driver_name: &str, + driver_version: impl Into, + default_image: impl Into, +) -> GetCapabilitiesResponse { + build_capabilities_response_with_template_reconciliation( + driver_name, + driver_version, + default_image, + false, + ) +} + +/// Build a [`GetCapabilitiesResponse`] and configure authoritative +/// sandbox-template reconciliation. +pub fn build_capabilities_response_with_template_reconciliation( + driver_name: &str, + driver_version: impl Into, + default_image: impl Into, + supports_sandbox_template_reconciliation: bool, +) -> GetCapabilitiesResponse { + GetCapabilitiesResponse { + driver_name: driver_name.to_string(), + driver_version: driver_version.into(), + default_image: default_image.into(), + gateway_manages_lifecycle: false, + supports_sandbox_authentication: false, + driver_reports_runtime_readiness: false, + supports_sandbox_template_reconciliation, + supports_warm_supervisor_bootstrap: false, + resource_capabilities: None, + rootfs_tar_staging_dir: String::new(), + rootfs_tar_max_bytes: 0, + } +} + /// Return the effective log level for a sandbox. /// /// Uses the level from the sandbox spec when non-empty, falling back to diff --git a/crates/openshell-core/src/dynamic_string_allowlist.rs b/crates/openshell-core/src/dynamic_string_allowlist.rs index 1a2968188f..a99a64f15f 100644 --- a/crates/openshell-core/src/dynamic_string_allowlist.rs +++ b/crates/openshell-core/src/dynamic_string_allowlist.rs @@ -3,14 +3,24 @@ use std::collections::BTreeSet; use std::sync::{Arc, RwLock}; +use tokio::sync::watch; /// Thread-safe dynamic allowlist of strings shared across component boundaries. #[derive(Debug, Clone)] pub struct DynamicStringAllowlist { inner: Arc>>, + initial_sync: Arc>, } impl DynamicStringAllowlist { + fn with_initial_sync_state(set: BTreeSet, initially_synced: bool) -> Self { + let (initial_sync, _) = watch::channel(initially_synced); + Self { + inner: Arc::new(RwLock::new(set)), + initial_sync: Arc::new(initial_sync), + } + } + fn read_guard(&self) -> std::sync::RwLockReadGuard<'_, BTreeSet> { self.inner .read() @@ -25,22 +35,43 @@ impl DynamicStringAllowlist { #[must_use] pub fn new() -> Self { - Self { - inner: Arc::new(RwLock::new(BTreeSet::new())), - } + Self::with_initial_sync_state(BTreeSet::new(), false) } #[must_use] pub fn from_set(set: BTreeSet) -> Self { - Self { - inner: Arc::new(RwLock::new(set)), - } + Self::with_initial_sync_state(set, true) } pub fn replace(&self, new_set: BTreeSet) { *self.write_guard() = new_set; } + /// Replace the allowlist from an authoritative source snapshot and unblock + /// consumers waiting for the first successful synchronization. + pub fn replace_and_mark_initially_synced(&self, new_set: BTreeSet) { + self.replace(new_set); + self.initial_sync.send_replace(true); + } + + #[must_use] + pub fn is_initially_synced(&self) -> bool { + *self.initial_sync.borrow() + } + + /// Wait until an authoritative source snapshot has populated the allowlist. + pub async fn wait_until_initially_synced(&self) { + let mut initial_sync = self.initial_sync.subscribe(); + if *initial_sync.borrow_and_update() { + return; + } + while initial_sync.changed().await.is_ok() { + if *initial_sync.borrow_and_update() { + return; + } + } + } + pub fn merge(&self, additional: &BTreeSet) { self.write_guard().extend(additional.iter().cloned()); } @@ -73,3 +104,33 @@ impl Default for DynamicStringAllowlist { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn authoritative_replace_completes_initial_sync() { + let allowlist = DynamicStringAllowlist::new(); + assert!(!allowlist.is_initially_synced()); + + let waiter = { + let allowlist = allowlist.clone(); + tokio::spawn(async move { allowlist.wait_until_initially_synced().await }) + }; + allowlist.replace_and_mark_initially_synced(BTreeSet::new()); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("initial sync waiter should be notified") + .expect("initial sync waiter should finish"); + assert!(allowlist.is_initially_synced()); + } + + #[test] + fn static_set_is_initially_synced() { + let allowlist = DynamicStringAllowlist::from_set(BTreeSet::new()); + assert!(allowlist.is_initially_synced()); + } +} diff --git a/crates/openshell-core/src/error.rs b/crates/openshell-core/src/error.rs index 145106012d..780a6ae3db 100644 --- a/crates/openshell-core/src/error.rs +++ b/crates/openshell-core/src/error.rs @@ -122,6 +122,10 @@ pub enum ComputeDriverError { /// A precondition for the operation was not met. #[error("{0}")] Precondition(String), + /// The backend may have accepted the operation, but its result could not be + /// determined. Callers must preserve durable intent for reconciliation. + #[error("{0}")] + Unavailable(String), /// Generic error message. #[error("{0}")] Message(String), @@ -134,6 +138,7 @@ impl From for tonic::Status { ComputeDriverError::NotFound => Self::not_found("sandbox not found"), ComputeDriverError::InvalidArgument(m) => Self::invalid_argument(m), ComputeDriverError::Precondition(m) => Self::failed_precondition(m), + ComputeDriverError::Unavailable(m) => Self::unavailable(m), ComputeDriverError::Message(m) => Self::internal(m), } } diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 315ff0b72f..a9b5464f5b 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -12,8 +12,8 @@ //! Podman / VM drivers write this to a bundle file at sandbox-create //! time). //! 3. `OPENSHELL_K8S_SA_TOKEN_FILE` — projected `ServiceAccount` JWT; the -//! supervisor exchanges it for a gateway JWT via `IssueSandboxToken` -//! once at startup. +//! supervisor registers its pod and receives a gateway JWT via +//! `RegisterSupervisor` once at startup. //! //! The resolved bearer credential is held in process memory thereafter and //! injected on every outbound call by [`AuthInterceptor`]. @@ -24,10 +24,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::proto::{ DenialSummary, ExchangeProviderSubjectTokenRequest, GetDraftPolicyRequest, - GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, - NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, + GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, NetworkActivitySummary, + PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, RegisterSupervisorRequest, ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UpdateConfigRequest, open_shell_client::OpenShellClient, + SubmitPolicyAnalysisResponse, SupervisorActivationMessage, UpdateConfigRequest, + open_shell_client::OpenShellClient, }; use crate::sandbox_env; use miette::{IntoDiagnostic, Result, WrapErr}; @@ -257,7 +258,7 @@ async fn token_slot(endpoint: &str, plain_channel: &Channel) -> Result<(TokenSlo /// /// `endpoint` is logged on errors but never used for transport here; the /// actual network call lives inside this function only on the K8s -/// bootstrap path, which uses `plain_channel` to call `IssueSandboxToken` +/// bootstrap path, which uses `plain_channel` to call `RegisterSupervisor` /// once before the steady-state Bearer-authenticated channel is built. async fn acquire_sandbox_token(endpoint: &str, plain_channel: &Channel) -> Result { if let Ok(t) = std::env::var(sandbox_env::SANDBOX_TOKEN) @@ -287,7 +288,9 @@ async fn acquire_sandbox_token(endpoint: &str, plain_channel: &Channel) -> Resul && !sa_path.is_empty() { return Ok(AcquiredToken { - token: acquire_k8s_sandbox_token(endpoint, plain_channel, &sa_path).await?, + token: acquire_supervisor_activation(endpoint, plain_channel, &sa_path) + .await? + .token, refresh_mode: RefreshMode::GatewayJwt(TokenSource::K8sServiceAccount), }); } @@ -305,12 +308,24 @@ async fn acquire_k8s_sandbox_token( plain_channel: &Channel, sa_path: &str, ) -> Result { + Ok( + acquire_supervisor_activation(endpoint, plain_channel, sa_path) + .await? + .token, + ) +} + +async fn acquire_supervisor_activation( + endpoint: &str, + plain_channel: &Channel, + sa_path: &str, +) -> Result { let sa_token = std::fs::read_to_string(sa_path) .into_diagnostic() .wrap_err_with(|| format!("failed to read K8s SA token from {sa_path}"))? .trim() .to_string(); - info!(endpoint = %endpoint, "exchanging K8s ServiceAccount token for sandbox JWT"); + info!(endpoint = %endpoint, "registering supervisor for sandbox activation"); // The bootstrap exchange uses a one-off interceptor pinned to the // SA token; the resulting gateway JWT becomes the value in the // shared `TOKEN_SLOT` once `connect_channel` returns. @@ -323,11 +338,77 @@ async fn acquire_k8s_sandbox_token( let bootstrap = InterceptedService::new(plain_channel.clone(), interceptor); let mut client = OpenShellClient::new(bootstrap); let resp = client - .issue_sandbox_token(IssueSandboxTokenRequest {}) + .register_supervisor(RegisterSupervisorRequest {}) + .await + .into_diagnostic() + .wrap_err("RegisterSupervisor bootstrap stream failed")?; + let mut stream = resp.into_inner(); + let activation = stream + .message() .await .into_diagnostic() - .wrap_err("IssueSandboxToken bootstrap exchange failed")?; - Ok(resp.into_inner().token) + .wrap_err("RegisterSupervisor activation stream failed")? + .ok_or_else(|| miette::miette!("RegisterSupervisor stream closed before activation"))?; + info!( + sandbox_id = %activation.sandbox_id, + sandbox_name = %activation.sandbox_name, + "received supervisor activation" + ); + Ok(activation) +} + +/// Register a supervisor instance and install the activated gateway JWT. +/// +/// Warm instances call this before policy loading. The registration stream remains +/// pending until the Kubernetes driver observes a claim binding and the gateway +/// sends activation. +pub async fn register_supervisor(endpoint: &str) -> Result { + let sa_path = std::env::var(sandbox_env::K8S_SA_TOKEN_FILE) + .ok() + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + miette::miette!( + "{} must be set to register supervisor", + sandbox_env::K8S_SA_TOKEN_FILE + ) + })?; + + let mut backoff = Duration::from_secs(1); + loop { + let activation = { + let guard = TOKEN_INIT_LOCK.lock().await; + if TOKEN_SLOT.get().is_some() { + return Err(miette::miette!( + "supervisor registration was requested after sandbox token initialization" + )); + } + + match async { + let plain_channel = build_plain_channel(endpoint).await?; + acquire_supervisor_activation(endpoint, &plain_channel, &sa_path).await + } + .await + { + Ok(activation) => activation, + Err(err) => { + warn!( + endpoint = %endpoint, + error = %err, + retry_after_secs = backoff.as_secs(), + "supervisor registration failed; retrying: {err:#}" + ); + drop(guard); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + continue; + } + } + }; + + let _slot = install_token_slot(&activation.token)?; + let _ = TOKEN_REFRESH_MODE.set(RefreshMode::GatewayJwt(TokenSource::K8sServiceAccount)); + return Ok(activation); + } } /// Build an authenticated channel for direct external use (e.g. the diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 1bded44693..83541314af 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod secrets; pub mod settings; pub mod shell; pub mod spiffe; +pub mod supervisor_bootstrap; pub mod telemetry; pub mod time; pub mod transport_errors; diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 2ce8e4b058..870c3813f8 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -32,6 +32,10 @@ pub const LOG_LEVEL: &str = "OPENSHELL_LOG_LEVEL"; /// environment values may use the `base64url:`-prefixed representation. pub const MAIN_PROCESS_SPEC: &str = "OPENSHELL_MAIN_PROCESS_SPEC"; +/// Server-owned sandbox annotation that preserves create-time main-process +/// launch intent for warm-pool activation. +pub const MAIN_PROCESS_SPEC_ANNOTATION: &str = "openshell.ai/main-process-spec"; + const MAIN_PROCESS_SPEC_BASE64URL_PREFIX: &str = "base64url:"; /// Lossless driver-to-supervisor representation of the canonical process. @@ -77,6 +81,25 @@ impl MainProcessConfig { } } + #[must_use] + pub fn from_public_spec( + spec: Option<&crate::proto::SandboxSpec>, + await_main_process_attachment: bool, + ) -> Self { + match spec { + Some(spec) if !spec.command.is_empty() => Self { + version: Self::VERSION, + command: spec.command.clone(), + tty: spec.tty, + await_main_process_attachment, + }, + None | Some(_) => Self { + await_main_process_attachment, + ..Self::scratch() + }, + } + } + /// Decode the versioned transport without shell interpretation. pub fn decode(encoded: &str) -> Result { let decoded; @@ -116,6 +139,15 @@ impl MainProcessConfig { serde_json::to_string(&Self::from_driver_spec(spec)) } + /// Encode the public API create-time process intent for activation-time + /// delivery to warm supervisors. + pub fn encode_public_spec( + spec: Option<&crate::proto::SandboxSpec>, + await_main_process_attachment: bool, + ) -> Result { + serde_json::to_string(&Self::from_public_spec(spec, await_main_process_attachment)) + } + /// Encode the versioned transport without whitespace for constrained /// environment-variable transports used by embedded runtimes. pub fn encode_driver_spec_base64url( @@ -196,9 +228,10 @@ pub const USER_ENVIRONMENT: &str = "OPENSHELL_USER_ENVIRONMENT"; /// Path to the projected `ServiceAccount` JWT (Kubernetes driver). /// -/// Used to bootstrap a gateway-minted JWT via `IssueSandboxToken`. Kubelet -/// writes and rotates this file; the supervisor exchanges its contents -/// for a gateway JWT at startup and on refresh. +/// Used to register the supervisor pod and receive a gateway-minted JWT via +/// `RegisterSupervisor`. Kubelet writes and rotates this file; the +/// supervisor presents its contents at startup and when rebootstrap is needed +/// after refresh authentication failure. pub const K8S_SA_TOKEN_FILE: &str = "OPENSHELL_K8S_SA_TOKEN_FILE"; /// Filesystem path to the SPIFFE Workload API UNIX socket used for provider @@ -259,6 +292,22 @@ mod tests { assert!(decoded.await_main_process_attachment); } + #[test] + fn public_main_process_transport_preserves_attachment_hint() { + let spec = crate::proto::SandboxSpec { + command: vec!["date".into(), "-u".into()], + tty: false, + ..Default::default() + }; + + let encoded = MainProcessConfig::encode_public_spec(Some(&spec), true).unwrap(); + let decoded = MainProcessConfig::decode(&encoded).unwrap(); + + assert_eq!(decoded.command, spec.command); + assert!(!decoded.tty); + assert!(decoded.await_main_process_attachment); + } + #[test] fn base64url_main_process_transport_preserves_spaces() { let spec = crate::proto::compute::v1::DriverSandboxSpec { diff --git a/crates/openshell-core/src/supervisor_bootstrap.rs b/crates/openshell-core/src/supervisor_bootstrap.rs new file mode 100644 index 0000000000..1f34fbc0e0 --- /dev/null +++ b/crates/openshell-core/src/supervisor_bootstrap.rs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driver-provided supervisor bootstrap capabilities. +//! +//! These types describe the narrow interface between the gateway and compute +//! drivers for supervisors that cannot start with a gateway-minted sandbox JWT. +//! Kubernetes is the initial implementation: the driver validates projected +//! `ServiceAccount` tokens and classifies pods as already bound or warm-pending. + +use tonic::Status; +use tonic::async_trait; + +/// Registration-only identity for a supervisor runtime instance. +/// +/// This is not a sandbox principal. The gateway may use it only on the +/// bootstrap registration path until a concrete sandbox token is minted. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SupervisorBootstrapIdentity { + /// Driver that produced the identity, for example `kubernetes`. + pub driver: String, + /// Driver-native stable instance ID, for example Kubernetes pod UID. + pub instance_id: String, + /// Whether the instance is already bound to an `OpenShell` sandbox or is + /// waiting for a warm-pool claim. + pub binding: SupervisorBootstrapBinding, +} + +/// Binding state returned by a driver bootstrap identity provider. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SupervisorBootstrapBinding { + /// The instance is already bound to a concrete `OpenShell` sandbox. + BoundSandbox { sandbox_id: String }, + /// The instance is valid but not yet claim-bound. + WarmPending { activation_guard: String }, +} + +impl SupervisorBootstrapIdentity { + /// Return the bound sandbox ID when the identity is already activated by + /// driver state. + #[must_use] + pub fn bound_sandbox_id(&self) -> Option<&str> { + match &self.binding { + SupervisorBootstrapBinding::BoundSandbox { sandbox_id } => Some(sandbox_id.as_str()), + SupervisorBootstrapBinding::WarmPending { .. } => None, + } + } +} + +/// Driver-provided authentication for supervisor bootstrap registration. +#[async_trait] +pub trait SupervisorBootstrapIdentityProvider: Send + Sync { + /// Authenticate a driver-native bootstrap token. + /// + /// `Ok(None)` means the token did not authenticate and another + /// authenticator may try it. `Err` means authentication could not safely + /// complete or the token was authenticated but invalid for bootstrap. + async fn authenticate_registration( + &self, + token: &str, + ) -> Result, Status>; +} + +/// Request sent by a driver-side warm-pool controller to activate a pending +/// bootstrap stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SupervisorBootstrapActivationRequest { + /// Driver that produced the pending registration. + pub driver: String, + /// Driver-native stable instance ID to activate. + pub instance_id: String, + /// `OpenShell` sandbox ID the instance should become. + pub sandbox_id: String, + /// Opaque driver-owned activation correlation value observed during claim + /// validation. + pub activation_guard: String, + /// Short reason for logs and audit messages. + pub reason: String, +} + +/// Gateway-owned activation callback passed to driver-side warm-pool logic. +#[async_trait] +pub trait SupervisorBootstrapActivator: Send + Sync { + async fn activate_registered_supervisor( + &self, + request: SupervisorBootstrapActivationRequest, + ) -> Result<(), Status>; +} diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 4566d5c171..cf4d2aefca 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -606,6 +606,8 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, } } diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 010d50c20e..cc2776ae2d 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -472,7 +472,10 @@ async fn tracing_lifecycle_rpc_failures_export_docker_operation_spans() { async { ComputeDriver::create_sandbox( &driver, - Request::new(CreateSandboxRequest { sandbox: None }), + Request::new(CreateSandboxRequest { + sandbox: None, + sandbox_template: None, + }), ) .await .expect_err("missing sandbox should fail"); diff --git a/crates/openshell-driver-kubernetes/Cargo.toml b/crates/openshell-driver-kubernetes/Cargo.toml index 3a3d843f1a..665cff4767 100644 --- a/crates/openshell-driver-kubernetes/Cargo.toml +++ b/crates/openshell-driver-kubernetes/Cargo.toml @@ -31,6 +31,8 @@ kube-runtime = { workspace = true } k8s-openapi = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } +sha2 = { workspace = true } clap = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 02dcfe5e87..157bbec113 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -63,6 +63,72 @@ Kubernetes API calls use explicit timeouts so gRPC handlers do not block indefinitely when the API server is slow or unavailable. Resource and Event watches recover in place with API-friendly backoff after transient watcher errors, avoiding a gateway-side watch restart and its associated watch gap. +Managed and operator modes watch both `Sandbox` and `SandboxClaim` resources +across workspace namespaces, scoped to the current gateway identity. + +## Warm-Pool Allocation + +The driver can use Agent Sandbox extension CRDs for transparent warm-pool +allocation. When `warm_pooling.enabled` is true, the driver reconciles eligible +OpenShell `SandboxTemplate` resources into generated +`extensions.agents.x-k8s.io/v1beta1` `SandboxTemplate` and `SandboxWarmPool` +resources. It watches and caches those generated pools, resolves each pool's +referenced `SandboxTemplate`, and computes an in-memory fingerprint of the +template spec. + +On create, the driver maps the OpenShell workspace to its target Kubernetes +namespace, renders the spec that direct `Sandbox` creation would use, strips +per-sandbox identity values from the fingerprint, and matches it against the +warm-pool cache for that namespace. Shared mode uses the configured namespace, +managed mode uses the gateway-owned workspace namespace, and operator mode uses +the allowlisted workspace namespace. +If exactly one pool matches, the driver creates a v1beta1 `SandboxClaim` with +`spec.warmPoolRef.name` set to that pool and +`spec.lifecycle.shutdownPolicy` set to `Delete`. If no pool matches, multiple +pools match, or the v1beta1 extension APIs are absent, the driver falls back to +direct `Sandbox` creation. RBAC failures are logged as configuration errors; +claim inventory and cleanup return the error instead of reporting an empty +backend. +Claim watch events publish pending and selected warm-pool status through the +same lifecycle stream as direct `Sandbox` resources in every namespace mode. +Stop and start operations resolve claim-backed sandbox IDs through +`status.sandbox.name` on the claim, then patch the selected `Sandbox` in the +claim's namespace. + +The extension CRDs are intentionally v1beta1-only. The core +`agents.x-k8s.io/Sandbox` CRD still supports the existing v1beta1-to-v1alpha1 +fallback. + +The gateway periodically sends the complete authoritative OpenShell template +set to drivers that advertise template reconciliation support. It also triggers +a sweep after template creation and deletion. The Kubernetes driver applies the +set and prunes generated resources, scoped by the gateway identity, when their +source template is absent. Transient Kubernetes or driver outages recover on a +later sweep without delivery rows or delete tombstones. +In operator mode, reconciliation waits for the first authoritative namespace +allowlist snapshot before it applies or prunes generated resources. A delayed +label watch or failed initial namespace-file load therefore cannot make an +unsynchronized empty allowlist look like an instruction to delete warm pools. +Kubernetes creates a warm pool when +`desired_service_level.startup.ready_within` is strictly less than +`warm_pooling.templates.ready_within_threshold_secs` (default 5 seconds). The +pool replica count comes from `desired_service_level.startup.max_burst`, capped +by `warm_pooling.templates.max_replicas` (default 20). Generated resource names +include both a source-template-ID hash and the rendered-spec fingerprint, which +keeps same-named templates from different workspaces distinct in shared mode. + +Disabling `warm_pooling.enabled` disables warm-pool allocation. If the Agent +Sandbox extension APIs remain reachable, reconciliation deletes existing +gateway-owned generated resources. The driver discovers the extension API +resources dynamically and caches successful discovery results for 30 seconds. +It does not cache discovery errors, and it continues claim inventory, +activation, and cleanup independently of new warm allocation. Installing the +extension CRDs therefore takes effect without restarting the gateway. +The standalone remote driver is cold-only because the current remote driver +protocol has no gateway-side claim activation callback. It can still reconcile +template desired state to remove existing gateway-owned pools. The allocation +cache remains replica-local and runs on every in-process gateway because +sandbox creation reads it locally. ## Workspace Persistence @@ -102,10 +168,11 @@ values must override image-provided environment variables. Sandbox pods run as `service_account_name` and keep `automountServiceAccountToken: false`. The only Kubernetes token exposed to the supervisor is an explicit, audience-bound projected token mounted at -`/var/run/secrets/openshell/token` for the one-shot `IssueSandboxToken` -bootstrap exchange. The Kubernetes driver authenticates that token through the -compute-driver protocol using its own `service_account_name` and workspace-mode -namespace policy; the gateway receives only the verified sandbox ID. +`/var/run/secrets/openshell/token` for the `RegisterSupervisor` bootstrap +stream. The Kubernetes driver authenticates that token through the compute-driver +protocol using its own `service_account_name` and workspace-mode namespace +policy; the gateway receives only the verified sandbox ID and activates +already-bound cold pods by returning a gateway-minted sandbox JWT on that stream. The gateway uses the supervisor relay for connect, exec, and file sync. Sandbox pods do not need direct external ingress for SSH. diff --git a/crates/openshell-driver-kubernetes/src/bootstrap.rs b/crates/openshell-driver-kubernetes/src/bootstrap.rs new file mode 100644 index 0000000000..ec71e04c50 --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/bootstrap.rs @@ -0,0 +1,974 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Kubernetes `ServiceAccount` supervisor bootstrap identity provider. +//! +//! Validates a projected SA token presented by a sandbox pod, reads the pod's +//! `openshell.ai/sandbox-id` annotation, verifies the pod is controlled by the +//! corresponding Sandbox CR, and returns a registration-only driver bootstrap +//! identity. Warm pods may register before they are bound to a sandbox, so this +//! identity must not grant sandbox-scoped RPC access. +//! +//! This is the Kubernetes driver's apiserver-facing side of the supervisor +//! bootstrap boundary. The gateway owns the public registration stream and +//! token minting. + +use crate::config::{KubernetesComputeConfig, OperatorNamespaceAllowlist, accepts_auth_namespace}; +use k8s_openapi::api::{ + authentication::v1::{TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo}, + core::v1::Pod, +}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::Error as KubeError; +use kube::api::{Api, ApiResource, PostParams}; +use kube::core::{DynamicObject, gvk::GroupVersionKind}; +use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapBinding, SupervisorBootstrapIdentity, SupervisorBootstrapIdentityProvider, +}; +use std::sync::Arc; +use tonic::Status; +use tonic::async_trait; +use tracing::{debug, info, warn}; + +/// Pod annotation that binds a sandbox pod to its UUID. Set by the +/// Kubernetes compute driver at pod-create time. +pub const SANDBOX_ID_ANNOTATION: &str = "openshell.ai/sandbox-id"; +const SANDBOX_API_GROUP: &str = "agents.x-k8s.io"; +const SANDBOX_API_VERSION_V1BETA1: &str = "v1beta1"; +const SANDBOX_API_VERSION_V1ALPHA1: &str = "v1alpha1"; +const SANDBOX_API_VERSION_FULL_V1BETA1: &str = "agents.x-k8s.io/v1beta1"; +const SANDBOX_API_VERSION_FULL_V1ALPHA1: &str = "agents.x-k8s.io/v1alpha1"; +const SANDBOX_KIND: &str = "Sandbox"; +const SANDBOX_ID_LABEL: &str = "openshell.ai/sandbox-id"; +const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name"; +const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid"; + +/// Apiserver-facing operations the authenticator depends on. Split out so +/// tests can fake the apiserver without standing up a kube cluster. +#[async_trait] +pub trait K8sIdentityResolver: Send + Sync + 'static { + /// Validate `token` via `TokenReview` (`aud == openshell-gateway`), + /// extract the pod name/uid, then `GET` the pod and owning Sandbox CR. + /// Returns `Ok(None)` when the token is well-formed but does not + /// authenticate (e.g. wrong audience); returns `Err` for + /// transport/server errors. + async fn resolve(&self, token: &str) -> Result, Status>; +} + +/// Kubernetes implementation of the driver bootstrap identity provider. +pub struct KubernetesSupervisorBootstrapIdentityProvider { + resolver: Arc, +} + +impl std::fmt::Debug for KubernetesSupervisorBootstrapIdentityProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KubernetesSupervisorBootstrapIdentityProvider") + .finish_non_exhaustive() + } +} + +impl KubernetesSupervisorBootstrapIdentityProvider { + pub fn new(resolver: Arc) -> Self { + Self { resolver } + } +} + +#[async_trait] +impl SupervisorBootstrapIdentityProvider for KubernetesSupervisorBootstrapIdentityProvider { + async fn authenticate_registration( + &self, + token: &str, + ) -> Result, Status> { + self.resolver.resolve(token).await + } +} + +#[derive(Debug)] +struct TokenReviewIdentity { + namespace: String, + pod_name: String, + pod_uid: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SandboxOwnerReference { + api_version: String, + name: String, + uid: String, +} + +/// Resolver backed by the apiserver's `TokenReview` API and `kube::Client` +/// for the per-pod annotation lookup. +pub struct LiveK8sResolver { + client: kube::Client, + token_reviews_api: Api, + config: KubernetesComputeConfig, + operator_allowlist: Option, + expected_audience: String, +} + +impl LiveK8sResolver { + pub fn new( + client: kube::Client, + config: KubernetesComputeConfig, + operator_allowlist: Option, + expected_audience: String, + ) -> Self { + let token_reviews_api: Api = Api::all(client.clone()); + Self { + client, + token_reviews_api, + config, + operator_allowlist, + expected_audience, + } + } + + fn pods_api(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + fn sandboxes_apis(&self, namespace: &str) -> [Api; 2] { + let sandbox_gvk_v1beta1 = + GroupVersionKind::gvk(SANDBOX_API_GROUP, SANDBOX_API_VERSION_V1BETA1, SANDBOX_KIND); + let sandbox_resource_v1beta1 = ApiResource::from_gvk(&sandbox_gvk_v1beta1); + let sandbox_gvk_v1alpha1 = GroupVersionKind::gvk( + SANDBOX_API_GROUP, + SANDBOX_API_VERSION_V1ALPHA1, + SANDBOX_KIND, + ); + let sandbox_resource_v1alpha1 = ApiResource::from_gvk(&sandbox_gvk_v1alpha1); + let sandboxes_api_v1beta1: Api = + Api::namespaced_with(self.client.clone(), namespace, &sandbox_resource_v1beta1); + let sandboxes_api_v1alpha1: Api = + Api::namespaced_with(self.client.clone(), namespace, &sandbox_resource_v1alpha1); + [sandboxes_api_v1beta1, sandboxes_api_v1alpha1] + } + + async fn get_sandbox_cr_for_owner( + &self, + namespace: &str, + owner: &SandboxOwnerReference, + ) -> Result, KubeError> { + let [sandboxes_api_v1beta1, sandboxes_api_v1alpha1] = self.sandboxes_apis(namespace); + let apis = if owner.api_version == SANDBOX_API_VERSION_FULL_V1ALPHA1 { + [&sandboxes_api_v1alpha1, &sandboxes_api_v1beta1] + } else { + [&sandboxes_api_v1beta1, &sandboxes_api_v1alpha1] + }; + + for api in apis { + match api.get_opt(&owner.name).await { + Ok(Some(sandbox_cr)) => return Ok(Some(sandbox_cr)), + Ok(None) => {} + Err(err) if should_try_next_sandbox_api_version(&err) => {} + Err(err) => return Err(err), + } + } + + Ok(None) + } +} + +#[async_trait] +impl K8sIdentityResolver for LiveK8sResolver { + async fn resolve(&self, token: &str) -> Result, Status> { + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: TokenReviewSpec { + audiences: Some(vec![self.expected_audience.clone()]), + token: Some(token.to_string()), + }, + status: None, + }; + + let review = self + .token_reviews_api + .create(&PostParams::default(), &review) + .await + .map_err(|e| { + warn!(error = %e, "K8s TokenReview failed"); + Status::internal(format!("tokenreview failed: {e}")) + })?; + let status = review + .status + .ok_or_else(|| Status::internal("TokenReview response missing status"))?; + let Some(identity) = token_review_identity( + &status, + &self.expected_audience, + &self.config.service_account_name, + )? + else { + return Ok(None); + }; + if !accepts_auth_namespace( + &self.config, + self.operator_allowlist.as_ref(), + &identity.namespace, + ) { + warn!( + namespace = %identity.namespace, + workspace_mode = %self.config.workspace_mode, + "K8s TokenReview principal namespace is not accepted for supervisor bootstrap" + ); + return Err(Status::permission_denied( + "SA token namespace is not accepted by the driver", + )); + } + + info!( + namespace = %identity.namespace, + pod_name = %identity.pod_name, + pod_uid = %identity.pod_uid, + service_account = %self.config.service_account_name, + "validated K8s SA token via TokenReview" + ); + + // Look up the pod and read its sandbox-id annotation. + let pod = self + .pods_api(&identity.namespace) + .get_opt(&identity.pod_name) + .await + .map_err(|e| { + warn!( + pod = %identity.pod_name, + error = %e, + "failed to fetch sandbox pod for annotation lookup" + ); + Status::internal(format!("pod GET failed: {e}")) + })?; + let Some(pod) = pod else { + warn!( + pod = %identity.pod_name, + "sandbox pod referenced by SA token not found in this namespace" + ); + return Err(Status::not_found("sandbox pod not found")); + }; + + // Defense-in-depth: confirm the pod UID matches the SA token's + // `kubernetes.io.pod.uid`. Prevents a replayed token from a + // recreated pod with the same name. + let actual_uid = pod.metadata.uid.as_deref().unwrap_or_default(); + if actual_uid != identity.pod_uid { + warn!( + pod = %identity.pod_name, + claimed_uid = %identity.pod_uid, + actual_uid = %actual_uid, + "SA token pod UID does not match live pod; rejecting" + ); + return Err(Status::permission_denied("SA token pod UID mismatch")); + } + + let sandbox_id = pod_sandbox_id(&pod); + + let owner = sandbox_owner_reference(&pod)?; + let sandbox_cr = self + .get_sandbox_cr_for_owner(&identity.namespace, &owner) + .await + .map_err(|e| { + warn!( + namespace = %identity.namespace, + pod = %identity.pod_name, + sandbox_owner = %owner.name, + sandbox_owner_api_version = %owner.api_version, + error = %e, + "failed to fetch owning Sandbox CR for pod identity validation" + ); + Status::internal(format!("sandbox GET failed: {e}")) + })?; + let Some(sandbox_cr) = sandbox_cr else { + warn!( + pod = %identity.pod_name, + sandbox_owner = %owner.name, + sandbox_owner_api_version = %owner.api_version, + "pod ownerReference points to a Sandbox CR that does not exist" + ); + return Err(Status::permission_denied("sandbox owner not found")); + }; + validate_sandbox_owner_reference(&owner, &sandbox_cr)?; + if let Some(ref sandbox_id) = sandbox_id { + validate_sandbox_owner_binding(&owner, sandbox_id, &sandbox_cr)?; + } + + let binding = sandbox_id.map_or_else( + || SupervisorBootstrapBinding::WarmPending { + activation_guard: owner.uid.clone(), + }, + |sandbox_id| SupervisorBootstrapBinding::BoundSandbox { sandbox_id }, + ); + + Ok(Some(SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: identity.pod_uid, + binding, + })) + } +} + +#[allow(clippy::result_large_err)] +fn token_review_identity( + status: &TokenReviewStatus, + expected_audience: &str, + expected_service_account: &str, +) -> Result, Status> { + if status.authenticated != Some(true) { + debug!( + error = status.error.as_deref().unwrap_or_default(), + "K8s TokenReview did not authenticate token" + ); + return Ok(None); + } + + let audiences = status.audiences.as_deref().unwrap_or_default(); + if !audiences.iter().any(|aud| aud == expected_audience) { + warn!( + expected_audience = %expected_audience, + audiences = ?audiences, + "K8s TokenReview authenticated token without expected audience" + ); + return Err(Status::unauthenticated("SA token audience not accepted")); + } + + let user = status + .user + .as_ref() + .ok_or_else(|| Status::permission_denied("TokenReview response missing user info"))?; + let username = user + .username + .as_deref() + .ok_or_else(|| Status::permission_denied("TokenReview response missing username"))?; + let rest = username + .strip_prefix("system:serviceaccount:") + .ok_or_else(|| Status::permission_denied("credential is not a service account"))?; + let (namespace, service_account) = rest + .split_once(':') + .filter(|(namespace, service_account)| !namespace.is_empty() && !service_account.is_empty()) + .ok_or_else(|| Status::permission_denied("credential has invalid service account"))?; + if service_account != expected_service_account { + warn!( + username = %username, + namespace = %namespace, + service_account = %expected_service_account, + "K8s TokenReview principal is not the configured sandbox service account" + ); + return Err(Status::permission_denied( + "SA token is not from the configured sandbox service account", + )); + } + + let pod_name = user_extra_one(user, POD_NAME_EXTRA)?; + let pod_uid = user_extra_one(user, POD_UID_EXTRA)?; + Ok(Some(TokenReviewIdentity { + namespace: namespace.to_string(), + pod_name, + pod_uid, + })) +} + +#[allow(clippy::result_large_err)] +fn user_extra_one(user: &UserInfo, key: &str) -> Result { + let Some(values) = user.extra.as_ref().and_then(|extra| extra.get(key)) else { + return Err(Status::permission_denied("SA token is not pod-bound")); + }; + if values.len() != 1 || values[0].is_empty() { + return Err(Status::permission_denied( + "SA token has invalid pod binding", + )); + } + Ok(values[0].clone()) +} + +#[allow(clippy::result_large_err)] +fn pod_sandbox_id(pod: &Pod) -> Option { + pod.metadata + .annotations + .as_ref() + .and_then(|a| a.get(SANDBOX_ID_ANNOTATION)) + .cloned() + .filter(|sandbox_id| !sandbox_id.is_empty()) +} + +#[allow(clippy::result_large_err)] +fn sandbox_owner_reference(pod: &Pod) -> Result { + let owner_refs = pod.metadata.owner_references.as_deref().unwrap_or_default(); + let mut sandbox_refs = owner_refs + .iter() + .filter(|owner| is_supported_sandbox_owner_reference(owner)); + let Some(owner) = sandbox_refs.next() else { + let unsupported_sandbox_api_versions = owner_refs + .iter() + .filter(|owner| owner.kind == SANDBOX_KIND) + .map(|owner| owner.api_version.as_str()) + .collect::>(); + if !unsupported_sandbox_api_versions.is_empty() { + warn!( + api_versions = ?unsupported_sandbox_api_versions, + supported_api_versions = ?[ + SANDBOX_API_VERSION_FULL_V1BETA1, + SANDBOX_API_VERSION_FULL_V1ALPHA1, + ], + "pod Sandbox ownerReference uses unsupported apiVersion" + ); + } + return Err(Status::permission_denied( + "pod is not controlled by an OpenShell Sandbox", + )); + }; + if sandbox_refs.next().is_some() { + return Err(Status::permission_denied( + "pod has multiple OpenShell Sandbox owners", + )); + } + if owner.controller != Some(true) { + return Err(Status::permission_denied( + "pod Sandbox ownerReference is not controlling", + )); + } + if owner.name.is_empty() || owner.uid.is_empty() { + return Err(Status::permission_denied( + "pod Sandbox ownerReference is incomplete", + )); + } + Ok(SandboxOwnerReference { + api_version: owner.api_version.clone(), + name: owner.name.clone(), + uid: owner.uid.clone(), + }) +} + +fn is_supported_sandbox_owner_reference( + owner: &k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference, +) -> bool { + owner.kind == SANDBOX_KIND + && matches!( + owner.api_version.as_str(), + SANDBOX_API_VERSION_FULL_V1BETA1 | SANDBOX_API_VERSION_FULL_V1ALPHA1 + ) +} + +fn should_try_next_sandbox_api_version(err: &KubeError) -> bool { + // Kubernetes returns a structured 404 for some missing API resources and a + // raw "404 page not found" body for others. Both mean the probed + // group/version is unavailable and the next supported Sandbox API version + // should be tried. + matches!(err, KubeError::Api(api) if api.code == 404) +} + +#[allow(clippy::result_large_err)] +fn validate_sandbox_owner_reference( + owner: &SandboxOwnerReference, + sandbox_cr: &DynamicObject, +) -> Result<(), Status> { + let actual_uid = sandbox_cr.metadata.uid.as_deref().unwrap_or_default(); + if actual_uid != owner.uid { + warn!( + sandbox_owner = %owner.name, + owner_uid = %owner.uid, + actual_uid = %actual_uid, + "pod Sandbox ownerReference UID does not match live Sandbox CR" + ); + return Err(Status::permission_denied("sandbox owner UID mismatch")); + } + + Ok(()) +} + +#[allow(clippy::result_large_err)] +fn validate_sandbox_owner_binding( + owner: &SandboxOwnerReference, + sandbox_id: &str, + sandbox_cr: &DynamicObject, +) -> Result<(), Status> { + let actual_sandbox_id = sandbox_cr + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(SANDBOX_ID_LABEL)) + .map(String::as_str) + .unwrap_or_default(); + if actual_sandbox_id != sandbox_id { + warn!( + sandbox_owner = %owner.name, + owner_uid = %owner.uid, + pod_sandbox_id = %sandbox_id, + cr_sandbox_id = %actual_sandbox_id, + "pod sandbox annotation does not match owning Sandbox CR label" + ); + return Err(Status::permission_denied("sandbox owner ID mismatch")); + } + + Ok(()) +} + +#[cfg(test)] +pub mod test_support { + use super::*; + use std::sync::Mutex; + + /// Fake resolver for unit tests. Returns the configured outcome on + /// every call and records the tokens it observed. + pub struct FakeResolver { + pub outcome: Result, Status>, + pub seen_tokens: Mutex>, + } + + impl FakeResolver { + pub fn returning(outcome: Result, Status>) -> Self { + Self { + outcome, + seen_tokens: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl K8sIdentityResolver for FakeResolver { + async fn resolve( + &self, + token: &str, + ) -> Result, Status> { + self.seen_tokens.lock().unwrap().push(token.to_string()); + match &self.outcome { + Ok(opt) => Ok(opt.clone()), + Err(s) => Err(Status::new(s.code(), s.message())), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::FakeResolver; + use super::*; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; + use std::collections::{BTreeMap, BTreeSet}; + use std::sync::Arc; + + fn kube_api_error(code: u16, message: &str) -> KubeError { + KubeError::Api(kube::core::ErrorResponse { + status: if code == 404 { + "404 Not Found".to_string() + } else { + "Failure".to_string() + }, + message: message.to_string(), + reason: "Failed to parse error data".to_string(), + code, + }) + } + + #[test] + fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { + let structured = kube_api_error(404, "could not find the requested resource"); + assert!(should_try_next_sandbox_api_version(&structured)); + + let raw = kube_api_error(404, "404 page not found\n"); + assert!(should_try_next_sandbox_api_version(&raw)); + } + + #[test] + fn sandbox_api_version_probe_keeps_non_404_errors() { + let err = kube_api_error(403, "sandboxes.agents.x-k8s.io is forbidden"); + assert!(!should_try_next_sandbox_api_version(&err)); + } + + fn token_review_status( + authenticated: bool, + audiences: Vec<&str>, + username: &str, + extra: Vec<(&str, &str)>, + ) -> TokenReviewStatus { + TokenReviewStatus { + authenticated: Some(authenticated), + audiences: Some(audiences.into_iter().map(str::to_string).collect()), + error: None, + user: Some(UserInfo { + username: Some(username.to_string()), + uid: Some("sa-uid".to_string()), + groups: Some(vec![ + "system:serviceaccounts".to_string(), + "system:serviceaccounts:openshell".to_string(), + "system:authenticated".to_string(), + ]), + extra: Some( + extra + .into_iter() + .map(|(k, v)| (k.to_string(), vec![v.to_string()])) + .collect::>(), + ), + }), + } + } + + fn sandbox_owner(name: &str, uid: &str) -> OwnerReference { + sandbox_owner_with_api_version(SANDBOX_API_VERSION_FULL_V1BETA1, name, uid) + } + + fn sandbox_owner_with_api_version(api_version: &str, name: &str, uid: &str) -> OwnerReference { + OwnerReference { + api_version: api_version.to_string(), + block_owner_deletion: None, + controller: Some(true), + kind: SANDBOX_KIND.to_string(), + name: name.to_string(), + uid: uid.to_string(), + } + } + + fn pod_with_owner_refs(owner_references: Vec) -> Pod { + Pod { + metadata: ObjectMeta { + owner_references: Some(owner_references), + ..Default::default() + }, + ..Default::default() + } + } + + fn pod_with_sandbox_id(sandbox_id: Option<&str>) -> Pod { + Pod { + metadata: ObjectMeta { + annotations: sandbox_id.map(|id| { + BTreeMap::from([(SANDBOX_ID_ANNOTATION.to_string(), id.to_string())]) + }), + ..Default::default() + }, + ..Default::default() + } + } + + fn bootstrap_identity(binding: SupervisorBootstrapBinding) -> SupervisorBootstrapIdentity { + SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: "uid-a".to_string(), + binding, + } + } + + fn sandbox_cr(name: &str, uid: &str, sandbox_id: &str) -> DynamicObject { + let sandbox_gvk = + GroupVersionKind::gvk(SANDBOX_API_GROUP, SANDBOX_API_VERSION_V1BETA1, SANDBOX_KIND); + let sandbox_resource = ApiResource::from_gvk(&sandbox_gvk); + let mut cr = DynamicObject::new(name, &sandbox_resource); + cr.metadata.uid = Some(uid.to_string()); + cr.metadata.labels = Some(BTreeMap::from([( + SANDBOX_ID_LABEL.to_string(), + sandbox_id.to_string(), + )])); + cr + } + + #[test] + fn token_review_identity_extracts_pod_binding() { + let status = token_review_status( + true, + vec!["openshell-gateway"], + "system:serviceaccount:openshell:default", + vec![ + (POD_NAME_EXTRA, "openshell-sandbox-a"), + (POD_UID_EXTRA, "uid-a"), + ], + ); + + let identity = token_review_identity(&status, "openshell-gateway", "default") + .unwrap() + .expect("authenticated token should resolve"); + + assert_eq!(identity.namespace, "openshell"); + assert_eq!(identity.pod_name, "openshell-sandbox-a"); + assert_eq!(identity.pod_uid, "uid-a"); + } + + #[test] + fn token_review_identity_returns_none_when_not_authenticated() { + let status = TokenReviewStatus { + authenticated: Some(false), + error: Some("invalid audience".to_string()), + ..Default::default() + }; + + assert!( + token_review_identity(&status, "openshell-gateway", "default") + .unwrap() + .is_none() + ); + } + + #[test] + fn token_review_identity_requires_expected_audience() { + let status = token_review_status( + true, + vec!["kubernetes.default.svc"], + "system:serviceaccount:openshell:default", + vec![ + (POD_NAME_EXTRA, "openshell-sandbox-a"), + (POD_UID_EXTRA, "uid-a"), + ], + ); + + let err = token_review_identity(&status, "openshell-gateway", "default") + .expect_err("wrong audience must fail closed"); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn token_review_identity_accepts_namespace_for_policy_validation() { + let status = token_review_status( + true, + vec!["openshell-gateway"], + "system:serviceaccount:other:default", + vec![ + (POD_NAME_EXTRA, "openshell-sandbox-a"), + (POD_UID_EXTRA, "uid-a"), + ], + ); + + let identity = token_review_identity(&status, "openshell-gateway", "default") + .unwrap() + .expect("namespace should be parsed for caller validation"); + assert_eq!(identity.namespace, "other"); + } + + #[test] + fn token_review_identity_requires_configured_service_account() { + let status = token_review_status( + true, + vec!["openshell-gateway"], + "system:serviceaccount:openshell:other", + vec![ + (POD_NAME_EXTRA, "openshell-sandbox-a"), + (POD_UID_EXTRA, "uid-a"), + ], + ); + + let err = token_review_identity(&status, "openshell-gateway", "default") + .expect_err("other service account must be rejected"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn token_review_identity_requires_pod_bound_extras() { + let status = token_review_status( + true, + vec!["openshell-gateway"], + "system:serviceaccount:openshell:default", + vec![], + ); + + let err = token_review_identity(&status, "openshell-gateway", "default") + .expect_err("non pod-bound tokens must be rejected"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn bootstrap_namespace_policy_matches_workspace_modes() { + let mut config = KubernetesComputeConfig { + namespace: "openshell".to_string(), + ..Default::default() + }; + + assert!(accepts_auth_namespace(&config, None, "openshell")); + assert!(!accepts_auth_namespace(&config, None, "team-a")); + + config.workspace_mode = crate::config::WorkspaceMode::Managed; + config.gateway_id = "gw".to_string(); + assert!(accepts_auth_namespace(&config, None, "openshell-gw-team-a")); + assert!(!accepts_auth_namespace( + &config, + None, + "openshell-other-team-a" + )); + + config.workspace_mode = crate::config::WorkspaceMode::Operator; + let allowlist = OperatorNamespaceAllowlist::from_set(BTreeSet::from([ + "team-a".to_string(), + "team-b".to_string(), + ])); + assert!(accepts_auth_namespace(&config, Some(&allowlist), "team-a")); + assert!(!accepts_auth_namespace(&config, Some(&allowlist), "team-c")); + assert!(!accepts_auth_namespace(&config, None, "team-a")); + } + + #[test] + fn pod_sandbox_id_is_optional_for_warm_registration() { + assert_eq!( + pod_sandbox_id(&pod_with_sandbox_id(Some("sandbox-id-a"))).as_deref(), + Some("sandbox-id-a") + ); + assert!(pod_sandbox_id(&pod_with_sandbox_id(None)).is_none()); + } + + #[test] + fn sandbox_owner_reference_extracts_controlling_sandbox_owner() { + let pod = pod_with_owner_refs(vec![sandbox_owner("sandbox-a", "cr-uid-a")]); + + let owner = sandbox_owner_reference(&pod).expect("expected Sandbox owner"); + + assert_eq!( + owner, + SandboxOwnerReference { + api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), + name: "sandbox-a".to_string(), + uid: "cr-uid-a".to_string(), + } + ); + } + + #[test] + fn sandbox_owner_reference_accepts_v1alpha1_owner() { + let pod = pod_with_owner_refs(vec![sandbox_owner_with_api_version( + SANDBOX_API_VERSION_FULL_V1ALPHA1, + "sandbox-a", + "cr-uid-a", + )]); + + let owner = sandbox_owner_reference(&pod).expect("expected v1alpha1 Sandbox owner"); + + assert_eq!( + owner, + SandboxOwnerReference { + api_version: SANDBOX_API_VERSION_FULL_V1ALPHA1.to_string(), + name: "sandbox-a".to_string(), + uid: "cr-uid-a".to_string(), + } + ); + } + + #[test] + fn sandbox_owner_reference_rejects_missing_owner() { + let pod = pod_with_owner_refs(vec![]); + + let err = sandbox_owner_reference(&pod).expect_err("missing owner must fail"); + + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_owner_reference_rejects_unsupported_sandbox_api_version() { + let pod = pod_with_owner_refs(vec![sandbox_owner_with_api_version( + "agents.x-k8s.io/v1", + "sandbox-a", + "cr-uid-a", + )]); + + let err = + sandbox_owner_reference(&pod).expect_err("unsupported apiVersion must fail closed"); + + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_owner_reference_requires_controlling_owner() { + let mut owner = sandbox_owner("sandbox-a", "cr-uid-a"); + owner.controller = Some(false); + let pod = pod_with_owner_refs(vec![owner]); + + let err = sandbox_owner_reference(&pod).expect_err("non-controller owner must fail"); + + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_owner_reference_rejects_ambiguous_sandbox_owners() { + let pod = pod_with_owner_refs(vec![ + sandbox_owner("sandbox-a", "cr-uid-a"), + sandbox_owner("sandbox-b", "cr-uid-b"), + ]); + + let err = sandbox_owner_reference(&pod).expect_err("multiple owners must fail"); + + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn validate_sandbox_owner_reference_requires_matching_cr_uid() { + let owner = SandboxOwnerReference { + api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), + name: "sandbox-a".to_string(), + uid: "cr-uid-a".to_string(), + }; + let cr = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-a"); + validate_sandbox_owner_reference(&owner, &cr).expect("matching CR should be accepted"); + + let wrong_uid = sandbox_cr("sandbox-a", "cr-uid-b", "sandbox-id-a"); + let err = validate_sandbox_owner_reference(&owner, &wrong_uid) + .expect_err("wrong CR UID must fail"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn validate_sandbox_owner_binding_requires_matching_label() { + let owner = SandboxOwnerReference { + api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(), + name: "sandbox-a".to_string(), + uid: "cr-uid-a".to_string(), + }; + let cr = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-a"); + validate_sandbox_owner_binding(&owner, "sandbox-id-a", &cr) + .expect("matching label should be accepted"); + let wrong_label = sandbox_cr("sandbox-a", "cr-uid-a", "sandbox-id-b"); + let err = validate_sandbox_owner_binding(&owner, "sandbox-id-a", &wrong_label) + .expect_err("wrong sandbox-id label must fail"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn provider_delegates_to_resolver() { + let resolved = bootstrap_identity(SupervisorBootstrapBinding::BoundSandbox { + sandbox_id: "sandbox-a".to_string(), + }); + let fake = Arc::new(FakeResolver::returning(Ok(Some(resolved.clone())))); + let provider = KubernetesSupervisorBootstrapIdentityProvider::new(fake.clone()); + + let result = provider + .authenticate_registration("sa-jwt") + .await + .unwrap() + .expect("expected identity"); + + assert_eq!(result, resolved); + assert_eq!(fake.seen_tokens.lock().unwrap().as_slice(), ["sa-jwt"]); + } + + #[tokio::test] + async fn provider_allows_none_to_fall_through() { + let fake = Arc::new(FakeResolver::returning(Ok(None))); + let provider = KubernetesSupervisorBootstrapIdentityProvider::new(fake); + let result = provider.authenticate_registration("unknown").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn provider_accepts_warm_pending_identity() { + let resolved = bootstrap_identity(SupervisorBootstrapBinding::WarmPending { + activation_guard: "cr-uid-a".to_string(), + }); + let fake = Arc::new(FakeResolver::returning(Ok(Some(resolved.clone())))); + let provider = KubernetesSupervisorBootstrapIdentityProvider::new(fake); + let identity = provider + .authenticate_registration("sa-jwt") + .await + .unwrap() + .expect("expected identity"); + + assert_eq!( + identity.binding, + SupervisorBootstrapBinding::WarmPending { + activation_guard: "cr-uid-a".to_string(), + } + ); + } + + #[tokio::test] + async fn resolver_error_propagates() { + let fake = Arc::new(FakeResolver::returning(Err(Status::unavailable( + "apiserver down", + )))); + let provider = KubernetesSupervisorBootstrapIdentityProvider::new(fake); + let err = provider + .authenticate_registration("sa-jwt") + .await + .expect_err("resolver error must propagate"); + assert_eq!(err.code(), tonic::Code::Unavailable); + } +} diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 805c0314b0..da85fcd90b 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -25,6 +25,12 @@ pub const DEFAULT_WORKSPACE_STORAGE_SIZE: &str = "2Gi"; /// Default non-root UID for relaxed Kubernetes network supervisor sidecars. pub const DEFAULT_PROXY_UID: u32 = 1337; +/// Default strict startup SLO threshold for proactive template warm pools. +pub const DEFAULT_WARM_POOL_TEMPLATE_READY_WITHIN_THRESHOLD_SECS: u64 = 5; + +/// Default upper bound for template-driven warm-pool replicas. +pub const DEFAULT_WARM_POOL_TEMPLATE_MAX_REPLICAS: u32 = 20; + /// How the supervisor binary is delivered into sandbox pods. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -177,6 +183,63 @@ impl KubernetesSidecarConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct KubernetesWarmPoolingConfig { + /// Reconcile template-backed warm pools and satisfy compatible create + /// requests by creating v1beta1 Agent Sandbox `SandboxClaim` resources. + pub enabled: bool, + /// Template-driven warm-pool sizing settings. + pub templates: KubernetesWarmPoolTemplatesConfig, +} + +impl Default for KubernetesWarmPoolingConfig { + fn default() -> Self { + Self { + enabled: true, + templates: KubernetesWarmPoolTemplatesConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct KubernetesWarmPoolTemplatesConfig { + /// Strict upper bound, in seconds, for `desired_service_level.startup.ready_within`. + pub ready_within_threshold_secs: u64, + /// Maximum allowed generated warm-pool replicas. + pub max_replicas: u32, +} + +impl Default for KubernetesWarmPoolTemplatesConfig { + fn default() -> Self { + Self { + ready_within_threshold_secs: DEFAULT_WARM_POOL_TEMPLATE_READY_WITHIN_THRESHOLD_SECS, + max_replicas: DEFAULT_WARM_POOL_TEMPLATE_MAX_REPLICAS, + } + } +} + +impl KubernetesWarmPoolTemplatesConfig { + #[must_use] + pub fn effective_max_replicas(&self) -> u32 { + if self.max_replicas == 0 { + DEFAULT_WARM_POOL_TEMPLATE_MAX_REPLICAS + } else { + self.max_replicas + } + } + + #[must_use] + pub fn effective_ready_within_threshold(&self) -> std::time::Duration { + if self.ready_within_threshold_secs == 0 { + std::time::Duration::from_secs(DEFAULT_WARM_POOL_TEMPLATE_READY_WITHIN_THRESHOLD_SECS) + } else { + std::time::Duration::from_secs(self.ready_within_threshold_secs) + } + } +} + /// Kubernetes `AppArmor` profile requested for the sandbox agent container. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppArmorProfile { @@ -345,6 +408,8 @@ pub struct KubernetesComputeConfig { /// Send hostnames rather than validated IPs in CONNECT requests. This is a /// last-resort compatibility mode for hostname-filtering proxy ACLs. pub proxy_connect_by_hostname: Option, + /// Warm-pool allocation settings. + pub warm_pooling: KubernetesWarmPoolingConfig, pub grpc_endpoint: String, pub ssh_socket_path: String, pub client_tls_secret_name: String, @@ -370,9 +435,9 @@ pub struct KubernetesComputeConfig { /// Empty string (default) = omit the field, using the cluster default. pub default_runtime_class_name: String, /// Lifetime (seconds) of the projected `ServiceAccount` token kubelet - /// writes into each sandbox pod. Used only for the one-shot - /// `IssueSandboxToken` bootstrap exchange — the gateway-minted JWT - /// that follows has its own TTL set via `gateway_jwt.ttl_secs`. + /// writes into each sandbox pod. Used only for the + /// `RegisterSupervisor` bootstrap stream — the gateway-minted JWT that + /// follows has its own TTL set via `gateway_jwt.ttl_secs`. /// /// Kubelet enforces a minimum of 600 seconds; the supervisor uses /// this token within a few seconds of pod start, so any value at @@ -457,6 +522,7 @@ impl Default for KubernetesComputeConfig { proxy_auth_secret_key: None, proxy_auth_allow_insecure: None, proxy_connect_by_hostname: None, + warm_pooling: KubernetesWarmPoolingConfig::default(), grpc_endpoint: String::new(), ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), client_tls_secret_name: String::new(), @@ -811,6 +877,23 @@ pub fn managed_namespace_prefix(gateway_id: &str) -> String { format!("openshell-{gateway_id}-") } +#[must_use] +pub fn accepts_auth_namespace( + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + namespace: &str, +) -> bool { + match config.workspace_mode { + WorkspaceMode::Shared => namespace == config.namespace, + WorkspaceMode::Managed => { + namespace.starts_with(&managed_namespace_prefix(&config.gateway_id)) + } + WorkspaceMode::Operator => { + operator_allowlist.is_some_and(|allowlist| allowlist.contains(namespace)) + } + } +} + /// Check whether a string is a valid DNS-1123 label (lowercase alphanumeric /// and hyphens, 1-63 chars, must start and end with alphanumeric). #[must_use] @@ -928,6 +1011,87 @@ mod tests { assert!(cfg.sidecar.process_binary_aware_network_policy); } + #[test] + fn default_warm_pooling_is_enabled() { + let cfg = KubernetesComputeConfig::default(); + assert!(cfg.warm_pooling.enabled); + assert_eq!( + cfg.warm_pooling + .templates + .effective_ready_within_threshold(), + std::time::Duration::from_secs(5) + ); + assert_eq!(cfg.warm_pooling.templates.effective_max_replicas(), 20); + } + + #[test] + fn serde_override_warm_pooling_enabled() { + let json = serde_json::json!({ + "warm_pooling": { + "enabled": false + } + }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert!(!cfg.warm_pooling.enabled); + } + + #[test] + fn serde_override_warm_pool_template_settings() { + let json = serde_json::json!({ + "warm_pooling": { + "templates": { + "ready_within_threshold_secs": 3, + "max_replicas": 7 + } + } + }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert_eq!( + cfg.warm_pooling + .templates + .effective_ready_within_threshold(), + std::time::Duration::from_secs(3) + ); + assert_eq!(cfg.warm_pooling.templates.max_replicas, 7); + } + + #[test] + fn serde_rejects_removed_warm_pool_template_enabled() { + let json = serde_json::json!({ + "warm_pooling": { + "templates": { + "enabled": false + } + } + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn serde_rejects_removed_warm_pool_configmap_reconciliation() { + let json = serde_json::json!({ + "warm_pooling": { + "profiles": { + "enabled": true + } + } + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn serde_rejects_unknown_warm_pooling_field() { + let json = serde_json::json!({ + "warm_pooling": { + "mode": "always" + } + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + #[test] fn serde_override_topology_sidecar() { let json = serde_json::json!({ diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 1e790bf348..c7dc6ffc95 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -8,7 +8,21 @@ use crate::config::{ DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_SANDBOX_UID, DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, OperatorNamespaceAllowlist, SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, is_dns_1123_label, - managed_namespace, managed_namespace_prefix, validate_managed_namespace_name, + managed_namespace, validate_managed_namespace_name, +}; +use crate::extension_api::{ + EXTENSIONS_GROUP, EXTENSIONS_VERSION_V1BETA1, ExtensionApiDiscoveryCache, +}; +#[cfg(test)] +use crate::extension_api::{ + ExtensionApiAvailability, SANDBOX_CLAIM_KIND, SANDBOX_TEMPLATE_KIND, SANDBOX_WARM_POOL_KIND, +}; +#[cfg(test)] +#[allow(clippy::wildcard_imports)] +use crate::warm_pool::*; +use crate::warm_pool::{ + AllocationDecision, WarmActivationSupport, WarmPoolManager, dynamic_sandbox_claim_watcher, + sandbox_from_claim_object, update_claim_indexes, }; use futures::{Stream, StreamExt, TryStreamExt}; use k8s_openapi::api::authentication::v1::{ @@ -43,16 +57,22 @@ use openshell_core::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, }; +#[cfg(test)] +use openshell_core::proto::compute::v1::DriverSandboxTemplateStartup; use openshell_core::proto::compute::v1::{ CpuResourceCapabilities, DriverCondition as SandboxCondition, DriverPlatformEvent as PlatformEvent, DriverSandbox as Sandbox, DriverSandboxSpec as SandboxSpec, DriverSandboxStatus as SandboxStatus, - DriverSandboxTemplate as SandboxTemplate, GetCapabilitiesResponse, GpuResourceCapabilities, + DriverSandboxTemplate as SandboxTemplate, DriverSandboxTemplateRef, + DriverSandboxTemplateResource, GetCapabilitiesResponse, GpuResourceCapabilities, GpuResourceRequirements, MemoryResourceCapabilities, ResourceCapabilities, WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::{struct_to_json_object, value_to_json}; +use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapBinding, SupervisorBootstrapIdentity, +}; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Path, PathBuf}; @@ -61,7 +81,7 @@ use std::sync::Arc; use std::time::{Duration, SystemTime}; use tokio::sync::{OnceCell, mpsc}; use tokio_stream::wrappers::ReceiverStream; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; pub type WatchStream = Pin> + Send>>; @@ -80,6 +100,8 @@ pub enum KubernetesDriverError { #[error("{0}")] Precondition(String), #[error("{0}")] + Unavailable(String), + #[error("{0}")] Message(String), } @@ -99,6 +121,7 @@ impl From for openshell_core::ComputeDriverError { KubernetesDriverError::NotFound => Self::NotFound, KubernetesDriverError::InvalidArgument(m) => Self::InvalidArgument(m), KubernetesDriverError::Precondition(m) => Self::Precondition(m), + KubernetesDriverError::Unavailable(m) => Self::Unavailable(m), KubernetesDriverError::Message(m) => Self::Message(m), } } @@ -107,7 +130,7 @@ impl From for openshell_core::ComputeDriverError { /// Timeout for individual Kubernetes API calls (create, delete, get). /// This prevents gRPC handlers from blocking indefinitely when the k8s /// API server is unreachable or slow. -const KUBE_API_TIMEOUT: Duration = Duration::from_secs(30); +pub(super) const KUBE_API_TIMEOUT: Duration = Duration::from_secs(30); /// Kubernetes defaults pod termination to 30 seconds when the pod template /// omits `terminationGracePeriodSeconds`. @@ -371,7 +394,7 @@ fn is_dns_subdomain(value: &str) -> bool { value.len() <= 253 && value.split('.').all(is_dns_1123_label) } -fn validate_kubernetes_dns1123_label(value: &str, field: &str) -> Result<(), String> { +pub(super) fn validate_kubernetes_dns1123_label(value: &str, field: &str) -> Result<(), String> { if !is_dns_1123_label(value) { return Err(format!( "{field} must be a DNS-1123 label: use lowercase alphanumeric characters or '-', start and end with an alphanumeric character, and use at most 63 characters" @@ -461,6 +484,7 @@ pub struct KubernetesComputeDriver { client: Client, watch_client: Client, sandbox_api_version: Arc>, + warm_pools: WarmPoolManager, config: KubernetesComputeConfig, operator_allowlist: Option, } @@ -488,6 +512,10 @@ impl KubernetesComputeDriver { client: client.clone(), watch_client: client, sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), config, operator_allowlist: None, } @@ -496,6 +524,15 @@ impl KubernetesComputeDriver { pub async fn new( config: KubernetesComputeConfig, shutdown_rx: tokio::sync::watch::Receiver, + ) -> Result { + Self::new_with_activation_support(config, shutdown_rx, WarmActivationSupport::Available) + .await + } + + pub async fn new_with_activation_support( + config: KubernetesComputeConfig, + shutdown_rx: tokio::sync::watch::Receiver, + warm_activation_support: WarmActivationSupport, ) -> Result { config .validate_workspace_mode() @@ -558,44 +595,66 @@ impl KubernetesComputeDriver { client, watch_client, sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::default(), + warm_activation_support, + ), config, operator_allowlist, }; - if driver.workspace_mode() == WorkspaceMode::Shared { driver.backfill_gateway_id_labels().await?; } + if driver.warm_pooling_configured() { + driver.warm_pools.spawn_cache_controller( + driver.client.clone(), + driver.watch_client.clone(), + &driver.config, + ); + } Ok(driver) } pub fn capabilities(&self) -> Result { - Ok(GetCapabilitiesResponse { - driver_name: "kubernetes".to_string(), - driver_version: openshell_core::VERSION.to_string(), - default_image: self.config.default_image.clone(), - gateway_manages_lifecycle: false, - supports_sandbox_authentication: true, - driver_reports_runtime_readiness: false, - resource_capabilities: Some(ResourceCapabilities { - cpu: Some(CpuResourceCapabilities { - limit_supported: true, - }), - memory: Some(MemoryResourceCapabilities { - limit_supported: true, - }), - gpu: Some(GpuResourceCapabilities { - default_selection_supported: true, - count_selection_supported: true, - }), + // Capabilities describe structural driver support. Extension API presence + // is discovered dynamically by each operation so CRDs installed after + // gateway startup become usable without a restart. + let supports_warm_supervisor_bootstrap = self.warm_pools.supports_activation(); + let supports_sandbox_template_reconciliation = true; + let mut capabilities = + openshell_core::driver_utils::build_capabilities_response_with_template_reconciliation( + "kubernetes", + openshell_core::VERSION, + &self.config.default_image, + supports_sandbox_template_reconciliation, + ); + capabilities.supports_sandbox_authentication = true; + capabilities.supports_warm_supervisor_bootstrap = supports_warm_supervisor_bootstrap; + capabilities.resource_capabilities = Some(ResourceCapabilities { + cpu: Some(CpuResourceCapabilities { + limit_supported: true, }), - rootfs_tar_staging_dir: String::new(), - rootfs_tar_max_bytes: 0, - }) + memory: Some(MemoryResourceCapabilities { + limit_supported: true, + }), + gpu: Some(GpuResourceCapabilities { + default_selection_supported: true, + count_selection_supported: true, + }), + }); + Ok(capabilities) + } + + fn warm_pooling_configured(&self) -> bool { + self.warm_pools.allocation_enabled(&self.config) } /// Authenticate the projected `ServiceAccount` token used by a sandbox pod. - pub async fn authenticate_sandbox(&self, credential: &str) -> Result { + pub async fn authenticate_sandbox( + &self, + credential: &str, + ) -> Result { let reviews: Api = Api::all(self.client.clone()); let review = TokenReview { metadata: ObjectMeta::default(), @@ -635,20 +694,38 @@ impl KubernetesComputeDriver { tonic::Status::permission_denied("authenticated sandbox pod not found") })?; validate_pod_uid(&pod, &identity.pod_uid)?; - let sandbox_id = pod_sandbox_id(&pod)?; + let sandbox_id = pod_sandbox_id(&pod); let owner = sandbox_owner_reference(&pod)?; let sandboxes = self - .supported_agent_sandbox_api(self.client.clone(), &identity.namespace) + .supported_agent_sandbox_api_for_namespace(self.client.clone(), &identity.namespace) .await .map_err(|error| { tonic::Status::internal(format!("failed to select Sandbox API: {error}")) })?; - let sandbox = sandboxes.api.get_opt(&owner.name).await.map_err(|error| { - warn!(sandbox = %owner.name, %error, "failed to read authenticated Sandbox resource"); - tonic::Status::internal("failed to read authenticated Sandbox resource") - })?.ok_or_else(|| tonic::Status::permission_denied("sandbox owner not found"))?; - validate_sandbox_owner_identity(owner, &sandbox_id, &sandbox)?; - Ok(sandbox_id) + let sandbox = sandboxes + .api + .get_opt(&owner.name) + .await + .map_err(|error| { + warn!(sandbox = %owner.name, %error, "failed to read authenticated Sandbox resource"); + tonic::Status::internal("failed to read authenticated Sandbox resource") + })? + .ok_or_else(|| tonic::Status::permission_denied("sandbox owner not found"))?; + validate_sandbox_owner_reference(owner, &sandbox)?; + if let Some(ref sandbox_id) = sandbox_id { + validate_sandbox_owner_binding(owner, sandbox_id, &sandbox)?; + } + let binding = sandbox_id.map_or_else( + || SupervisorBootstrapBinding::WarmPending { + activation_guard: owner.uid.clone(), + }, + |sandbox_id| SupervisorBootstrapBinding::BoundSandbox { sandbox_id }, + ); + Ok(SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: identity.pod_uid, + binding, + }) } fn accepts_auth_namespace(&self, namespace: &str) -> bool { @@ -659,6 +736,22 @@ impl KubernetesComputeDriver { self.operator_allowlist.as_ref() } + pub(crate) fn extension_api_discovery_cache(&self) -> ExtensionApiDiscoveryCache { + self.warm_pools.discovery_cache() + } + + pub fn client(&self) -> Client { + self.client.clone() + } + + pub fn watch_client(&self) -> Client { + self.watch_client.clone() + } + + pub fn config(&self) -> &KubernetesComputeConfig { + &self.config + } + pub fn default_image(&self) -> &str { &self.config.default_image } @@ -1102,6 +1195,19 @@ impl KubernetesComputeDriver { Ok(()) } + pub async fn reconcile_sandbox_templates( + &self, + templates: &[DriverSandboxTemplateResource], + ) -> Result<(u32, u32), KubernetesDriverError> { + self.warm_pools + .reconcile_templates( + &self.client, + &self.config, + self.operator_allowlist.as_ref(), + templates, + ) + .await + } fn validate_driver_config_for_sandbox( &self, sandbox: &Sandbox, @@ -1118,8 +1224,8 @@ impl KubernetesComputeDriver { fn agent_sandbox_api( client: Client, - sandbox_api_version: &str, namespace: &str, + sandbox_api_version: &str, ) -> AgentSandboxApi { let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, sandbox_api_version, SANDBOX_KIND); let resource = ApiResource::from_gvk(&gvk); @@ -1134,31 +1240,20 @@ impl KubernetesComputeDriver { AgentSandboxApi { api, resource } } - async fn supported_agent_sandbox_api( - &self, - client: Client, - namespace: &str, - ) -> Result { - let sandbox_api_version = self.supported_sandbox_api_version(client.clone()).await?; - Ok(Self::agent_sandbox_api( - client, - sandbox_api_version, - namespace, - )) - } - async fn supported_sandbox_api_for_lookup( &self, client: Client, ) -> Result { - let sandbox_api_version = self.supported_sandbox_api_version(client.clone()).await?; + let sandbox_api_version = self + .supported_sandbox_api_version(client.clone(), &self.config.namespace) + .await?; if self.config.is_multi_namespace() { Ok(Self::cluster_wide_sandbox_api(client, sandbox_api_version)) } else { Ok(Self::agent_sandbox_api( client, - sandbox_api_version, &self.config.namespace, + sandbox_api_version, )) } } @@ -1171,11 +1266,36 @@ impl KubernetesComputeDriver { openshell_sandbox_selector_for(&self.config.gateway_id) } - async fn supported_sandbox_api_version(&self, client: Client) -> Result<&'static str, String> { + async fn supported_agent_sandbox_api(&self, client: Client) -> Result { + self.supported_agent_sandbox_api_for_namespace(client, &self.config.namespace) + .await + } + + async fn supported_agent_sandbox_api_for_namespace( + &self, + client: Client, + namespace: &str, + ) -> Result { + let sandbox_api_version = self + .supported_sandbox_api_version(client.clone(), namespace) + .await?; + Ok(Self::agent_sandbox_api( + client, + namespace, + sandbox_api_version, + )) + } + + async fn supported_sandbox_api_version( + &self, + client: Client, + namespace: &str, + ) -> Result<&'static str, String> { self.sandbox_api_version - .get_or_try_init( - || async move { self.detect_supported_sandbox_api_version(client).await }, - ) + .get_or_try_init(|| async move { + self.detect_supported_sandbox_api_version(client, namespace) + .await + }) .await .copied() } @@ -1183,13 +1303,11 @@ impl KubernetesComputeDriver { async fn detect_supported_sandbox_api_version( &self, client: Client, + namespace: &str, ) -> Result<&'static str, String> { for sandbox_api_version in SANDBOX_VERSIONS { - let agent_sandbox_api = Self::agent_sandbox_api( - client.clone(), - sandbox_api_version, - &self.config.namespace, - ); + let agent_sandbox_api = + Self::agent_sandbox_api(client.clone(), namespace, sandbox_api_version); match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&ListParams::default().limit(1)), @@ -1198,7 +1316,7 @@ impl KubernetesComputeDriver { { Ok(Ok(_)) => { debug!( - namespace = %self.config.namespace, + namespace, sandbox_api_version = %sandbox_api_version, "Selected Agent Sandbox API version" ); @@ -1206,7 +1324,7 @@ impl KubernetesComputeDriver { } Ok(Err(err)) if should_try_next_sandbox_api_version(&err) => { debug!( - namespace = %self.config.namespace, + namespace, sandbox_api_version = %sandbox_api_version, error = %err, "Sandbox API version is not available; trying next supported version" @@ -1227,63 +1345,19 @@ impl KubernetesComputeDriver { )) } - async fn resolve_sandbox_identity_in_namespace( + /// Resolve sandbox UID/GID from config or `OpenShift` SCC namespace annotations. + /// + /// Returns `(uid, gid, ns_annotations_map)`: + /// - If `sandbox_uid` is set in config, returns that (with fallback GID) + /// - Otherwise fetches the target namespace and checks for + /// `openshift.io/sa.scc.uid-range` / `openshift.io/sa.scc.supplemental-groups` + /// annotations. + /// - If neither config nor `OpenShift` is found, returns `(1000, 1000, {})` as defaults. + async fn resolve_sandbox_identity( &self, namespace: &str, ) -> (u32, u32, BTreeMap) { - if self.config.sandbox_uid.is_some() { - let uid = self.config.resolve_sandbox_uid(None); - let gid = self.config.resolve_sandbox_gid(uid, None); - return (uid, gid, BTreeMap::new()); - } - - let ns_api: Api = Api::all(self.client.clone()); - match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(namespace)).await { - Ok(Ok(ns)) => { - let anns = ns.metadata.annotations.unwrap_or_default(); - tracing::info!( - namespace = %namespace, - uid_range = ?anns.get(crate::config::ANNOTATION_SCC_UID_RANGE), - sup_groups = ?anns.get(crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS), - "Resolved namespace annotations for sandbox identity" - ); - let uid = self.config.resolve_sandbox_uid(Some(&anns)); - let baseline_gid = self.config.resolve_sandbox_gid(uid, None); - let gid = self.config.sandbox_gid.map_or_else( - || { - anns.get(crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS) - .and_then(|sup_range| { - KubernetesComputeConfig::from_open_shift_supplemental_groups( - sup_range, - ) - }) - .unwrap_or(baseline_gid) - }, - |_| baseline_gid, - ); - tracing::info!(uid, gid, "Resolved sandbox identity"); - (uid, gid, anns) - } - Ok(Err(e)) => { - tracing::warn!( - namespace = %namespace, - error = %e, - "Failed to fetch namespace for SCC annotations, falling back to defaults" - ); - let uid = DEFAULT_SANDBOX_UID; - let gid = self.config.resolve_sandbox_gid(uid, None); - (uid, gid, BTreeMap::new()) - } - Err(_) => { - tracing::warn!( - namespace = %namespace, - "Namespace fetch timed out, falling back to defaults" - ); - let uid = DEFAULT_SANDBOX_UID; - let gid = self.config.resolve_sandbox_gid(uid, None); - (uid, gid, BTreeMap::new()) - } - } + resolve_sandbox_identity_for_config(self.client.clone(), &self.config, namespace).await } async fn has_gpu_capacity(&self) -> Result { @@ -1340,20 +1414,19 @@ impl KubernetesComputeDriver { let selector = self.sandbox_lookup_selector(sandbox_id); let lp = ListParams::default().labels(&selector); match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { - Ok(Ok(list)) => list.items.into_iter().next().map_or_else( - || { - debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes"); - Ok(None) - }, - |obj| { + Ok(Ok(list)) => { + if let Some(obj) = list.items.into_iter().next() { let ns = obj .metadata .namespace .clone() .unwrap_or_else(|| self.config.namespace.clone()); Ok(sandbox_from_object(&ns, obj).ok().map(|(_, s)| s)) - }, - ), + } else { + debug!(sandbox_id = %sandbox_id, "Sandbox not found in Kubernetes"); + self.get_claim_backed_sandbox(sandbox_id).await + } + } Ok(Err(err)) => { warn!( sandbox_id = %sandbox_id, @@ -1414,6 +1487,13 @@ impl KubernetesComputeDriver { } }) .collect(); + let mut claim_sandboxes = self.list_claim_backed_sandboxes().await?; + let mut claim_ids = claim_sandboxes + .iter() + .map(|sandbox| sandbox.id.clone()) + .collect::>(); + sandboxes.retain(|sandbox| !claim_ids.remove(&sandbox.id)); + sandboxes.append(&mut claim_sandboxes); sandboxes.sort_by(|left, right| { left.name .cmp(&right.name) @@ -1441,10 +1521,30 @@ impl KubernetesComputeDriver { } } + async fn list_claim_backed_sandboxes(&self) -> Result, String> { + let selector = self.openshell_sandbox_selector(); + self.warm_pools + .list_claims(&self.client, &self.config, &selector) + .await + } + + async fn get_claim_backed_sandbox(&self, sandbox_id: &str) -> Result, String> { + let selector = self.sandbox_lookup_selector(sandbox_id); + self.warm_pools + .get_claim(&self.client, &self.config, &selector) + .await + } + + async fn delete_sandbox_claim(&self, sandbox_id: &str) -> Result { + let selector = self.sandbox_lookup_selector(sandbox_id); + self.warm_pools + .delete_claim(&self.client, &self.config, sandbox_id, &selector) + .await + } #[allow(clippy::similar_names)] #[tracing::instrument( - name = "kubernetes.provision", - skip(self, sandbox), + name = "kubernetes.create_sandbox", + skip(self, sandbox, sandbox_template), fields( otel.name = "kubernetes.provision", otel.status_code = tracing::field::Empty, @@ -1452,14 +1552,22 @@ impl KubernetesComputeDriver { sandbox.name = %sandbox.name, ) )] - pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { + pub async fn create_sandbox( + &self, + sandbox: &Sandbox, + sandbox_template: Option<&DriverSandboxTemplateRef>, + ) -> Result<(), KubernetesDriverError> { let span_status = openshell_otel::ErrorStatusGuard::current(); - let result = self.create_sandbox_inner(sandbox).await; + let result = self.create_sandbox_inner(sandbox, sandbox_template).await; span_status.finish(result) } #[allow(clippy::similar_names)] - async fn create_sandbox_inner(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { + async fn create_sandbox_inner( + &self, + sandbox: &Sandbox, + sandbox_template: Option<&DriverSandboxTemplateRef>, + ) -> Result<(), KubernetesDriverError> { let gpu_requirements = sandbox .spec .as_ref() @@ -1509,14 +1617,13 @@ impl KubernetesComputeDriver { ); let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone(), &target_namespace) + .supported_agent_sandbox_api_for_namespace(self.client.clone(), &target_namespace) .await .map_err(KubernetesDriverError::Message)?; // Resolve sandbox UID/GID from config or OpenShift SCC namespace annotations. - let (resolved_user_id, resolved_group_id, ns_annotations) = self - .resolve_sandbox_identity_in_namespace(&target_namespace) - .await; + let (resolved_user_id, resolved_group_id, ns_annotations) = + self.resolve_sandbox_identity(&target_namespace).await; let params = SandboxPodParams { default_image: &self.config.default_image, @@ -1561,6 +1668,32 @@ impl KubernetesComputeDriver { let data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms) .map_err(KubernetesDriverError::InvalidArgument)?; + if self.warm_pooling_configured() { + if let Some(sandbox_template) = sandbox_template { + if self + .warm_pools + .try_allocate( + &self.client, + &self.config, + sandbox, + &target_namespace, + sandbox_template, + &data, + ) + .await? + == AllocationDecision::Claimed + { + return Ok(()); + } + } else { + info!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + "Sandbox create did not include a workload-template reference; falling back to direct Sandbox" + ); + } + } let kube_name = self.config.kube_resource_name(workspace, name); let mut obj = DynamicObject::new(&kube_name, &agent_sandbox_api.resource); let mut annotations = sandbox_annotations(sandbox); @@ -1732,11 +1865,13 @@ impl KubernetesComputeDriver { )) })? .map_err(KubernetesDriverError::from_kube)?; - let object = list - .items - .into_iter() - .next() - .ok_or(KubernetesDriverError::NotFound)?; + let object = match list.items.into_iter().next() { + Some(object) => object, + None => self + .claim_selected_sandbox(sandbox_id, &lookup_api.resource.version) + .await? + .ok_or(KubernetesDriverError::NotFound)?, + }; let namespace = object .metadata .namespace @@ -1744,8 +1879,8 @@ impl KubernetesComputeDriver { .unwrap_or_else(|| self.config.namespace.clone()); let agent_sandbox_api = Self::agent_sandbox_api( self.client.clone(), - &lookup_api.resource.version, &namespace, + &lookup_api.resource.version, ); let stop_timeout = kubernetes_sandbox_stop_timeout(&object); let kube_name = object.metadata.name.ok_or_else(|| { @@ -1796,6 +1931,49 @@ impl KubernetesComputeDriver { )) } + async fn claim_selected_sandbox( + &self, + sandbox_id: &str, + sandbox_api_version: &str, + ) -> Result, KubernetesDriverError> { + let Some(claim) = self + .get_claim_backed_sandbox(sandbox_id) + .await + .map_err(KubernetesDriverError::Message)? + else { + return Ok(None); + }; + let selected_name = claim + .status + .as_ref() + .map(|status| status.sandbox_name.as_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + KubernetesDriverError::Precondition(format!( + "SandboxClaim for sandbox {sandbox_id} has not selected a Sandbox yet" + )) + })?; + let api = + Self::agent_sandbox_api(self.client.clone(), &claim.namespace, sandbox_api_version); + let mut object = tokio::time::timeout(KUBE_API_TIMEOUT, api.api.get(selected_name)) + .await + .map_err(|_| { + KubernetesDriverError::Message(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )) + })? + .map_err(|err| match err { + KubeError::Api(response) if response.code == 404 => KubernetesDriverError::NotFound, + other => KubernetesDriverError::from_kube(other), + })?; + object + .metadata + .namespace + .get_or_insert_with(|| claim.namespace.clone()); + Ok(Some(object)) + } + #[tracing::instrument( name = "kubernetes.delete_sandbox", skip(self), @@ -1818,6 +1996,10 @@ impl KubernetesComputeDriver { "Deleting sandbox from Kubernetes" ); + if self.delete_sandbox_claim(sandbox_id).await? { + return Ok(true); + } + let lookup_api = self .supported_sandbox_api_for_lookup(self.client.clone()) .await?; @@ -1879,7 +2061,7 @@ impl KubernetesComputeDriver { }; let delete_api = self - .supported_agent_sandbox_api(self.client.clone(), &obj_namespace) + .supported_agent_sandbox_api_for_namespace(self.client.clone(), &obj_namespace) .await?; let dp = DeleteParams::default().preconditions(preconditions); match tokio::time::timeout(KUBE_API_TIMEOUT, delete_api.api.delete(&kube_name, &dp)).await { @@ -1920,7 +2102,12 @@ impl KubernetesComputeDriver { let selector = self.sandbox_lookup_selector(sandbox_id); let lp = ListParams::default().labels(&selector); match tokio::time::timeout(KUBE_API_TIMEOUT, agent_sandbox_api.api.list(&lp)).await { - Ok(Ok(list)) => Ok(!list.items.is_empty()), + Ok(Ok(list)) => { + if !list.items.is_empty() { + return Ok(true); + } + Ok(self.get_claim_backed_sandbox(sandbox_id).await?.is_some()) + } Ok(Err(err)) => Err(err.to_string()), Err(_elapsed) => Err(format!( "timed out after {}s waiting for Kubernetes API", @@ -1942,7 +2129,7 @@ impl KubernetesComputeDriver { async fn watch_sandboxes_single_namespace(&self) -> Result { let namespace = self.config.namespace.clone(); let agent_sandbox_api = self - .supported_agent_sandbox_api(self.watch_client.clone(), &self.config.namespace) + .supported_agent_sandbox_api(self.watch_client.clone()) .await?; let event_api: Api = Api::namespaced(self.watch_client.clone(), &namespace); let watcher_config = watcher::Config::default().labels(&openshell_sandbox_label_selector()); @@ -1951,6 +2138,14 @@ impl KubernetesComputeDriver { "sandbox-resource", ) .boxed(); + let claim_selector = self.openshell_sandbox_selector(); + let mut claim_stream = dynamic_sandbox_claim_watcher( + self.watch_client.clone(), + self.warm_pools.discovery_cache(), + namespace.clone(), + false, + claim_selector, + ); let mut event_stream = recovering_watcher_stream( watcher::watcher(event_api, watcher::Config::default()), "kubernetes-event", @@ -2001,6 +2196,49 @@ impl KubernetesComputeDriver { break; } }, + event = claim_stream.next() => match event { + Some(Event::Apply(obj) | Event::InitApply(obj)) => { + let claim_name = obj.metadata.name.clone().unwrap_or_default(); + if let Ok(sandbox) = sandbox_from_claim_object(&namespace, obj) { + update_claim_indexes( + &mut sandbox_name_to_id, + &mut agent_pod_to_id, + &claim_name, + &sandbox, + ); + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Delete(obj)) => { + if is_openshell_managed(&obj) + && let Ok(sandbox_id) = sandbox_id_from_object(&obj) + { + remove_indexes(&mut sandbox_name_to_id, &mut agent_pod_to_id, &sandbox_id); + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Init | Event::InitDone) => {} + None => { + let _ = tx.send(Err(KubernetesDriverError::Message( + "sandbox claim watcher stream ended unexpectedly".to_string() + ))).await; + break; + } + }, event = event_stream.next() => match event { Some(Event::Apply(obj)) => { if let Some((sandbox_id, event)) = map_kube_event_to_platform( @@ -2039,7 +2277,7 @@ impl KubernetesComputeDriver { async fn watch_sandboxes_cluster_wide(&self) -> Result { let sandbox_api_version = self - .supported_sandbox_api_version(self.watch_client.clone()) + .supported_sandbox_api_version(self.watch_client.clone(), &self.config.namespace) .await?; let cluster_api = Self::cluster_wide_sandbox_api(self.watch_client.clone(), sandbox_api_version); @@ -2050,17 +2288,30 @@ impl KubernetesComputeDriver { "sandbox-resource", ) .boxed(); + let claim_stream = dynamic_sandbox_claim_watcher( + self.watch_client.clone(), + self.warm_pools.discovery_cache(), + self.config.namespace.clone(), + true, + selector, + ); Ok(cluster_wide_watch_stream( sandbox_stream, + claim_stream, self.config.namespace.clone(), )) } } -fn cluster_wide_watch_stream(mut sandbox_stream: S, default_namespace: String) -> WatchStream +fn cluster_wide_watch_stream( + mut sandbox_stream: S, + mut claim_stream: C, + default_namespace: String, +) -> WatchStream where S: Stream> + Send + Unpin + 'static, + C: Stream> + Send + Unpin + 'static, { let (tx, rx) = mpsc::channel(256); @@ -2104,6 +2355,41 @@ where break; } }, + event = claim_stream.next() => match event { + Some(Event::Apply(obj) | Event::InitApply(obj)) => { + if let Ok(sandbox) = sandbox_from_claim_object(&default_namespace, obj) { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { sandbox: Some(sandbox) } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Delete(obj)) => { + if is_openshell_managed(&obj) + && let Ok(sandbox_id) = sandbox_id_from_object(&obj) + { + let event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id } + )), + }; + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Some(Event::Init | Event::InitDone) => {} + None => { + let _ = tx.send(Err(KubernetesDriverError::Message( + "sandbox claim watcher stream ended unexpectedly".to_string() + ))).await; + break; + } + }, () = tx.closed() => break, } } @@ -2158,10 +2444,69 @@ fn add_trace_context_annotation(annotations: &mut BTreeMap) { } } -fn should_try_next_sandbox_api_version(err: &KubeError) -> bool { - // Kubernetes returns a structured 404 for some missing API resources and a - // raw "404 page not found" body for others. Both mean the probed - // group/version is unavailable and the next supported Sandbox API version +pub(super) async fn resolve_sandbox_identity_for_config( + client: Client, + config: &KubernetesComputeConfig, + namespace: &str, +) -> (u32, u32, BTreeMap) { + // Explicit config takes priority — skip namespace lookup entirely. + if config.sandbox_uid.is_some() { + let uid = config.resolve_sandbox_uid(None); + let gid = config.resolve_sandbox_gid(uid, None); + return (uid, gid, BTreeMap::new()); + } + + let ns_api: Api = Api::all(client); + match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(namespace)).await { + Ok(Ok(ns)) => { + let anns = ns.metadata.annotations.unwrap_or_default(); + tracing::info!( + namespace, + uid_range = ?anns.get(crate::config::ANNOTATION_SCC_UID_RANGE), + sup_groups = ?anns.get(crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS), + "Resolved namespace annotations for sandbox identity" + ); + let uid = config.resolve_sandbox_uid(Some(&anns)); + let baseline_gid = config.resolve_sandbox_gid(uid, None); + let gid = config.sandbox_gid.map_or_else( + || { + anns.get(crate::config::ANNOTATION_SCC_SUPPLEMENTAL_GROUPS) + .and_then(|sup_range| { + KubernetesComputeConfig::from_open_shift_supplemental_groups(sup_range) + }) + .unwrap_or(baseline_gid) + }, + |_| baseline_gid, + ); + tracing::info!(uid, gid, "Resolved sandbox identity"); + (uid, gid, anns) + } + Ok(Err(e)) => { + tracing::warn!( + namespace, + error = %e, + "Failed to fetch namespace for SCC annotations, falling back to defaults" + ); + let uid = DEFAULT_SANDBOX_UID; + let gid = config.resolve_sandbox_gid(uid, None); + (uid, gid, BTreeMap::new()) + } + Err(_) => { + tracing::warn!( + namespace, + "Namespace fetch timed out, falling back to defaults" + ); + let uid = DEFAULT_SANDBOX_UID; + let gid = config.resolve_sandbox_gid(uid, None); + (uid, gid, BTreeMap::new()) + } + } +} + +fn should_try_next_sandbox_api_version(err: &KubeError) -> bool { + // Kubernetes returns a structured 404 for some missing API resources and a + // raw "404 page not found" body for others. Both mean the probed + // group/version is unavailable and the next supported Sandbox API version // should be tried. matches!(err, KubeError::Api(api) if api.code == 404) } @@ -2174,7 +2519,7 @@ fn validate_gpu_request( Ok(()) } -const MAX_KUBE_NAME_LEN: usize = 63; +pub(super) const MAX_KUBE_NAME_LEN: usize = 63; fn validate_kube_resource_name_length(workspace: &str, name: &str) -> Result<(), tonic::Status> { let combined = workspace.len() + 2 + name.len(); // "--" separator @@ -2228,7 +2573,10 @@ fn openshell_sandbox_selector_for(gateway_id: &str) -> String { selector } -fn sandbox_labels(sandbox: &Sandbox, gateway_id: Option<&str>) -> BTreeMap { +pub(super) fn sandbox_labels( + sandbox: &Sandbox, + gateway_id: Option<&str>, +) -> BTreeMap { let mut labels = BTreeMap::new(); labels.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); labels.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); @@ -2309,7 +2657,7 @@ fn image_pull_secret_copy(secret_name: &str, namespace: &str, source: Secret) -> } } -fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { +pub(super) fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { let mut annotations = BTreeMap::new(); annotations.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); annotations.insert(LABEL_SANDBOX_NAME.to_string(), sandbox.name.clone()); @@ -2320,7 +2668,44 @@ fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { annotations } -fn sandbox_id_from_object(obj: &DynamicObject) -> Result { +pub(super) fn kube_error_code(err: &KubeError) -> Option { + match err { + KubeError::Api(api) => Some(api.code), + _ => None, + } +} + +pub(super) fn watcher_error_code(err: &watcher::Error) -> Option { + match err { + watcher::Error::InitialListFailed(err) + | watcher::Error::WatchStartFailed(err) + | watcher::Error::WatchFailed(err) => kube_error_code(err), + watcher::Error::WatchError(err) => Some(err.code), + watcher::Error::NoResourceVersion => None, + } +} + +pub(super) fn log_extension_api_permission_error( + kind: &str, + operation: &str, + scope: &str, + err: &KubeError, +) -> String { + error!( + api_group = EXTENSIONS_GROUP, + api_version = EXTENSIONS_VERSION_V1BETA1, + kind, + operation, + scope, + error = %err, + "Kubernetes RBAC configuration error for Agent Sandbox extension API" + ); + format!( + "Kubernetes RBAC configuration error: permission denied while attempting to {operation} {kind} resources in scope '{scope}': {err}" + ) +} + +pub(super) fn sandbox_id_from_object(obj: &DynamicObject) -> Result { if let Some(annotations) = obj.metadata.annotations.as_ref() && let Some(id) = annotations.get(LABEL_SANDBOX_ID) { @@ -2402,14 +2787,13 @@ fn user_extra_one(user: &UserInfo, key: &str) -> Result { } #[allow(clippy::result_large_err)] -fn pod_sandbox_id(pod: &Pod) -> Result { +fn pod_sandbox_id(pod: &Pod) -> Option { pod.metadata .annotations .as_ref() .and_then(|annotations| annotations.get(LABEL_SANDBOX_ID)) .filter(|value| !value.is_empty()) .cloned() - .ok_or_else(|| tonic::Status::permission_denied("pod is not bound to a sandbox identity")) } #[allow(clippy::result_large_err)] @@ -2453,23 +2837,37 @@ fn sandbox_owner_reference(pod: &Pod) -> Result<&OwnerReference, tonic::Status> } #[allow(clippy::result_large_err)] -fn validate_sandbox_owner_identity( +fn validate_sandbox_owner_reference( owner: &OwnerReference, - sandbox_id: &str, sandbox: &DynamicObject, ) -> Result<(), tonic::Status> { let uid_matches = sandbox.metadata.uid.as_deref() == Some(owner.uid.as_str()); + if uid_matches { + return Ok(()); + } + Err(tonic::Status::permission_denied( + "pod identity does not match its Sandbox owner", + )) +} + +#[allow(clippy::result_large_err)] +fn validate_sandbox_owner_binding( + owner: &OwnerReference, + sandbox_id: &str, + sandbox: &DynamicObject, +) -> Result<(), tonic::Status> { + validate_sandbox_owner_reference(owner, sandbox)?; let sandbox_id_matches = sandbox .metadata .labels .as_ref() .and_then(|labels| labels.get(LABEL_SANDBOX_ID)) .is_some_and(|actual| actual == sandbox_id); - if uid_matches && sandbox_id_matches { + if sandbox_id_matches { return Ok(()); } Err(tonic::Status::permission_denied( - "pod identity does not match its Sandbox owner", + "pod sandbox ID does not match its Sandbox owner", )) } @@ -2478,18 +2876,10 @@ fn accepts_auth_namespace( operator_allowlist: Option<&OperatorNamespaceAllowlist>, namespace: &str, ) -> bool { - match config.workspace_mode { - WorkspaceMode::Shared => namespace == config.namespace, - WorkspaceMode::Managed => { - namespace.starts_with(&managed_namespace_prefix(&config.gateway_id)) - } - WorkspaceMode::Operator => { - operator_allowlist.is_some_and(|allowlist| allowlist.contains(namespace)) - } - } + crate::config::accepts_auth_namespace(config, operator_allowlist, namespace) } -fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { +pub(super) fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { obj.metadata .annotations .as_ref() @@ -2498,7 +2888,7 @@ fn annotation_or_label(obj: &DynamicObject, key: &str) -> Option { .cloned() } -fn is_openshell_managed(obj: &DynamicObject) -> bool { +pub(super) fn is_openshell_managed(obj: &DynamicObject) -> bool { annotation_or_label(obj, LABEL_MANAGED_BY).as_deref() == Some(LABEL_MANAGED_BY_VALUE) } @@ -3533,43 +3923,43 @@ fn default_workspace_volume_claim_templates( /// Parameters shared by `sandbox_to_k8s_spec` and `sandbox_template_to_k8s`. #[allow(clippy::struct_excessive_bools)] -struct SandboxPodParams<'a> { - default_image: &'a str, - image_pull_policy: &'a str, - image_pull_secrets: &'a [String], - supervisor_image: &'a str, - supervisor_image_pull_policy: &'a str, - supervisor_sideload_method: SupervisorSideloadMethod, - topology: SupervisorTopology, - proxy_uid: u32, - process_binary_aware_network_policy: bool, - https_proxy: Option<&'a str>, - no_proxy: Option<&'a str>, - proxy_auth_secret_name: Option<&'a str>, - proxy_auth_secret_key: Option<&'a str>, - proxy_auth_allow_insecure: bool, - proxy_connect_by_hostname: bool, - service_account_name: &'a str, - sandbox_id: &'a str, - sandbox_name: &'a str, - grpc_endpoint: &'a str, - ssh_socket_path: &'a str, - client_tls_secret_name: &'a str, - host_gateway_ip: &'a str, - enable_user_namespaces: bool, - app_armor_profile: Option<&'a AppArmorProfile>, - workspace_default_storage_size: &'a str, - workspace_storage_class: &'a str, - default_runtime_class_name: &'a str, +pub(super) struct SandboxPodParams<'a> { + pub(super) default_image: &'a str, + pub(super) image_pull_policy: &'a str, + pub(super) image_pull_secrets: &'a [String], + pub(super) supervisor_image: &'a str, + pub(super) supervisor_image_pull_policy: &'a str, + pub(super) supervisor_sideload_method: SupervisorSideloadMethod, + pub(super) topology: SupervisorTopology, + pub(super) proxy_uid: u32, + pub(super) process_binary_aware_network_policy: bool, + pub(super) https_proxy: Option<&'a str>, + pub(super) no_proxy: Option<&'a str>, + pub(super) proxy_auth_secret_name: Option<&'a str>, + pub(super) proxy_auth_secret_key: Option<&'a str>, + pub(super) proxy_auth_allow_insecure: bool, + pub(super) proxy_connect_by_hostname: bool, + pub(super) service_account_name: &'a str, + pub(super) sandbox_id: &'a str, + pub(super) sandbox_name: &'a str, + pub(super) grpc_endpoint: &'a str, + pub(super) ssh_socket_path: &'a str, + pub(super) client_tls_secret_name: &'a str, + pub(super) host_gateway_ip: &'a str, + pub(super) enable_user_namespaces: bool, + pub(super) app_armor_profile: Option<&'a AppArmorProfile>, + pub(super) workspace_default_storage_size: &'a str, + pub(super) workspace_storage_class: &'a str, + pub(super) default_runtime_class_name: &'a str, /// Lifetime (seconds) of the projected `ServiceAccount` token used - /// for the bootstrap `IssueSandboxToken` exchange. - sa_token_ttl_secs: i64, - provider_spiffe_enabled: bool, - provider_spiffe_workload_api_socket_path: &'a str, + /// for the bootstrap `RegisterSupervisor` stream. + pub(super) sa_token_ttl_secs: i64, + pub(super) provider_spiffe_enabled: bool, + pub(super) provider_spiffe_workload_api_socket_path: &'a str, /// Resolved sandbox UID for supervisor `runAsUser` and env var. - sandbox_uid: u32, + pub(super) sandbox_uid: u32, /// Resolved sandbox GID for PVC init container operations. - sandbox_gid: u32, + pub(super) sandbox_gid: u32, } impl Default for SandboxPodParams<'_> { @@ -3611,7 +4001,7 @@ impl Default for SandboxPodParams<'_> { } } -fn validate_sidecar_proxy_identity( +pub(super) fn validate_sidecar_proxy_identity( params: &SandboxPodParams<'_>, ) -> Result<(), KubernetesDriverError> { if params.topology == SupervisorTopology::Sidecar && params.proxy_uid == params.sandbox_uid { @@ -3656,7 +4046,7 @@ fn kubernetes_driver_config_for_spec( Ok(config) } -fn sandbox_to_k8s_spec( +pub(super) fn sandbox_to_k8s_spec( spec: Option<&SandboxSpec>, params: &SandboxPodParams<'_>, ) -> Result { @@ -3786,24 +4176,22 @@ fn sandbox_template_to_k8s_with_validated_config( .iter() .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone()))) .collect::>(); - if params.provider_spiffe_enabled { + pod_labels.insert( + LABEL_MANAGED_BY.to_string(), + serde_json::Value::String(LABEL_MANAGED_BY_VALUE.to_string()), + ); + if params.provider_spiffe_enabled && !params.sandbox_id.is_empty() { pod_labels.insert( - LABEL_MANAGED_BY.to_string(), - serde_json::Value::String(LABEL_MANAGED_BY_VALUE.to_string()), + LABEL_SANDBOX_ID.to_string(), + serde_json::Value::String(params.sandbox_id.to_string()), ); - if !params.sandbox_id.is_empty() { - pod_labels.insert( - LABEL_SANDBOX_ID.to_string(), - serde_json::Value::String(params.sandbox_id.to_string()), - ); - } } if !pod_labels.is_empty() { metadata.insert("labels".to_string(), serde_json::Value::Object(pod_labels)); } // Carry the sandbox UUID as a pod annotation so the gateway can resolve // a projected SA token claim (pod name + uid) back to a sandbox identity - // when the supervisor calls `IssueSandboxToken` at startup. The gateway + // when the supervisor calls `RegisterSupervisor` at startup. The gateway // also verifies the pod's controlling Sandbox ownerReference against the // live CR before accepting this annotation. Its K8s Role does NOT grant // `patch pods`, so this annotation is effectively immutable post-create. @@ -4046,9 +4434,10 @@ fn sandbox_template_to_k8s_with_validated_config( } // Projected ServiceAccountToken volume — kubelet writes a short-lived // audience-bound JWT into /var/run/secrets/openshell/token and rotates - // it automatically. The supervisor exchanges this for a gateway-minted - // JWT via `IssueSandboxToken` once at startup. In sidecar topology both - // supervisor containers run with the sandbox GID and need group-read access. + // it automatically. The supervisor presents this to `RegisterSupervisor` + // and receives a gateway-minted JWT on the activation stream. In sidecar + // topology both supervisor containers run with the sandbox GID and need + // group-read access. let sa_token_default_mode = match params.topology { SupervisorTopology::Combined => 0o400, SupervisorTopology::Sidecar => 0o440, @@ -4392,7 +4781,7 @@ fn apply_required_env( } // Projected ServiceAccount token written by kubelet (see the volume // definition in `sandbox_template_to_k8s`). The supervisor reads this - // and exchanges it for a gateway-minted JWT via `IssueSandboxToken`. + // and receives a gateway-minted JWT via `RegisterSupervisor`. upsert_env( env, openshell_core::sandbox_env::K8S_SA_TOKEN_FILE, @@ -4652,7 +5041,7 @@ fn sandbox_operating_state_patch( } } -fn condition_from_value(value: &serde_json::Value) -> Option { +pub(super) fn condition_from_value(value: &serde_json::Value) -> Option { let obj = value.as_object()?; Some(SandboxCondition { r#type: obj.get("type")?.as_str()?.to_string(), @@ -4789,8 +5178,9 @@ fn apply_namespace_watch_event( } Event::InitDone => { // Readers must see a complete snapshot, including during interrupted relists. - let count = relisted_names.len(); - allowlist.replace(std::mem::take(relisted_names)); + let names = std::mem::take(relisted_names); + let count = names.len(); + allowlist.replace_and_mark_initially_synced(names); info!( total = count, "operator namespace allowlist replaced from full relist" @@ -4807,7 +5197,7 @@ fn spawn_namespace_file_watcher( match load_namespace_file(&path) { Ok(names) => { let count = names.len(); - allowlist.replace(names); + allowlist.replace_and_mark_initially_synced(names); info!( path = %path.display(), total = count, @@ -4891,7 +5281,7 @@ fn spawn_namespace_file_watcher( match load_namespace_file(&path) { Ok(names) => { let count = names.len(); - allowlist.replace(names); + allowlist.replace_and_mark_initially_synced(names); info!( total = count, "operator namespace allowlist reloaded from file" @@ -4927,6 +5317,7 @@ fn spawn_namespace_file_watcher( #[cfg(test)] mod tests { use super::*; + use futures::stream; use openshell_core::progress::{ PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY, PROGRESS_COMPLETE_STEP_KEY, @@ -4935,154 +5326,759 @@ mod tests { use prost_types::{Struct, Value, value::Kind}; use std::collections::BTreeSet; - static ENV_LOCK: std::sync::LazyLock> = - std::sync::LazyLock::new(|| std::sync::Mutex::new(())); - #[tokio::test] - async fn tracing_create_sandbox_failure_exports_a_kubernetes_operation_span() { - use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; - use tracing::instrument::WithSubscriber as _; - use tracing_subscriber::layer::SubscriberExt as _; - - let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; - let exporter = InMemorySpanExporterBuilder::new().build(); - let provider = SdkTracerProvider::builder() - .with_simple_exporter(exporter.clone()) - .build(); - let subscriber = - tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + async fn warm_capabilities_report_structural_driver_support() { let driver = KubernetesComputeDriver::new_for_test(KubernetesComputeConfig::default()); - driver - .create_sandbox(&Sandbox::default()) - .with_subscriber(subscriber) - .await - .expect_err("missing sandbox name should fail"); - provider.force_flush().unwrap(); + let capabilities = driver.capabilities().unwrap(); + assert!(capabilities.supports_warm_supervisor_bootstrap); + assert!(capabilities.supports_sandbox_template_reconciliation); - let spans = exporter.get_finished_spans().unwrap(); - let span = spans - .iter() - .find(|span| span.name == "kubernetes.provision") - .expect("create operation span"); - assert!(matches!( - span.status, - opentelemetry::trace::Status::Error { .. } - )); - provider.shutdown().unwrap(); + let mut disabled_config = KubernetesComputeConfig::default(); + disabled_config.warm_pooling.enabled = false; + let disabled = KubernetesComputeDriver::new_for_test(disabled_config); + let capabilities = disabled.capabilities().unwrap(); + assert!(capabilities.supports_warm_supervisor_bootstrap); + assert!(capabilities.supports_sandbox_template_reconciliation); + + let mut unavailable = driver; + unavailable.warm_pools.activation_support = WarmActivationSupport::Unavailable; + let capabilities = unavailable.capabilities().unwrap(); + assert!(!capabilities.supports_warm_supervisor_bootstrap); + assert!(capabilities.supports_sandbox_template_reconciliation); } #[tokio::test] - async fn sandbox_annotation_propagates_the_active_w3c_trace_context() { - use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; - use tracing_subscriber::layer::SubscriberExt as _; + async fn absent_sandbox_claim_api_skips_lifecycle_requests() { + let mut config = KubernetesComputeConfig::default(); + config.warm_pooling.enabled = false; + let mut driver = KubernetesComputeDriver::new_for_test(config); + driver.warm_pools.discovery = + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::default()); - let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; - let exporter = InMemorySpanExporterBuilder::new().build(); - let provider = SdkTracerProvider::builder() - .with_simple_exporter(exporter) - .build(); - let subscriber = - tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + assert!( + driver + .list_claim_backed_sandboxes() + .await + .unwrap() + .is_empty() + ); + assert!( + driver + .get_claim_backed_sandbox("sandbox-id") + .await + .unwrap() + .is_none() + ); + assert!(!driver.delete_sandbox_claim("sandbox-id").await.unwrap()); + } - let annotations = tracing::subscriber::with_default(subscriber, || { - let span = tracing::info_span!("kubernetes.provision"); - let _entered = span.enter(); - let mut annotations = BTreeMap::new(); - add_trace_context_annotation(&mut annotations); - annotations + #[tokio::test] + async fn disabled_warm_allocation_preserves_sandbox_claim_inventory() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let service = tower::service_fn(move |request: http::Request| { + let captured = captured.clone(); + async move { + captured.lock().unwrap().push(request.uri().to_string()); + let body = serde_json::json!({ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": "SandboxClaimList", + "metadata": {}, + "items": [] + }); + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + } }); + let client = Client::new(service, "default"); + let mut config = KubernetesComputeConfig::default(); + config.warm_pooling.enabled = false; + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), + config, + operator_allowlist: None, + }; - let carrier: serde_json::Value = serde_json::from_str( - annotations - .get("opentelemetry.io/trace-context") - .expect("agent-sandbox trace-context annotation"), - ) - .expect("annotation should contain a JSON propagation carrier"); - let traceparent = carrier["traceparent"] - .as_str() - .expect("carrier should contain traceparent"); - assert!(traceparent.starts_with("00-")); - assert_eq!(traceparent.len(), 55); - - provider.shutdown().unwrap(); + assert!( + driver + .list_claim_backed_sandboxes() + .await + .unwrap() + .is_empty() + ); + assert_eq!(requests.lock().unwrap().len(), 1); + assert!(requests.lock().unwrap()[0].contains("/sandboxclaims")); } - fn json_struct(value: serde_json::Value) -> Struct { - let serde_json::Value::Object(object) = value else { - panic!("expected JSON object"); - }; - openshell_core::proto_struct::json_object_to_struct(object) - .expect("test JSON must convert to a protobuf Struct") - } + #[tokio::test] + async fn sandbox_claim_inventory_propagates_discovery_failures() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; - fn sandbox_to_k8s_spec_for_test( - spec: Option<&SandboxSpec>, - params: &SandboxPodParams<'_>, - ) -> serde_json::Value { - sandbox_to_k8s_spec(spec, params).expect("test Kubernetes driver_config should be valid") - } + let service = tower::service_fn(|_request: http::Request| async { + let status = serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "message": "temporary discovery failure", + "reason": "ServiceUnavailable", + "code": 503 + }); + Ok::<_, Infallible>( + http::Response::builder() + .status(http::StatusCode::SERVICE_UNAVAILABLE) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(status.to_string()))) + .unwrap(), + ) + }); + let client = Client::new(service, "default"); + let mut driver = KubernetesComputeDriver::new_for_test(KubernetesComputeConfig::default()); + driver.client = client; + driver.warm_pools.discovery = ExtensionApiDiscoveryCache::default(); - fn kube_api_error(code: u16, message: &str) -> KubeError { - KubeError::Api(kube::core::ErrorResponse { - status: if code == 404 { - "404 Not Found".to_string() - } else { - "Failure".to_string() - }, - message: message.to_string(), - reason: "Failed to parse error data".to_string(), - code, - }) + let err = driver.list_claim_backed_sandboxes().await.unwrap_err(); + assert!(err.contains("failed to discover Agent Sandbox extension APIs")); } - fn expired_watch_error() -> watcher::Error { - watcher::Error::WatchError(kube::core::ErrorResponse { - status: "Failure".to_string(), - message: "too old resource version".to_string(), - reason: "Expired".to_string(), - code: 410, - }) + #[tokio::test] + async fn sandbox_claim_permission_failure_is_reported_as_rbac_configuration_error() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + + let service = tower::service_fn(|_request: http::Request| async { + let status = serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "message": "sandboxclaims is forbidden", + "reason": "Forbidden", + "code": 403 + }); + Ok::<_, Infallible>( + http::Response::builder() + .status(http::StatusCode::FORBIDDEN) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(status.to_string()))) + .unwrap(), + ) + }); + let client = Client::new(service, "default"); + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), + config: KubernetesComputeConfig::default(), + operator_allowlist: None, + }; + + let err = driver.list_claim_backed_sandboxes().await.unwrap_err(); + assert!(err.contains("Kubernetes RBAC configuration error")); + assert!(err.contains("SandboxClaim")); } #[tokio::test] - async fn sandbox_watcher_error_does_not_hide_relist() { - let recovered = DynamicObject { - types: None, - metadata: ObjectMeta { - name: Some("recovered-sandbox".to_string()), - ..Default::default() - }, - data: serde_json::json!({}), + async fn disabled_warm_pooling_prunes_owned_generated_resources() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let service = tower::service_fn(move |request: http::Request| { + let captured = captured.clone(); + async move { + let uri = request.uri().to_string(); + captured + .lock() + .unwrap() + .push(format!("{} {uri}", request.method())); + let body = if request.method() == http::Method::DELETE { + serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Success", + "code": 200 + }) + } else { + let kind = if uri.contains("sandboxwarmpools") { + SANDBOX_WARM_POOL_KIND + } else { + SANDBOX_TEMPLATE_KIND + }; + serde_json::json!({ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": format!("{kind}List"), + "metadata": {}, + "items": [{ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": kind, + "metadata": { + "name": "stale", + "namespace": "team-a", + "labels": { + (LABEL_WARM_POOL_ENABLED): "true", + (LABEL_WARM_POOL_MANAGED_BY): LABEL_WARM_POOL_MANAGED_BY_VALUE, + (LABEL_GATEWAY_ID): "gateway-a" + }, + "annotations": { + (ANNOTATION_WARM_POOL_TEMPLATE_ID): "deleted-template" + } + }, + "spec": {} + }] + }) + }; + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "control"); + let mut config = KubernetesComputeConfig::default(); + config.warm_pooling.enabled = false; + config.gateway_id = "gateway-a".to_string(); + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), + config, + operator_allowlist: None, }; - let source = futures::stream::iter([ - Err(expired_watch_error()), - Ok(Event::Init), - Ok(Event::InitApply(recovered)), - Ok(Event::InitDone), - ]); - let mut stream = continue_on_watcher_errors(source, "sandbox-resource"); - assert!(matches!(stream.next().await, Some(Event::Init))); - let event = stream - .next() - .await - .expect("410 Expired must not terminate the watcher stream"); - let Event::InitApply(object) = event else { - panic!("expected kube-runtime recovery to emit InitApply"); - }; - assert_eq!(object.metadata.name.as_deref(), Some("recovered-sandbox")); - assert!(matches!(stream.next().await, Some(Event::InitDone))); + assert_eq!( + driver.reconcile_sandbox_templates(&[]).await.unwrap(), + (0, 2) + ); + let requests = requests.lock().unwrap(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("DELETE ")) + .count(), + 2, + "unexpected requests: {requests:?}" + ); assert!( - stream.next().await.is_none(), - "source closure must be preserved" + requests + .iter() + .filter(|request| request.starts_with("GET ")) + .all(|request| request.contains("gateway-id%3Dgateway-a"),) ); } - #[tokio::test(start_paused = true)] - async fn outward_watch_stream_survives_expired_error_and_backoff_recovery() { - let recovered = DynamicObject { - types: None, + #[tokio::test] + async fn template_reconciliation_skips_invalid_templates_and_continues() { + use bytes::Bytes; + use http_body_util::Full; + use openshell_core::proto::compute::v1::DriverSandboxTemplateServiceLevel; + use std::convert::Infallible; + + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let service = tower::service_fn(move |request: http::Request| { + let captured = captured.clone(); + async move { + let uri = request.uri().to_string(); + let method = request.method().clone(); + captured.lock().unwrap().push(format!("{method} {uri}")); + let body = if method == http::Method::DELETE { + serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Success", + "code": 200 + }) + } else if uri == "/api/v1/namespaces/default" { + serde_json::json!({ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { "name": "default" } + }) + } else { + let kind = if uri.contains("sandboxwarmpools") { + SANDBOX_WARM_POOL_KIND + } else { + SANDBOX_TEMPLATE_KIND + }; + let items = if uri.contains("valid-template") { + Vec::new() + } else { + vec![serde_json::json!({ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": kind, + "metadata": { + "name": "stale-invalid-template", + "namespace": "default", + "labels": { + (LABEL_WARM_POOL_ENABLED): "true", + (LABEL_WARM_POOL_MANAGED_BY): LABEL_WARM_POOL_MANAGED_BY_VALUE, + (LABEL_GATEWAY_ID): "gateway-a" + }, + "annotations": { + (ANNOTATION_WARM_POOL_TEMPLATE_ID): "invalid-template" + } + }, + "spec": {} + })] + }; + serde_json::json!({ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": format!("{kind}List"), + "metadata": {}, + "items": items + }) + }; + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "control"); + let config = KubernetesComputeConfig { + gateway_id: "gateway-a".to_string(), + ..Default::default() + }; + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), + config, + operator_allowlist: None, + }; + let valid = DriverSandboxTemplateResource { + id: "valid-template".to_string(), + name: "valid-template".to_string(), + workspace: "default".to_string(), + template: Some(SandboxTemplate::default()), + ..Default::default() + }; + let invalid = DriverSandboxTemplateResource { + id: "invalid-template".to_string(), + name: "invalid-template".to_string(), + workspace: "default".to_string(), + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "unknown": true + }))), + ..Default::default() + }), + desired_service_level: Some(DriverSandboxTemplateServiceLevel { + startup: Some(DriverSandboxTemplateStartup { + ready_within: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), + max_burst: 1, + }), + }), + ..Default::default() + }; + + assert_eq!( + driver + .reconcile_sandbox_templates(&[invalid, valid]) + .await + .unwrap(), + (1, 2) + ); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 7, "unexpected requests: {requests:?}"); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("DELETE ")) + .count(), + 2, + "generated resources for the invalid template must be pruned: {requests:?}" + ); + } + + #[tokio::test] + async fn lifecycle_patch_uses_discovered_version_and_object_namespace() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let service = tower::service_fn(move |request: http::Request| { + let captured = captured.clone(); + async move { + let uri = request.uri().to_string(); + captured + .lock() + .unwrap() + .push(format!("{} {uri}", request.method())); + let object = serde_json::json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", + "kind": "Sandbox", + "metadata": { + "name": "sandbox-a", + "namespace": "tenant-a", + "resourceVersion": "42" + }, + "spec": {} + }); + let body = if request.method() == http::Method::PATCH { + object + } else { + serde_json::json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", + "kind": "SandboxList", + "metadata": {}, + "items": [object] + }) + }; + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "control"); + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::default()), + WarmActivationSupport::Available, + ), + config: KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: "gateway".to_string(), + namespace: "control".to_string(), + ..KubernetesComputeConfig::default() + }, + operator_allowlist: None, + }; + + driver + .patch_sandbox_operating_state("sandbox-id", true) + .await + .unwrap(); + + let patch = requests + .lock() + .unwrap() + .iter() + .find(|request| request.starts_with("PATCH ")) + .cloned() + .expect("lifecycle PATCH request"); + assert!( + patch.starts_with( + "PATCH /apis/agents.x-k8s.io/v1beta1/namespaces/tenant-a/sandboxes/sandbox-a" + ), + "unexpected lifecycle request: {patch}" + ); + } + + #[tokio::test] + async fn lifecycle_patch_resolves_the_sandbox_selected_by_a_claim() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let service = tower::service_fn(move |request: http::Request| { + let captured = captured.clone(); + async move { + let method = request.method().clone(); + let uri = request.uri().to_string(); + captured.lock().unwrap().push(format!("{method} {uri}")); + let path = request.uri().path(); + let body = if path == "/apis/extensions.agents.x-k8s.io/v1beta1/sandboxclaims" { + serde_json::json!({ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": "SandboxClaimList", + "metadata": {}, + "items": [{ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": "SandboxClaim", + "metadata": { + "name": "workspace-a--dev", + "namespace": "tenant-a", + "labels": { + "openshell.ai/managed-by": "openshell", + "openshell.ai/sandbox-id": "sandbox-id", + "openshell.ai/sandbox-name": "dev", + "openshell.ai/sandbox-workspace": "workspace-a" + } + }, + "status": { + "sandbox": {"name": "pool-a-7x9sk"} + } + }] + }) + } else if path + == "/apis/agents.x-k8s.io/v1beta1/namespaces/tenant-a/sandboxes/pool-a-7x9sk" + { + serde_json::json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", + "kind": "Sandbox", + "metadata": { + "name": "pool-a-7x9sk", + "namespace": "tenant-a", + "resourceVersion": "42", + "annotations": { + "agents.x-k8s.io/pod-name": "pool-a-7x9sk-agent" + } + }, + "spec": {} + }) + } else { + serde_json::json!({ + "apiVersion": "agents.x-k8s.io/v1beta1", + "kind": "SandboxList", + "metadata": {}, + "items": [] + }) + }; + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "control"); + let sandbox_api_version = OnceCell::new(); + sandbox_api_version.set(SANDBOX_VERSION_V1BETA1).unwrap(); + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(sandbox_api_version), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), + config: KubernetesComputeConfig { + workspace_mode: WorkspaceMode::Managed, + gateway_id: "gateway".to_string(), + namespace: "control".to_string(), + ..KubernetesComputeConfig::default() + }, + operator_allowlist: None, + }; + + let (_, kube_name, pod_name, namespace, _) = driver + .patch_sandbox_operating_state("sandbox-id", false) + .await + .unwrap(); + + assert_eq!(kube_name, "pool-a-7x9sk"); + assert_eq!(pod_name, "pool-a-7x9sk-agent"); + assert_eq!(namespace, "tenant-a"); + let requests = requests.lock().unwrap(); + let claim_lookup = requests + .iter() + .find(|request| request.contains("/sandboxclaims?")) + .expect("SandboxClaim lookup request"); + assert!( + claim_lookup.contains("gateway-id%3Dgateway"), + "SandboxClaim lookup must be scoped to the gateway: {claim_lookup}" + ); + let patch = requests + .iter() + .find(|request| request.starts_with("PATCH ")) + .cloned() + .expect("lifecycle PATCH request"); + assert!( + patch.starts_with( + "PATCH /apis/agents.x-k8s.io/v1beta1/namespaces/tenant-a/sandboxes/pool-a-7x9sk" + ), + "unexpected lifecycle request: {patch}" + ); + } + + static ENV_LOCK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + + #[tokio::test] + async fn tracing_create_sandbox_failure_exports_a_kubernetes_operation_span() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + let driver = KubernetesComputeDriver::new_for_test(KubernetesComputeConfig::default()); + + driver + .create_sandbox(&Sandbox::default(), None) + .with_subscriber(subscriber) + .await + .expect_err("missing sandbox name should fail"); + provider.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|span| span.name == "kubernetes.provision") + .expect("create operation span"); + assert!(matches!( + span.status, + opentelemetry::trace::Status::Error { .. } + )); + provider.shutdown().unwrap(); + } + + #[tokio::test] + async fn sandbox_annotation_propagates_the_active_w3c_trace_context() { + use opentelemetry_sdk::trace::{InMemorySpanExporterBuilder, SdkTracerProvider}; + use tracing_subscriber::layer::SubscriberExt as _; + + let _tracing_lock = openshell_otel_test_support::tracing_test_lock().await; + let exporter = InMemorySpanExporterBuilder::new().build(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter) + .build(); + let subscriber = + tracing_subscriber::registry().with(crate::otel_tracing::TRACING.layer(&provider)); + + let annotations = tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("kubernetes.provision"); + let _entered = span.enter(); + let mut annotations = BTreeMap::new(); + add_trace_context_annotation(&mut annotations); + annotations + }); + + let carrier: serde_json::Value = serde_json::from_str( + annotations + .get("opentelemetry.io/trace-context") + .expect("agent-sandbox trace-context annotation"), + ) + .expect("annotation should contain a JSON propagation carrier"); + let traceparent = carrier["traceparent"] + .as_str() + .expect("carrier should contain traceparent"); + assert!(traceparent.starts_with("00-")); + assert_eq!(traceparent.len(), 55); + + provider.shutdown().unwrap(); + } + + fn json_struct(value: serde_json::Value) -> Struct { + let serde_json::Value::Object(object) = value else { + panic!("expected JSON object"); + }; + openshell_core::proto_struct::json_object_to_struct(object) + .expect("test JSON must convert to a protobuf Struct") + } + + fn sandbox_to_k8s_spec_for_test( + spec: Option<&SandboxSpec>, + params: &SandboxPodParams<'_>, + ) -> serde_json::Value { + sandbox_to_k8s_spec(spec, params).expect("test Kubernetes driver_config should be valid") + } + + fn kube_api_error(code: u16, message: &str) -> KubeError { + KubeError::Api(kube::core::ErrorResponse { + status: if code == 404 { + "404 Not Found".to_string() + } else { + "Failure".to_string() + }, + message: message.to_string(), + reason: "Failed to parse error data".to_string(), + code, + }) + } + + fn expired_watch_error() -> watcher::Error { + watcher::Error::WatchError(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: "too old resource version".to_string(), + reason: "Expired".to_string(), + code: 410, + }) + } + + #[tokio::test] + async fn sandbox_watcher_error_does_not_hide_relist() { + let recovered = DynamicObject { + types: None, + metadata: ObjectMeta { + name: Some("recovered-sandbox".to_string()), + ..Default::default() + }, + data: serde_json::json!({}), + }; + let source = stream::iter([ + Err(expired_watch_error()), + Ok(Event::Init), + Ok(Event::InitApply(recovered)), + Ok(Event::InitDone), + ]); + let mut stream = continue_on_watcher_errors(source, "sandbox-resource"); + + assert!(matches!(stream.next().await, Some(Event::Init))); + let event = stream + .next() + .await + .expect("410 Expired must not terminate the watcher stream"); + let Event::InitApply(object) = event else { + panic!("expected kube-runtime recovery to emit InitApply"); + }; + assert_eq!(object.metadata.name.as_deref(), Some("recovered-sandbox")); + assert!(matches!(stream.next().await, Some(Event::InitDone))); + assert!( + stream.next().await.is_none(), + "source closure must be preserved" + ); + } + + #[tokio::test(start_paused = true)] + async fn outward_watch_stream_survives_expired_error_and_backoff_recovery() { + let recovered = DynamicObject { + types: None, metadata: ObjectMeta { name: Some("recovered-sandbox".to_string()), namespace: Some("recovered-namespace".to_string()), @@ -5099,15 +6095,17 @@ mod tests { }, data: serde_json::json!({}), }; - let source = futures::stream::iter([ + let source = stream::iter([ Err(expired_watch_error()), Ok(Event::Init), Ok(Event::InitApply(recovered)), Ok(Event::InitDone), ]) - .chain(futures::stream::pending()); + .chain(stream::pending()); let sandbox_stream = recovering_watcher_stream(source, "sandbox-resource").boxed(); - let mut outward = cluster_wide_watch_stream(sandbox_stream, "default".to_string()); + let claim_stream = stream::pending::>(); + let mut outward = + cluster_wide_watch_stream(sandbox_stream, claim_stream, "default".to_string()); let event = outward .next() @@ -5130,8 +6128,61 @@ mod tests { } #[tokio::test] - async fn kubernetes_event_watcher_error_does_not_hide_relist() { - let source = futures::stream::iter([ + async fn cluster_wide_watch_stream_emits_sandbox_claim_events() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let mut claim = DynamicObject::new("workspace-a--dev", &resource); + claim.metadata.namespace = Some("team-a".to_string()); + claim.metadata.labels = Some(BTreeMap::from([ + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + (LABEL_SANDBOX_ID.to_string(), "sandbox-id".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "dev".to_string()), + ( + LABEL_SANDBOX_WORKSPACE.to_string(), + "workspace-a".to_string(), + ), + ])); + claim.data = serde_json::json!({ + "status": { + "sandbox": { + "name": "pool-a-7x9sk", + "podName": "pool-a-7x9sk-agent" + } + } + }); + + let sandbox_stream = stream::pending::>(); + let claim_stream = stream::iter([Event::Apply(claim)]).chain(stream::pending()); + let mut outward = + cluster_wide_watch_stream(sandbox_stream, claim_stream, "default".to_string()); + + let event = outward + .next() + .await + .expect("claim event must reach the outward stream") + .expect("claim event must be valid"); + let Some(watch_sandboxes_event::Payload::Sandbox(event)) = event.payload else { + panic!("expected claim-backed sandbox event"); + }; + let sandbox = event.sandbox.expect("sandbox payload must be populated"); + assert_eq!(sandbox.id, "sandbox-id"); + assert_eq!(sandbox.name, "dev"); + assert_eq!(sandbox.workspace, "workspace-a"); + assert_eq!(sandbox.namespace, "team-a"); + assert_eq!( + sandbox + .status + .as_ref() + .map(|status| status.sandbox_name.as_str()), + Some("pool-a-7x9sk") + ); + } + + #[tokio::test] + async fn kubernetes_event_watcher_error_does_not_hide_relist() { + let source = stream::iter([ Err(expired_watch_error()), Ok(Event::Init), Ok(Event::InitApply(KubeEventObj::default())), @@ -5154,7 +6205,9 @@ mod tests { #[test] fn namespace_relist_replaces_only_completed_snapshots() { - let allowlist = OperatorNamespaceAllowlist::from_set(BTreeSet::from(["old".to_string()])); + let allowlist = OperatorNamespaceAllowlist::new(); + allowlist.insert("old".to_string()); + assert!(!allowlist.is_initially_synced()); let mut pending = BTreeSet::new(); let namespace = |name: &str| Namespace { metadata: ObjectMeta { @@ -5184,6 +6237,7 @@ mod tests { apply_namespace_watch_event(&allowlist, &mut pending, Event::Init); apply_namespace_watch_event(&allowlist, &mut pending, Event::InitApply(namespace("new"))); apply_namespace_watch_event(&allowlist, &mut pending, Event::InitDone); + assert!(allowlist.is_initially_synced()); assert!(accepts_auth_namespace(&config, Some(&allowlist), "new")); assert!(!accepts_auth_namespace(&config, Some(&allowlist), "old")); assert!(!accepts_auth_namespace( @@ -5293,253 +6347,1175 @@ mod tests { "team-a".to_string(), "team-b".to_string(), ])); - assert!(accepts_auth_namespace(&config, Some(&allowlist), "team-a")); - assert!(!accepts_auth_namespace(&config, Some(&allowlist), "team-c")); - assert!(!accepts_auth_namespace(&config, None, "team-a")); + assert!(accepts_auth_namespace(&config, Some(&allowlist), "team-a")); + assert!(!accepts_auth_namespace(&config, Some(&allowlist), "team-c")); + assert!(!accepts_auth_namespace(&config, None, "team-a")); + } + + fn sandbox_owner_for_test(name: &str, uid: &str) -> OwnerReference { + OwnerReference { + api_version: "agents.x-k8s.io/v1beta1".to_string(), + block_owner_deletion: None, + controller: Some(true), + kind: SANDBOX_KIND.to_string(), + name: name.to_string(), + uid: uid.to_string(), + } + } + + fn sandbox_object_for_test(uid: &str, sandbox_id: &str) -> DynamicObject { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox-a", &resource); + sandbox.metadata.uid = Some(uid.to_string()); + sandbox.metadata.labels = Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + sandbox_id.to_string(), + )])); + sandbox + } + + #[test] + fn pod_identity_requires_matching_uid_annotation_and_controlling_owner() { + let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); + let pod = Pod { + metadata: ObjectMeta { + uid: Some("pod-uid-a".to_string()), + annotations: Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + "sandbox-id-a".to_string(), + )])), + owner_references: Some(vec![owner.clone()]), + ..Default::default() + }, + ..Default::default() + }; + + validate_pod_uid(&pod, "pod-uid-a").expect("matching pod UID"); + assert_eq!(pod_sandbox_id(&pod).unwrap(), "sandbox-id-a"); + assert_eq!(sandbox_owner_reference(&pod).unwrap(), &owner); + + let error = validate_pod_uid(&pod, "other-pod-uid").unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mut missing_annotation = pod.clone(); + missing_annotation.metadata.annotations = None; + assert_eq!(pod_sandbox_id(&missing_annotation), None); + + let mut non_controlling = pod; + non_controlling.metadata.owner_references.as_mut().unwrap()[0].controller = Some(false); + let error = sandbox_owner_reference(&non_controlling).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_owner_identity_requires_matching_uid_and_sandbox_id() { + let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); + let sandbox = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-a"); + validate_sandbox_owner_binding(&owner, "sandbox-id-a", &sandbox) + .expect("matching owner identity"); + + let mismatched_owner = sandbox_object_for_test("sandbox-uid-b", "sandbox-id-a"); + let error = + validate_sandbox_owner_binding(&owner, "sandbox-id-a", &mismatched_owner).unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + + let mismatched_annotation = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-b"); + let error = validate_sandbox_owner_binding(&owner, "sandbox-id-a", &mismatched_annotation) + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { + let structured = kube_api_error(404, "could not find the requested resource"); + assert!(should_try_next_sandbox_api_version(&structured)); + + let raw = kube_api_error(404, "404 page not found\n"); + assert!(should_try_next_sandbox_api_version(&raw)); + } + + #[test] + fn lifecycle_patch_uses_version_specific_operating_state() { + let beta_stop = sandbox_operating_state_patch(SANDBOX_VERSION_V1BETA1, "42", false); + assert_eq!(beta_stop["metadata"]["resourceVersion"], "42"); + assert_eq!(beta_stop["spec"]["operatingMode"], "Suspended"); + assert!(beta_stop["spec"].get("replicas").is_none()); + + let alpha_start = sandbox_operating_state_patch(SANDBOX_VERSION_V1ALPHA1, "43", true); + assert_eq!(alpha_start["metadata"]["resourceVersion"], "43"); + assert_eq!(alpha_start["spec"]["replicas"], 1); + assert!(alpha_start["spec"].get("operatingMode").is_none()); + } + + #[test] + fn stop_timeout_includes_pod_grace_period_and_reconcile_headroom() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + + assert_eq!( + kubernetes_sandbox_stop_timeout(&sandbox), + Duration::from_mins(1), + "an omitted grace period uses the Kubernetes 30-second default" + ); + + sandbox.data = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": {"terminationGracePeriodSeconds": 45} + } + } + }); + assert_eq!( + kubernetes_sandbox_stop_timeout(&sandbox), + Duration::from_secs(75) + ); + } + + #[test] + fn stop_poll_interval_backs_off_to_cap() { + let mut interval = STOP_INITIAL_POLL_INTERVAL; + let expected = [ + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(2), + ]; + + for expected_interval in expected { + interval = next_stop_poll_interval(interval); + assert_eq!(interval, expected_interval); + } + } + + #[test] + fn stopped_status_requires_published_condition() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1ALPHA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + sandbox.data = serde_json::json!({"status": {"replicas": 0}}); + + assert!( + !kubernetes_sandbox_has_stopped_condition(&sandbox), + "v1alpha1 omits a zero status replica count on the wire; it is not a usable completion signal" + ); + + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{"type": "Suspended", "status": "True"}] + } + }); + assert!(kubernetes_sandbox_has_stopped_condition(&sandbox)); + } + + #[test] + fn beta_stop_requires_suspended_condition_and_deleted_pod() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + + assert!(!kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + true, + )); + + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{"type": "Suspended", "status": "True"}] + } + }); + assert!(!kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + false, + )); + assert!(kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1BETA1, + &sandbox, + true, + )); + assert!(kubernetes_sandbox_stop_is_complete( + SANDBOX_VERSION_V1ALPHA1, + &DynamicObject::new("sandbox", &resource), + true, + )); + } + + #[test] + fn stop_failure_only_rejects_terminal_suspension_condition() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + sandbox.data = serde_json::json!({ + "status": { + "conditions": [{ + "type": "Suspended", + "status": "False", + "reason": "PodNotOwned", + "message": "Refused to delete pod because it is not owned by this sandbox" + }] + } + }); + + assert_eq!( + kubernetes_sandbox_stop_failure(&sandbox).as_deref(), + Some( + "Kubernetes sandbox stop rejected: Refused to delete pod because it is not owned by this sandbox" + ) + ); + + sandbox.data["status"]["conditions"][0]["status"] = serde_json::json!("Unknown"); + sandbox.data["status"]["conditions"][0]["reason"] = serde_json::json!("PodStateUnknown"); + assert!( + kubernetes_sandbox_stop_failure(&sandbox).is_none(), + "an unknown pod state can recover on a later controller reconciliation" + ); + } + + #[test] + fn sandbox_api_version_probe_keeps_non_404_errors() { + let err = kube_api_error(403, "sandboxes.agents.x-k8s.io is forbidden"); + assert!(!should_try_next_sandbox_api_version(&err)); + } + + #[test] + fn sandbox_claim_object_renders_v1beta1_warm_pool_ref() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let sandbox = Sandbox { + id: "sandbox-id".to_string(), + name: "dev".to_string(), + namespace: String::new(), + workspace: "workspace-a".to_string(), + spec: None, + status: None, + }; + + let config = KubernetesComputeConfig::default(); + let claim = sandbox_claim_to_k8s_object(&config, &sandbox, "pool-a", &resource); + + assert_eq!(claim.metadata.name.as_deref(), Some("workspace-a--dev")); + assert_eq!( + claim + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(LABEL_ALLOCATION)) + .map(String::as_str), + Some(LABEL_ALLOCATION_SANDBOX_CLAIM) + ); + assert_eq!( + string_at(&claim.data, &["spec", "warmPoolRef", "name"]).as_deref(), + Some("pool-a") + ); + assert_eq!( + string_at(&claim.data, &["spec", "lifecycle", "shutdownPolicy"]).as_deref(), + Some("Delete") + ); + assert_eq!( + string_at(&claim.data, &["spec", "sandboxTemplateRef", "name"]), + None + ); + } + + #[test] + fn sandbox_claim_create_classifies_conflict_and_server_errors_as_ambiguous() { + assert!(claim_create_result_is_ambiguous(&kube_api_error( + 409, + "already exists" + ))); + assert!(claim_create_result_is_ambiguous(&kube_api_error( + 408, + "request timeout" + ))); + assert!(claim_create_result_is_ambiguous(&kube_api_error( + 500, + "internal error" + ))); + assert!(!claim_create_result_is_ambiguous(&kube_api_error( + 403, + "forbidden" + ))); + assert!(!claim_create_result_is_ambiguous(&kube_api_error( + 422, "invalid" + ))); + } + + #[tokio::test] + async fn sandbox_claim_delete_conflict_does_not_fall_through_to_sandbox_delete() { + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let service = tower::service_fn(move |request: http::Request| { + let captured = captured.clone(); + async move { + let method = request.method().clone(); + let uri = request.uri().to_string(); + captured.lock().unwrap().push(format!("{method} {uri}")); + + if method == http::Method::DELETE { + let body = serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "message": "Operation cannot be fulfilled on sandboxclaims.extensions.agents.x-k8s.io \"claim-a\": the object has been modified", + "reason": "Conflict", + "details": { + "name": "claim-a", + "group": "extensions.agents.x-k8s.io", + "kind": "sandboxclaims" + }, + "code": 409 + }); + return Ok::<_, Infallible>( + http::Response::builder() + .status(http::StatusCode::CONFLICT) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ); + } + + let body = serde_json::json!({ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": "SandboxClaimList", + "metadata": {}, + "items": [{ + "apiVersion": "extensions.agents.x-k8s.io/v1beta1", + "kind": "SandboxClaim", + "metadata": { + "name": "claim-a", + "namespace": "tenant-a", + "uid": "claim-uid", + "resourceVersion": "42", + "labels": { + "openshell.ai/managed-by": "openshell", + "openshell.ai/sandbox-id": "sandbox-id" + } + }, + "spec": { + "warmPoolRef": {"name": "pool-a"} + } + }] + }); + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "control"); + let driver = KubernetesComputeDriver { + client: client.clone(), + watch_client: client, + sandbox_api_version: Arc::new(OnceCell::new()), + warm_pools: WarmPoolManager::new( + ExtensionApiDiscoveryCache::seeded(ExtensionApiAvailability::all()), + WarmActivationSupport::Available, + ), + config: KubernetesComputeConfig::default(), + operator_allowlist: None, + }; + + let error = driver + .delete_sandbox_inner("sandbox-id") + .await + .expect_err("claim deletion conflict must be surfaced"); + assert!(error.contains("object has been modified"), "{error}"); + + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 2, "unexpected requests: {requests:?}"); + assert!(requests[0].contains("/sandboxclaims?"), "{:?}", requests[0]); + assert!( + requests[0].contains("gateway-id%3Dopenshell"), + "SandboxClaim delete lookup must be scoped to the gateway: {}", + requests[0] + ); + assert!( + requests[1].starts_with( + "DELETE /apis/extensions.agents.x-k8s.io/v1beta1/namespaces/tenant-a/sandboxclaims/claim-a" + ), + "{:?}", + requests[1] + ); + assert!( + requests + .iter() + .all(|request| !request.contains("/apis/agents.x-k8s.io/")), + "claim conflict must not fall through to direct Sandbox deletion: {requests:?}" + ); + } + + #[test] + fn existing_sandbox_claim_is_idempotent_only_for_same_identity_and_pool() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let sandbox = Sandbox { + id: "sandbox-id".to_string(), + name: "dev".to_string(), + workspace: "workspace-a".to_string(), + ..Default::default() + }; + let config = KubernetesComputeConfig::default(); + let desired = sandbox_claim_to_k8s_object(&config, &sandbox, "pool-a", &resource); + let mut existing = desired.clone(); + existing.metadata.uid = Some("claim-uid".to_string()); + + validate_existing_sandbox_claim(&desired, &existing) + .expect("same claim identity should be idempotent"); + + existing + .metadata + .labels + .as_mut() + .unwrap() + .insert(LABEL_SANDBOX_ID.to_string(), "other-id".to_string()); + let err = validate_existing_sandbox_claim(&desired, &existing) + .expect_err("different sandbox identity must conflict"); + assert!(matches!(err, KubernetesDriverError::Precondition(_))); + } + + #[test] + fn existing_sandbox_claim_rejects_different_pool() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let sandbox = Sandbox { + id: "sandbox-id".to_string(), + name: "dev".to_string(), + workspace: "workspace-a".to_string(), + ..Default::default() + }; + let config = KubernetesComputeConfig::default(); + let desired = sandbox_claim_to_k8s_object(&config, &sandbox, "pool-a", &resource); + let existing = sandbox_claim_to_k8s_object(&config, &sandbox, "pool-b", &resource); + + let err = validate_existing_sandbox_claim(&desired, &existing) + .expect_err("different warm pool must conflict"); + assert!(matches!(err, KubernetesDriverError::Precondition(_))); + } + + #[test] + fn existing_sandbox_claim_rejects_different_gateway() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let sandbox = Sandbox { + id: "sandbox-id".to_string(), + name: "dev".to_string(), + workspace: "workspace-a".to_string(), + ..Default::default() + }; + let config = KubernetesComputeConfig::default(); + let desired = sandbox_claim_to_k8s_object(&config, &sandbox, "pool-a", &resource); + let mut existing = desired.clone(); + existing + .metadata + .labels + .as_mut() + .unwrap() + .insert(LABEL_GATEWAY_ID.to_string(), "another-gateway".to_string()); + + let err = validate_existing_sandbox_claim(&desired, &existing) + .expect_err("different gateway identity must conflict"); + assert!(matches!(err, KubernetesDriverError::Precondition(_))); + } + + #[test] + fn generated_warm_pool_selector_requires_generated_resources() { + assert_eq!( + generated_warm_pool_label_selector(), + "openshell.ai/enabled=true,openshell.ai/managed-by=openshell-kubernetes-driver" + ); + assert_eq!( + owned_generated_warm_pool_label_selector("gateway-a"), + "openshell.ai/enabled=true,openshell.ai/managed-by=openshell-kubernetes-driver,openshell.ai/gateway-id=gateway-a" + ); + } + + #[test] + fn generated_warm_pool_parse_defaults_template_namespace_to_pool_namespace() { + let resource = extension_resource(SANDBOX_WARM_POOL_KIND); + let mut warm_pool = DynamicObject::new("pool-a", &resource); + warm_pool.metadata.namespace = Some("team-a".to_string()); + warm_pool.metadata.labels = Some(BTreeMap::from([( + LABEL_WARM_POOL_ENABLED.to_string(), + "true".to_string(), + )])); + warm_pool.metadata.annotations = Some(BTreeMap::from([ + ( + ANNOTATION_WARM_POOL_TEMPLATE_ID.to_string(), + "template-id-a".to_string(), + ), + ( + ANNOTATION_WARM_POOL_TEMPLATE_NAME.to_string(), + "source-template-a".to_string(), + ), + ( + ANNOTATION_WARM_POOL_TEMPLATE_WORKSPACE.to_string(), + "workspace-a".to_string(), + ), + ( + ANNOTATION_WARM_POOL_SOURCE_RESOURCE_VERSION.to_string(), + "42".to_string(), + ), + ])); + warm_pool.data = serde_json::json!({ + "spec": { + "sandboxTemplateRef": { + "name": "template-a" + } + } + }); + + let parsed = generated_warm_pool_from_object(warm_pool).unwrap(); + + assert_eq!(parsed.namespace, "team-a"); + assert_eq!(parsed.name, "pool-a"); + assert_eq!(parsed.template_namespace, "team-a"); + assert_eq!(parsed.template_name, "template-a"); + assert_eq!(parsed.source_template_id, "template-id-a"); + assert_eq!(parsed.source_template_name, "source-template-a"); + assert_eq!(parsed.source_template_workspace, "workspace-a"); + assert_eq!(parsed.source_template_resource_version, 42); } - fn sandbox_owner_for_test(name: &str, uid: &str) -> OwnerReference { - OwnerReference { - api_version: "agents.x-k8s.io/v1beta1".to_string(), - block_owner_deletion: None, - controller: Some(true), - kind: SANDBOX_KIND.to_string(), - name: name.to_string(), - uid: uid.to_string(), + fn warm_pool_template_ref() -> DriverSandboxTemplateRef { + DriverSandboxTemplateRef { + id: "template-id-a".to_string(), + name: "template-a".to_string(), + workspace: "workspace-a".to_string(), + resource_version: 42, } } - fn sandbox_object_for_test(uid: &str, sandbox_id: &str) -> DynamicObject { - let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( - SANDBOX_GROUP, - SANDBOX_VERSION_V1BETA1, - SANDBOX_KIND, - )); - let mut sandbox = DynamicObject::new("sandbox-a", &resource); - sandbox.metadata.uid = Some(uid.to_string()); - sandbox.metadata.labels = Some(BTreeMap::from([( - LABEL_SANDBOX_ID.to_string(), - sandbox_id.to_string(), - )])); - sandbox + fn warm_pool_cache_entry( + namespace: &str, + name: &str, + template_fingerprint: &str, + ) -> WarmPoolCacheEntry { + WarmPoolCacheEntry { + namespace: namespace.to_string(), + name: name.to_string(), + template_namespace: namespace.to_string(), + template_name: format!("{name}-template"), + source_template_id: "template-id-a".to_string(), + source_template_name: "template-a".to_string(), + source_template_workspace: "workspace-a".to_string(), + source_template_resource_version: 42, + template_fingerprint: template_fingerprint.to_string(), + } } #[test] - fn pod_identity_requires_matching_uid_annotation_and_controlling_owner() { - let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); - let pod = Pod { - metadata: ObjectMeta { - uid: Some("pod-uid-a".to_string()), - annotations: Some(BTreeMap::from([( - LABEL_SANDBOX_ID.to_string(), - "sandbox-id-a".to_string(), - )])), - owner_references: Some(vec![owner.clone()]), - ..Default::default() - }, - ..Default::default() - }; - - validate_pod_uid(&pod, "pod-uid-a").expect("matching pod UID"); - assert_eq!(pod_sandbox_id(&pod).unwrap(), "sandbox-id-a"); - assert_eq!(sandbox_owner_reference(&pod).unwrap(), &owner); + fn warm_pool_cache_requires_successful_refresh_before_matching() { + futures::executor::block_on(async { + let cache = WarmPoolCache::default(); + let template_ref = warm_pool_template_ref(); - let error = validate_pod_uid(&pod, "other-pod-uid").unwrap_err(); - assert_eq!(error.code(), tonic::Code::PermissionDenied); + assert_eq!( + cache + .matching_pool("team-a", &template_ref, "fingerprint-a") + .await, + WarmPoolCacheLookup::NotReady + ); - let mut missing_annotation = pod.clone(); - missing_annotation.metadata.annotations = None; - let error = pod_sandbox_id(&missing_annotation).unwrap_err(); - assert_eq!(error.code(), tonic::Code::PermissionDenied); + cache.replace_entries(Vec::new()).await; - let mut non_controlling = pod; - non_controlling.metadata.owner_references.as_mut().unwrap()[0].controller = Some(false); - let error = sandbox_owner_reference(&non_controlling).unwrap_err(); - assert_eq!(error.code(), tonic::Code::PermissionDenied); + assert_eq!( + cache + .matching_pool("team-a", &template_ref, "fingerprint-a") + .await, + WarmPoolCacheLookup::NoMatch + ); + }); } #[test] - fn sandbox_owner_identity_requires_matching_uid_and_sandbox_id() { - let owner = sandbox_owner_for_test("sandbox-a", "sandbox-uid-a"); - let sandbox = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-a"); - validate_sandbox_owner_identity(&owner, "sandbox-id-a", &sandbox) - .expect("matching owner identity"); - - let mismatched_owner = sandbox_object_for_test("sandbox-uid-b", "sandbox-id-a"); - let error = - validate_sandbox_owner_identity(&owner, "sandbox-id-a", &mismatched_owner).unwrap_err(); - assert_eq!(error.code(), tonic::Code::PermissionDenied); + fn warm_pool_cache_matches_by_namespace_and_fingerprint() { + futures::executor::block_on(async { + let cache = WarmPoolCache::default(); + cache + .replace_entries(vec![ + warm_pool_cache_entry("team-a", "pool-a", "fingerprint-a"), + warm_pool_cache_entry("team-b", "pool-b", "fingerprint-a"), + ]) + .await; + let template_ref = warm_pool_template_ref(); - let mismatched_annotation = sandbox_object_for_test("sandbox-uid-a", "sandbox-id-b"); - let error = validate_sandbox_owner_identity(&owner, "sandbox-id-a", &mismatched_annotation) - .unwrap_err(); - assert_eq!(error.code(), tonic::Code::PermissionDenied); + assert_eq!( + cache + .matching_pool("team-a", &template_ref, "fingerprint-a") + .await, + WarmPoolCacheLookup::Match(warm_pool_cache_entry( + "team-a", + "pool-a", + "fingerprint-a" + )) + ); + assert_eq!( + cache + .matching_pool("team-a", &template_ref, "fingerprint-b") + .await, + WarmPoolCacheLookup::NoMatch + ); + let mut other_template_ref = template_ref; + other_template_ref.id = "template-id-b".to_string(); + assert_eq!( + cache + .matching_pool("team-a", &other_template_ref, "fingerprint-a") + .await, + WarmPoolCacheLookup::NoMatch + ); + }); } #[test] - fn sandbox_api_version_probe_retries_on_structured_and_raw_404() { - let structured = kube_api_error(404, "could not find the requested resource"); - assert!(should_try_next_sandbox_api_version(&structured)); + fn warm_pool_cache_reports_ambiguous_matches() { + futures::executor::block_on(async { + let cache = WarmPoolCache::default(); + cache + .replace_entries(vec![ + warm_pool_cache_entry("team-a", "pool-a", "fingerprint-a"), + warm_pool_cache_entry("team-a", "pool-b", "fingerprint-a"), + ]) + .await; + let template_ref = warm_pool_template_ref(); - let raw = kube_api_error(404, "404 page not found\n"); - assert!(should_try_next_sandbox_api_version(&raw)); + assert_eq!( + cache + .matching_pool("team-a", &template_ref, "fingerprint-a") + .await, + WarmPoolCacheLookup::Ambiguous(2) + ); + }); } #[test] - fn lifecycle_patch_uses_version_specific_operating_state() { - let beta_stop = sandbox_operating_state_patch(SANDBOX_VERSION_V1BETA1, "42", false); - assert_eq!(beta_stop["metadata"]["resourceVersion"], "42"); - assert_eq!(beta_stop["spec"]["operatingMode"], "Suspended"); - assert!(beta_stop["spec"].get("replicas").is_none()); + fn template_fingerprint_ignores_sandbox_identity_values() { + let base = serde_json::json!({ + "spec": { + "podTemplate": { + "metadata": { + "annotations": { + POD_ANNOTATION_SANDBOX_ID: "sandbox-a", + "stable": "value" + }, + "labels": { + LABEL_SANDBOX_ID: "sandbox-a", + LABEL_MANAGED_BY: LABEL_MANAGED_BY_VALUE + } + }, + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest", + "env": [ + {"name": openshell_core::sandbox_env::SANDBOX_ID, "value": "sandbox-a"}, + {"name": openshell_core::sandbox_env::SANDBOX, "value": "dev-a"}, + {"name": "ENDPOINT", "value": "https://gateway"} + ] + }] + } + } + } + }); + let changed = serde_json::json!({ + "spec": { + "podTemplate": { + "metadata": { + "labels": { + LABEL_MANAGED_BY: LABEL_MANAGED_BY_VALUE, + LABEL_SANDBOX_ID: "sandbox-b" + }, + "annotations": { + "stable": "value", + POD_ANNOTATION_SANDBOX_ID: "sandbox-b" + } + }, + "spec": { + "containers": [{ + "env": [ + {"name": openshell_core::sandbox_env::SANDBOX_ID, "value": "sandbox-b"}, + {"name": openshell_core::sandbox_env::SANDBOX, "value": "dev-b"}, + {"name": "ENDPOINT", "value": "https://gateway"} + ], + "image": "example.test/sandbox:latest", + "name": "agent" + }] + } + } + } + }); - let alpha_start = sandbox_operating_state_patch(SANDBOX_VERSION_V1ALPHA1, "43", true); - assert_eq!(alpha_start["metadata"]["resourceVersion"], "43"); - assert_eq!(alpha_start["spec"]["replicas"], 1); - assert!(alpha_start["spec"].get("operatingMode").is_none()); + assert_eq!( + sandbox_spec_fingerprint(&base).unwrap(), + sandbox_spec_fingerprint(&changed).unwrap() + ); } #[test] - fn stop_timeout_includes_pod_grace_period_and_reconcile_headroom() { - let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( - SANDBOX_GROUP, - SANDBOX_VERSION_V1BETA1, - SANDBOX_KIND, - )); - let mut sandbox = DynamicObject::new("sandbox", &resource); + fn template_fingerprint_ignores_main_process_activation_config() { + let warm_template = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest", + "env": [ + { + "name": openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + "value": "{\"version\":1,\"command\":[\"/bin/bash\",\"-l\"],\"tty\":true,\"await_main_process_attachment\":false}" + }, + {"name": "FOO", "value": "bar"} + ] + }] + } + } + } + }); + let create_request = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest", + "env": [ + { + "name": openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + "value": "{\"version\":1,\"command\":[\"/bin/bash\",\"-l\"],\"tty\":true,\"await_main_process_attachment\":true}" + }, + {"name": "FOO", "value": "bar"} + ] + }] + } + } + } + }); assert_eq!( - kubernetes_sandbox_stop_timeout(&sandbox), - Duration::from_mins(1), - "an omitted grace period uses the Kubernetes 30-second default" + sandbox_spec_fingerprint(&warm_template).unwrap(), + sandbox_spec_fingerprint(&create_request).unwrap() ); + } - sandbox.data = serde_json::json!({ + #[test] + fn template_fingerprint_ignores_agent_sandbox_defaulted_values() { + let pre_create = serde_json::json!({ "spec": { "podTemplate": { - "spec": {"terminationGracePeriodSeconds": 45} - } + "metadata": { + "labels": { + LABEL_MANAGED_BY: LABEL_MANAGED_BY_VALUE + } + }, + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest", + "env": [ + {"name": "OPENSHELL_ENDPOINT", "value": "https://gateway"}, + {"name": openshell_core::sandbox_env::SANDBOX_UID, "value": "1000"} + ] + }], + "initContainers": [{ + "name": "openshell-supervisor-install", + "image": "example.test/supervisor:latest" + }] + } + }, + "volumeClaimTemplates": [{ + "metadata": {"name": "workspace"}, + "spec": {"resources": {"requests": {"storage": "2Gi"}}} + }] + } + }); + let defaulted = serde_json::json!({ + "spec": { + "envVarsInjectionPolicy": "Disallowed", + "operatingMode": "Running", + "replicas": 1, + "shutdownPolicy": "Retain", + "volumeClaimTemplatesPolicy": "Disallowed", + "podTemplate": { + "metadata": { + "labels": { + LABEL_MANAGED_BY: LABEL_MANAGED_BY_VALUE + } + }, + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest", + "env": [ + {"name": "OPENSHELL_ENDPOINT", "value": "https://gateway"}, + {"name": openshell_core::sandbox_env::SANDBOX_UID, "value": "1000"} + ], + "resources": {} + }], + "initContainers": [{ + "name": "openshell-supervisor-install", + "image": "example.test/supervisor:latest", + "resources": {} + }] + } + }, + "volumeClaimTemplates": [{ + "metadata": {"name": "workspace"}, + "spec": {"resources": {"requests": {"storage": "2Gi"}}} + }] } }); + assert_eq!( - kubernetes_sandbox_stop_timeout(&sandbox), - Duration::from_secs(75) + sandbox_spec_fingerprint(&pre_create).unwrap(), + sandbox_spec_fingerprint(&defaulted).unwrap() ); } #[test] - fn stop_poll_interval_backs_off_to_cap() { - let mut interval = STOP_INITIAL_POLL_INTERVAL; - let expected = [ - Duration::from_millis(500), - Duration::from_secs(1), - Duration::from_secs(2), - Duration::from_secs(2), - ]; + fn template_fingerprint_treats_unmanaged_network_policy_as_warm_pool_baseline() { + let implicit = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest" + }] + } + } + } + }); + let unmanaged = serde_json::json!({ + "spec": { + "networkPolicyManagement": "Unmanaged", + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest" + }] + } + } + } + }); + let managed = serde_json::json!({ + "spec": { + "networkPolicyManagement": "Managed", + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest" + }] + } + } + } + }); - for expected_interval in expected { - interval = next_stop_poll_interval(interval); - assert_eq!(interval, expected_interval); - } + assert_eq!( + sandbox_spec_fingerprint(&implicit).unwrap(), + sandbox_spec_fingerprint(&unmanaged).unwrap() + ); + assert_ne!( + sandbox_spec_fingerprint(&implicit).unwrap(), + sandbox_spec_fingerprint(&managed).unwrap() + ); } #[test] - fn stopped_status_requires_published_condition() { - let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( - SANDBOX_GROUP, - SANDBOX_VERSION_V1ALPHA1, - SANDBOX_KIND, - )); - let mut sandbox = DynamicObject::new("sandbox", &resource); - sandbox.data = serde_json::json!({"status": {"replicas": 0}}); + fn template_fingerprint_prunes_empty_identity_metadata() { + let sandbox = serde_json::json!({ + "spec": { + "podTemplate": { + "metadata": { + "annotations": { + POD_ANNOTATION_SANDBOX_ID: "sandbox-a" + }, + "labels": { + LABEL_MANAGED_BY: LABEL_MANAGED_BY_VALUE, + LABEL_SANDBOX_ID: "sandbox-a" + } + }, + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest" + }] + } + } + } + }); + let template = serde_json::json!({ + "spec": { + "podTemplate": { + "metadata": { + "labels": { + LABEL_MANAGED_BY: LABEL_MANAGED_BY_VALUE + } + }, + "spec": { + "containers": [{ + "name": "agent", + "image": "example.test/sandbox:latest" + }] + } + } + } + }); - assert!( - !kubernetes_sandbox_has_stopped_condition(&sandbox), - "v1alpha1 omits a zero status replica count on the wire; it is not a usable completion signal" + assert_eq!( + sandbox_spec_fingerprint(&sandbox).unwrap(), + sandbox_spec_fingerprint(&template).unwrap() ); + } - sandbox.data = serde_json::json!({ - "status": { - "conditions": [{"type": "Suspended", "status": "True"}] + #[test] + fn template_fingerprint_ignores_default_volume_mount_read_only_false() { + let explicit_default = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "image", + "volumeMounts": [{ + "name": "workspace", + "mountPath": "/sandbox", + "readOnly": false + }] + }], + "initContainers": [{ + "name": "openshell-supervisor-install", + "image": "supervisor", + "volumeMounts": [{ + "name": "openshell-supervisor-bin", + "mountPath": "/opt/openshell/bin", + "readOnly": false + }] + }] + } + } } }); - assert!(kubernetes_sandbox_has_stopped_condition(&sandbox)); + let implicit_default = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "image", + "volumeMounts": [{ + "name": "workspace", + "mountPath": "/sandbox" + }] + }], + "initContainers": [{ + "name": "openshell-supervisor-install", + "image": "supervisor", + "volumeMounts": [{ + "name": "openshell-supervisor-bin", + "mountPath": "/opt/openshell/bin" + }] + }] + } + } + } + }); + + assert_eq!( + sandbox_spec_fingerprint(&explicit_default).unwrap(), + sandbox_spec_fingerprint(&implicit_default).unwrap() + ); } #[test] - fn beta_stop_requires_suspended_condition_and_deleted_pod() { - let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( - SANDBOX_GROUP, - SANDBOX_VERSION_V1BETA1, - SANDBOX_KIND, - )); - let mut sandbox = DynamicObject::new("sandbox", &resource); - - assert!(!kubernetes_sandbox_stop_is_complete( - SANDBOX_VERSION_V1BETA1, - &sandbox, - true, - )); - - sandbox.data = serde_json::json!({ - "status": { - "conditions": [{"type": "Suspended", "status": "True"}] + fn template_fingerprint_ignores_default_cluster_first_dns_policy() { + let explicit_default = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "dnsPolicy": "ClusterFirst", + "containers": [{ + "name": "agent", + "image": "image" + }] + } + } } }); - assert!(!kubernetes_sandbox_stop_is_complete( - SANDBOX_VERSION_V1BETA1, - &sandbox, - false, - )); - assert!(kubernetes_sandbox_stop_is_complete( - SANDBOX_VERSION_V1BETA1, - &sandbox, - true, - )); - assert!(kubernetes_sandbox_stop_is_complete( - SANDBOX_VERSION_V1ALPHA1, - &DynamicObject::new("sandbox", &resource), - true, - )); + let implicit_default = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "image": "image" + }] + } + } + } + }); + + assert_eq!( + sandbox_spec_fingerprint(&explicit_default).unwrap(), + sandbox_spec_fingerprint(&implicit_default).unwrap() + ); } #[test] - fn stop_failure_only_rejects_terminal_suspension_condition() { - let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( - SANDBOX_GROUP, - SANDBOX_VERSION_V1BETA1, - SANDBOX_KIND, - )); - let mut sandbox = DynamicObject::new("sandbox", &resource); - sandbox.data = serde_json::json!({ + fn sandbox_from_claim_object_preserves_claim_identity() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let mut claim = DynamicObject::new("workspace-a--dev", &resource); + claim.metadata.namespace = Some("team-a".to_string()); + claim.metadata.labels = Some(BTreeMap::from([ + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + (LABEL_SANDBOX_ID.to_string(), "sandbox-id".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "dev".to_string()), + ( + LABEL_SANDBOX_WORKSPACE.to_string(), + "workspace-a".to_string(), + ), + ])); + claim.data = serde_json::json!({ "status": { + "sandbox": { + "name": "pool-a-7x9sk", + "podName": "pool-a-7x9sk-agent" + }, "conditions": [{ - "type": "Suspended", + "type": "Ready", "status": "False", - "reason": "PodNotOwned", - "message": "Refused to delete pod because it is not owned by this sandbox" + "reason": "Binding", + "message": "waiting for warm pool" }] } }); + let sandbox = sandbox_from_claim_object("default", claim).unwrap(); + + assert_eq!(sandbox.id, "sandbox-id"); + assert_eq!(sandbox.name, "dev"); + assert_eq!(sandbox.workspace, "workspace-a"); + assert_eq!(sandbox.namespace, "team-a"); assert_eq!( - kubernetes_sandbox_stop_failure(&sandbox).as_deref(), - Some( - "Kubernetes sandbox stop rejected: Refused to delete pod because it is not owned by this sandbox" - ) + sandbox + .status + .as_ref() + .map(|status| status.sandbox_name.as_str()), + Some("pool-a-7x9sk") + ); + assert_eq!( + sandbox + .status + .as_ref() + .map(|status| status.instance_id.as_str()), + Some("pool-a-7x9sk-agent") ); + } - sandbox.data["status"]["conditions"][0]["status"] = serde_json::json!("Unknown"); - sandbox.data["status"]["conditions"][0]["reason"] = serde_json::json!("PodStateUnknown"); - assert!( - kubernetes_sandbox_stop_failure(&sandbox).is_none(), - "an unknown pod state can recover on a later controller reconciliation" + #[test] + fn claim_indexes_map_selected_sandbox_and_pod_events() { + let sandbox = Sandbox { + id: "sandbox-id".to_string(), + name: "dev".to_string(), + namespace: "team-a".to_string(), + status: Some(SandboxStatus { + sandbox_name: "pool-a-7x9sk".to_string(), + instance_id: "pool-a-7x9sk-agent".to_string(), + ..Default::default() + }), + workspace: "workspace-a".to_string(), + ..Default::default() + }; + let mut sandbox_name_to_id = std::collections::HashMap::new(); + let mut agent_pod_to_id = std::collections::HashMap::new(); + + update_claim_indexes( + &mut sandbox_name_to_id, + &mut agent_pod_to_id, + "workspace-a--dev", + &sandbox, + ); + + assert_eq!( + sandbox_name_to_id + .get("workspace-a--dev") + .map(String::as_str), + Some("sandbox-id") + ); + + let mut sandbox_event = KubeEventObj::default(); + sandbox_event.involved_object.kind = Some("Sandbox".to_string()); + sandbox_event.involved_object.name = Some("pool-a-7x9sk".to_string()); + let (sandbox_id, event) = + map_kube_event_to_platform(&sandbox_name_to_id, &agent_pod_to_id, &sandbox_event) + .expect("selected Sandbox events should resolve to claim sandbox ID"); + assert_eq!(sandbox_id, "sandbox-id"); + assert_eq!( + event.metadata.get("involved_name").map(String::as_str), + Some("pool-a-7x9sk") + ); + + let mut pod_event = KubeEventObj::default(); + pod_event.involved_object.kind = Some("Pod".to_string()); + pod_event.involved_object.name = Some("pool-a-7x9sk-agent".to_string()); + let (sandbox_id, event) = + map_kube_event_to_platform(&sandbox_name_to_id, &agent_pod_to_id, &pod_event) + .expect("claim-backed Pod events should resolve to claim sandbox ID"); + assert_eq!(sandbox_id, "sandbox-id"); + assert_eq!( + event.metadata.get("involved_name").map(String::as_str), + Some("pool-a-7x9sk-agent") ); } #[test] - fn sandbox_api_version_probe_keeps_non_404_errors() { - let err = kube_api_error(403, "sandboxes.agents.x-k8s.io is forbidden"); - assert!(!should_try_next_sandbox_api_version(&err)); + fn sandbox_from_pending_claim_object_has_empty_status() { + let resource = extension_resource(SANDBOX_CLAIM_KIND); + let mut claim = DynamicObject::new("workspace-a--dev", &resource); + claim.metadata.namespace = Some("team-a".to_string()); + claim.metadata.labels = Some(BTreeMap::from([ + ( + LABEL_MANAGED_BY.to_string(), + LABEL_MANAGED_BY_VALUE.to_string(), + ), + (LABEL_SANDBOX_ID.to_string(), "sandbox-id".to_string()), + (LABEL_SANDBOX_NAME.to_string(), "dev".to_string()), + ( + LABEL_SANDBOX_WORKSPACE.to_string(), + "workspace-a".to_string(), + ), + ])); + + let sandbox = sandbox_from_claim_object("default", claim).unwrap(); + + let status = sandbox.status.expect("pending claim should have status"); + assert!(status.sandbox_name.is_empty()); + assert!(status.instance_id.is_empty()); + assert!(!status.deleting); } fn rendered_env<'a>(container: &'a serde_json::Value, name: &str) -> Option<&'a str> { @@ -7896,6 +9872,22 @@ mod tests { ); } + #[test] + fn sandbox_template_always_renders_managed_by_label() { + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + &SandboxPodParams::default(), + ); + + assert_eq!( + pod_template["metadata"]["labels"][LABEL_MANAGED_BY], + serde_json::json!(LABEL_MANAGED_BY_VALUE) + ); + } + #[test] fn provider_spiffe_mounts_csi_socket_and_keeps_sa_token_bootstrap() { let params = SandboxPodParams { @@ -8445,6 +10437,98 @@ mod tests { ); } + #[test] + fn template_warm_pool_threshold_is_strict() { + let under = DriverSandboxTemplateStartup { + ready_within: Some(prost_types::Duration { + seconds: 4, + nanos: 999_000_000, + }), + max_burst: 1, + }; + let equal = DriverSandboxTemplateStartup { + ready_within: Some(prost_types::Duration { + seconds: 5, + nanos: 0, + }), + max_burst: 1, + }; + + assert!(template_requires_warm_pool(Some(&under), Duration::from_secs(5)).unwrap()); + assert!(!template_requires_warm_pool(Some(&equal), Duration::from_secs(5)).unwrap()); + } + + #[test] + fn template_warm_pool_replicas_use_max_burst_capped_by_config() { + let startup = DriverSandboxTemplateStartup { + ready_within: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), + max_burst: 50, + }; + + assert_eq!(requested_warm_pool_replicas(Some(&startup), 20), 20); + assert_eq!(requested_warm_pool_replicas(None, 20), 1); + } + + #[test] + fn warm_pool_template_converts_resources_to_driver_spec() { + use openshell_core::proto::compute::v1::{ + DriverResourceRequirements, ResourceRequirements, + }; + use prost_types::{Struct, Value, value::Kind}; + + let template = DriverSandboxTemplateResource { + id: "tmpl-123".to_string(), + name: "gpu-python".to_string(), + workspace: "research".to_string(), + template: Some(SandboxTemplate { + image: "ghcr.io/acme/agent-python:latest".to_string(), + environment: BTreeMap::from([("FOO".to_string(), "bar".to_string())]) + .into_iter() + .collect(), + resources: Some(DriverResourceRequirements { + cpu_limit: "500m".to_string(), + memory_limit: "2Gi".to_string(), + ..Default::default() + }), + platform_config: Some(Struct { + fields: BTreeMap::from([( + "runtime_class_name".to_string(), + Value { + kind: Some(Kind::StringValue("kata".to_string())), + }, + )]), + }), + ..Default::default() + }), + resource_requirements: Some(ResourceRequirements { + gpu: Some(GpuResourceRequirements { count: Some(2) }), + }), + ..Default::default() + }; + + let spec = warm_pool_template_to_sandbox_spec(&template).unwrap(); + assert_eq!(spec.environment.get("FOO").unwrap(), "bar"); + let template = spec.template.as_ref().unwrap(); + assert_eq!(template.image, "ghcr.io/acme/agent-python:latest"); + let resources = template.resources.as_ref().unwrap(); + assert_eq!(resources.cpu_limit, "500m"); + assert_eq!(resources.memory_limit, "2Gi"); + assert_eq!( + platform_config_string(template, "runtime_class_name").as_deref(), + Some("kata") + ); + assert_eq!( + spec.resource_requirements + .as_ref() + .and_then(|requirements| requirements.gpu.as_ref()) + .and_then(|gpu| gpu.count), + Some(2) + ); + } + #[test] fn workspace_storage_class_omitted_from_cr_spec_when_empty() { let cr = sandbox_to_k8s_spec_for_test( @@ -8572,6 +10656,46 @@ mod tests { ); } + #[test] + fn ensure_pod_dns_policy_sets_cluster_first() { + let mut rendered = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [] + } + } + } + }); + + ensure_pod_dns_policy(&mut rendered); + + assert_eq!( + rendered.pointer("/spec/podTemplate/spec/dnsPolicy"), + Some(&serde_json::json!("ClusterFirst")) + ); + } + + #[test] + fn ensure_network_policy_management_sets_unmanaged() { + let mut rendered = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [] + } + } + } + }); + + ensure_network_policy_management_unmanaged(&mut rendered); + + assert_eq!( + rendered.pointer("/spec/networkPolicyManagement"), + Some(&serde_json::json!("Unmanaged")) + ); + } + #[test] fn openshell_sandbox_selector_always_includes_gateway_id() { let sel = openshell_sandbox_selector_for("gw-99"); @@ -8655,6 +10779,84 @@ mod tests { ); } + #[test] + fn warm_pool_template_identity_env_is_removed() { + let mut rendered = serde_json::json!({ + "spec": { + "podTemplate": { + "spec": { + "containers": [{ + "name": "agent", + "env": [ + {"name": openshell_core::sandbox_env::SANDBOX_ID, "value": ""}, + {"name": openshell_core::sandbox_env::SANDBOX, "value": ""}, + {"name": openshell_core::sandbox_env::ENDPOINT, "value": "http://gateway"} + ] + }] + } + } + } + }); + + remove_warm_pool_template_identity_env(&mut rendered); + + let env = rendered + .pointer("/spec/podTemplate/spec/containers/0/env") + .and_then(serde_json::Value::as_array) + .expect("env should exist"); + let names = env + .iter() + .filter_map(|entry| entry.get("name").and_then(serde_json::Value::as_str)) + .collect::>(); + assert_eq!(names, vec![openshell_core::sandbox_env::ENDPOINT]); + } + + #[test] + fn warm_pool_template_generated_labels_identify_source_template() { + let rendered = RenderedWarmPoolTemplate { + source: WarmPoolTemplateSource { + name: "GPU_Python.Template".to_string(), + id: "tmpl-123".to_string(), + workspace: "research".to_string(), + resource_version: 7, + }, + gateway_id: "gateway-a".to_string(), + target_namespace: "openshell".to_string(), + generated_name: "openshell-wp-gpu-python-template-abcdef12".to_string(), + replicas: 2, + template_spec: serde_json::json!({}), + fingerprint: "sha256:abcdef1234567890".to_string(), + }; + + let labels = warm_pool_template_generated_labels(&rendered); + assert_eq!( + labels.get(LABEL_WARM_POOL_TEMPLATE).map(String::as_str), + Some("gpu-python-template") + ); + assert_eq!( + labels.get(LABEL_WARM_POOL_TEMPLATE_ID).map(String::as_str), + Some("tmpl-123") + ); + assert_eq!( + labels.get(LABEL_GATEWAY_ID).map(String::as_str), + Some("gateway-a") + ); + + let annotations = warm_pool_template_generated_annotations(&rendered); + assert_eq!( + annotations + .get(ANNOTATION_WARM_POOL_TEMPLATE_WORKSPACE) + .map(String::as_str), + Some("research") + ); + assert_eq!( + annotations + .get(ANNOTATION_WARM_POOL_SOURCE_RESOURCE_VERSION) + .map(String::as_str), + Some("7") + ); + } + #[test] fn image_pull_secret_copy_keeps_only_portable_secret_fields() { let source: Secret = serde_json::from_value(serde_json::json!({ @@ -8785,4 +10987,50 @@ mod tests { assert!(gpu.default_selection_supported); assert!(gpu.count_selection_supported); } + + #[test] + fn generated_warm_pool_template_name_is_stable_dns_label() { + let name = generated_warm_pool_template_name( + "GPU_Python.Template", + "template-id-a", + "sha256:abcdef1234567890", + ); + let repeated = generated_warm_pool_template_name( + "GPU_Python.Template", + "template-id-a", + "sha256:abcdef1234567890", + ); + + assert_eq!(name, repeated); + assert!(name.starts_with("openshell-wp-gpu-python-template-")); + assert!(name.ends_with("-abcdef12")); + assert!(name.len() <= MAX_KUBE_NAME_LEN); + assert!(is_dns_1123_label(&name)); + } + + #[test] + fn generated_warm_pool_template_names_distinguish_same_named_workspace_templates() { + let workspace_a = generated_warm_pool_template_name( + "python", + "template-id-workspace-a", + "sha256:abcdef1234567890", + ); + let workspace_b = generated_warm_pool_template_name( + "python", + "template-id-workspace-b", + "sha256:abcdef1234567890", + ); + + assert_ne!(workspace_a, workspace_b); + assert!(is_dns_1123_label(&workspace_a)); + assert!(is_dns_1123_label(&workspace_b)); + + let long_name = generated_warm_pool_template_name( + &"long-template-name-".repeat(10), + "template-id-workspace-a", + "sha256:abcdef1234567890", + ); + assert!(long_name.len() <= MAX_KUBE_NAME_LEN); + assert!(is_dns_1123_label(&long_name)); + } } diff --git a/crates/openshell-driver-kubernetes/src/extension_api.rs b/crates/openshell-driver-kubernetes/src/extension_api.rs new file mode 100644 index 0000000000..7ab33317ef --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/extension_api.rs @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Discovery and availability caching for Agent Sandbox extension APIs. + +use kube::core::gvk::GroupVersion; +use kube::discovery; +use kube::{Client, Error as KubeError}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::debug; + +pub const EXTENSIONS_GROUP: &str = "extensions.agents.x-k8s.io"; +pub const EXTENSIONS_VERSION_V1BETA1: &str = "v1beta1"; +pub const SANDBOX_CLAIM_KIND: &str = "SandboxClaim"; +pub const SANDBOX_TEMPLATE_KIND: &str = "SandboxTemplate"; +pub const SANDBOX_WARM_POOL_KIND: &str = "SandboxWarmPool"; + +const EXTENSION_API_DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(30); +const EXTENSION_API_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] +pub struct ExtensionApiAvailability { + pub sandbox_claim: bool, + pub sandbox_template: bool, + pub sandbox_warm_pool: bool, +} + +impl ExtensionApiAvailability { + #[cfg(test)] + pub fn all() -> Self { + Self { + sandbox_claim: true, + sandbox_template: true, + sandbox_warm_pool: true, + } + } + + pub fn supports_warm_allocation(self) -> bool { + self.sandbox_claim && self.sandbox_template && self.sandbox_warm_pool + } +} + +#[derive(Debug, Clone, Copy)] +struct CachedExtensionApiAvailability { + availability: ExtensionApiAvailability, + discovered_at: tokio::time::Instant, +} + +#[derive(Clone)] +pub struct ExtensionApiDiscoveryCache { + state: Arc>>, + ttl: Duration, +} + +impl Default for ExtensionApiDiscoveryCache { + fn default() -> Self { + Self { + state: Arc::new(Mutex::new(None)), + ttl: EXTENSION_API_DISCOVERY_CACHE_TTL, + } + } +} + +impl ExtensionApiDiscoveryCache { + #[cfg(test)] + pub fn with_ttl(ttl: Duration) -> Self { + Self { + state: Arc::new(Mutex::new(None)), + ttl, + } + } + + #[cfg(test)] + pub fn seeded(availability: ExtensionApiAvailability) -> Self { + Self { + state: Arc::new(Mutex::new(Some(CachedExtensionApiAvailability { + availability, + discovered_at: tokio::time::Instant::now(), + }))), + ttl: EXTENSION_API_DISCOVERY_CACHE_TTL, + } + } + + pub async fn get(&self, client: &Client) -> Result { + // Hold the mutex during discovery so concurrent reconciliation, lifecycle, + // and watch paths share one in-flight request instead of stampeding the API. + let mut state = self.state.lock().await; + if let Some(cached) = *state + && cached.discovered_at.elapsed() < self.ttl + { + return Ok(cached.availability); + } + + let group_version = GroupVersion::gv(EXTENSIONS_GROUP, EXTENSIONS_VERSION_V1BETA1); + let availability = match tokio::time::timeout( + EXTENSION_API_DISCOVERY_TIMEOUT, + discovery::pinned_group(client, &group_version), + ) + .await + { + Ok(Ok(group)) => { + let mut availability = ExtensionApiAvailability::default(); + for (resource, _) in group.versioned_resources(EXTENSIONS_VERSION_V1BETA1) { + match resource.kind.as_str() { + SANDBOX_CLAIM_KIND => availability.sandbox_claim = true, + SANDBOX_TEMPLATE_KIND => availability.sandbox_template = true, + SANDBOX_WARM_POOL_KIND => availability.sandbox_warm_pool = true, + _ => {} + } + } + availability + } + Ok(Err(err)) if extension_api_unavailable(&err) => ExtensionApiAvailability::default(), + Ok(Err(err)) => { + return Err(format!( + "failed to discover Agent Sandbox extension APIs: {err}" + )); + } + Err(_) => { + return Err(format!( + "timed out after {}s discovering Agent Sandbox extension APIs", + EXTENSION_API_DISCOVERY_TIMEOUT.as_secs() + )); + } + }; + + *state = Some(CachedExtensionApiAvailability { + availability, + discovered_at: tokio::time::Instant::now(), + }); + debug!( + sandbox_claim = availability.sandbox_claim, + sandbox_template = availability.sandbox_template, + sandbox_warm_pool = availability.sandbox_warm_pool, + cache_ttl_secs = self.ttl.as_secs(), + "Discovered Agent Sandbox extension APIs" + ); + Ok(availability) + } + + pub async fn invalidate(&self) { + *self.state.lock().await = None; + } +} + +pub fn extension_api_unavailable(err: &KubeError) -> bool { + matches!(err, KubeError::Api(api) if api.code == 404) +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http_body_util::Full; + use std::convert::Infallible; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn extension_api_resource_list() -> serde_json::Value { + serde_json::json!({ + "apiVersion": "v1", + "kind": "APIResourceList", + "groupVersion": "extensions.agents.x-k8s.io/v1beta1", + "resources": [ + { + "name": "sandboxclaims", + "singularName": "sandboxclaim", + "namespaced": true, + "kind": SANDBOX_CLAIM_KIND, + "verbs": ["create", "delete", "get", "list", "watch"] + }, + { + "name": "sandboxtemplates", + "singularName": "sandboxtemplate", + "namespaced": true, + "kind": SANDBOX_TEMPLATE_KIND, + "verbs": ["create", "delete", "get", "list", "patch", "watch"] + }, + { + "name": "sandboxwarmpools", + "singularName": "sandboxwarmpool", + "namespaced": true, + "kind": SANDBOX_WARM_POOL_KIND, + "verbs": ["create", "delete", "get", "list", "patch", "watch"] + } + ] + }) + } + + #[tokio::test] + async fn caches_successful_results() { + let requests = Arc::new(AtomicUsize::new(0)); + let captured = requests.clone(); + let service = tower::service_fn(move |_request: http::Request| { + let captured = captured.clone(); + async move { + captured.fetch_add(1, Ordering::SeqCst); + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from( + extension_api_resource_list().to_string(), + ))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "default"); + let cache = ExtensionApiDiscoveryCache::default(); + + assert!(cache.get(&client).await.unwrap().supports_warm_allocation()); + assert!(cache.get(&client).await.unwrap().supports_warm_allocation()); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn does_not_cache_errors() { + let requests = Arc::new(AtomicUsize::new(0)); + let captured = requests.clone(); + let service = tower::service_fn(move |_request: http::Request| { + let captured = captured.clone(); + async move { + let request = captured.fetch_add(1, Ordering::SeqCst); + if request == 0 { + let status = serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "message": "temporary discovery failure", + "reason": "InternalError", + "code": 500 + }); + return Ok::<_, Infallible>( + http::Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(status.to_string()))) + .unwrap(), + ); + } + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from( + extension_api_resource_list().to_string(), + ))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "default"); + let cache = ExtensionApiDiscoveryCache::default(); + + assert!(cache.get(&client).await.is_err()); + assert!(cache.get(&client).await.unwrap().supports_warm_allocation()); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn observes_installation_after_cached_absence_expires() { + let requests = Arc::new(AtomicUsize::new(0)); + let captured = requests.clone(); + let service = tower::service_fn(move |_request: http::Request| { + let captured = captured.clone(); + async move { + let request = captured.fetch_add(1, Ordering::SeqCst); + if request == 0 { + let status = serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "message": "the server could not find the requested resource", + "reason": "NotFound", + "code": 404 + }); + return Ok::<_, Infallible>( + http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(status.to_string()))) + .unwrap(), + ); + } + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from( + extension_api_resource_list().to_string(), + ))) + .unwrap(), + ) + } + }); + let client = Client::new(service, "default"); + let cache = ExtensionApiDiscoveryCache::with_ttl(Duration::ZERO); + + assert_eq!( + cache.get(&client).await.unwrap(), + ExtensionApiAvailability::default() + ); + assert!(cache.get(&client).await.unwrap().supports_warm_allocation()); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index 095752d842..50eb01fd57 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -10,11 +10,14 @@ use openshell_core::proto::compute::v1::{ DeleteWorkspaceResponse, EnsureWorkspaceRequest, EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, - ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, + ListSandboxesRequest, ListSandboxesResponse, ReconcileSandboxTemplatesRequest, + ReconcileSandboxTemplatesResponse, StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, - ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, - compute_driver_server::ComputeDriver, + ValidateSandboxCreateResponse, WarmPendingInstance, WatchSandboxesEvent, WatchSandboxesRequest, + authenticate_sandbox_response, compute_driver_server::ComputeDriver, + sandbox_template_reconciler_server::SandboxTemplateReconciler, }; +use openshell_core::supervisor_bootstrap::SupervisorBootstrapBinding; use std::pin::Pin; use tonic::{Request, Response, Status}; @@ -63,8 +66,21 @@ impl ComputeDriver for ComputeDriverService { if credential.is_empty() { return Err(Status::invalid_argument("credential is required")); } - let sandbox_id = self.driver.authenticate_sandbox(&credential).await?; - Ok(Response::new(AuthenticateSandboxResponse { sandbox_id })) + let identity = self.driver.authenticate_sandbox(&credential).await?; + let binding = match identity.binding { + SupervisorBootstrapBinding::BoundSandbox { sandbox_id } => { + authenticate_sandbox_response::Binding::SandboxId(sandbox_id) + } + SupervisorBootstrapBinding::WarmPending { activation_guard } => { + authenticate_sandbox_response::Binding::WarmPending(WarmPendingInstance { + instance_id: identity.instance_id, + activation_guard, + }) + } + }; + Ok(Response::new(AuthenticateSandboxResponse { + binding: Some(binding), + })) }) .await } @@ -160,12 +176,12 @@ impl ComputeDriver for ComputeDriverService { ) -> Result, Status> { self.rpc_tracer .trace(openshell_otel::rpc::CREATE_SANDBOX, async { + let request = request.into_inner(); let sandbox = request - .into_inner() .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.driver - .create_sandbox(&sandbox) + .create_sandbox(&sandbox, request.sandbox_template.as_ref()) .await .map_err(|e| Status::from(openshell_core::ComputeDriverError::from(e)))?; Ok(Response::new(CreateSandboxResponse {})) @@ -321,6 +337,24 @@ impl ComputeDriver for ComputeDriverService { } } +#[tonic::async_trait] +impl SandboxTemplateReconciler for ComputeDriverService { + async fn reconcile_sandbox_templates( + &self, + request: Request, + ) -> Result, Status> { + let (reconciled, pruned) = self + .driver + .reconcile_sandbox_templates(&request.into_inner().templates) + .await + .map_err(|e| Status::from(openshell_core::ComputeDriverError::from(e)))?; + Ok(Response::new(ReconcileSandboxTemplatesResponse { + reconciled, + pruned, + })) + } +} + fn workspace_delete_requires_namespace_access(mode: WorkspaceMode) -> bool { matches!(mode, WorkspaceMode::Managed) } @@ -628,4 +662,15 @@ mod tests { WorkspaceMode::Shared )); } + + #[test] + fn ambiguous_driver_errors_map_to_unavailable_status() { + let status: Status = ComputeDriverError::from(KubernetesDriverError::Unavailable( + "create outcome unknown".to_string(), + )) + .into(); + + assert_eq!(status.code(), tonic::Code::Unavailable); + assert_eq!(status.message(), "create outcome unknown"); + } } diff --git a/crates/openshell-driver-kubernetes/src/lib.rs b/crates/openshell-driver-kubernetes/src/lib.rs index 28d3c77a7d..418ad86bf8 100644 --- a/crates/openshell-driver-kubernetes/src/lib.rs +++ b/crates/openshell-driver-kubernetes/src/lib.rs @@ -1,17 +1,25 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +pub mod bootstrap; pub mod config; pub mod driver; +mod extension_api; pub mod grpc; pub mod otel_tracing; +pub mod sandboxclaim; +mod warm_pool; +pub use bootstrap::{ + K8sIdentityResolver, KubernetesSupervisorBootstrapIdentityProvider, LiveK8sResolver, +}; pub use config::{ AppArmorProfile, DEFAULT_GATEWAY_ID, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, KubernetesSidecarConfig, - ManagedSshIngressConfig, SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, - managed_namespace_prefix, + KubernetesWarmPoolingConfig, ManagedSshIngressConfig, OperatorNamespaceAllowlist, + SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, managed_namespace_prefix, }; pub use driver::{KubernetesComputeDriver, KubernetesDriverError}; pub use grpc::ComputeDriverService; -pub use openshell_core::DynamicStringAllowlist as OperatorNamespaceAllowlist; +pub use sandboxclaim::SandboxClaimActivationController; +pub use warm_pool::WarmActivationSupport; diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index 9b11f5da2a..28513cd214 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -9,12 +9,15 @@ use std::path::PathBuf; use tracing::info; use openshell_core::VERSION; -use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; +use openshell_core::proto::compute::v1::{ + compute_driver_server::ComputeDriverServer, + sandbox_template_reconciler_server::SandboxTemplateReconcilerServer, +}; use openshell_driver_kubernetes::{ AppArmorProfile, ComputeDriverService, DEFAULT_GATEWAY_ID, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, KubernetesComputeConfig, KubernetesComputeDriver, - KubernetesSidecarConfig, ManagedSshIngressConfig, SupervisorSideloadMethod, SupervisorTopology, - WorkspaceMode, + KubernetesSidecarConfig, KubernetesWarmPoolingConfig, ManagedSshIngressConfig, + SupervisorSideloadMethod, SupervisorTopology, WorkspaceMode, }; #[derive(Parser, Debug)] @@ -173,8 +176,8 @@ struct Args { app_armor_profile: Option, /// Lifetime (seconds) of the projected `ServiceAccount` token - /// kubelet writes into each sandbox pod for the `IssueSandboxToken` - /// bootstrap exchange. Kubelet enforces a minimum of 600s; the + /// kubelet writes into each sandbox pod for the `RegisterSupervisor` + /// bootstrap stream. Kubelet enforces a minimum of 600s; the /// gateway clamps values outside `[600, 86400]`. Default 3600. #[arg(long, env = "OPENSHELL_K8S_SA_TOKEN_TTL_SECS", default_value_t = 3600)] sa_token_ttl_secs: i64, @@ -239,7 +242,7 @@ async fn main() -> Result<()> { .collect::>>()?; let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - let driver = KubernetesComputeDriver::new( + let driver = KubernetesComputeDriver::new_with_activation_support( KubernetesComputeConfig { workspace_mode: args.workspace_mode, gateway_id: args.gateway_id, @@ -294,13 +297,17 @@ async fn main() -> Result<()> { .unwrap_or_default(), sandbox_uid: args.sandbox_uid, sandbox_gid: args.sandbox_gid, + warm_pooling: KubernetesWarmPoolingConfig::default(), }, shutdown_rx, + openshell_driver_kubernetes::WarmActivationSupport::Unavailable, ) .await .into_diagnostic()?; - let service = ComputeDriverServer::new(ComputeDriverService::new(driver)); + let service = ComputeDriverService::new(driver); + let compute_driver_service = ComputeDriverServer::new(service.clone()); + let template_reconciler_service = SandboxTemplateReconcilerServer::new(service); let shutdown = async move { shutdown_signal().await; let _ = shutdown_tx.send(true); @@ -313,7 +320,8 @@ async fn main() -> Result<()> { info!(socket = %socket_path.display(), "Starting Kubernetes compute driver"); tonic::transport::Server::builder() .layer(openshell_otel::compute_driver_rpc_layer()) - .add_service(service) + .add_service(compute_driver_service) + .add_service(template_reconciler_service) .serve_with_incoming_shutdown( openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), shutdown, @@ -324,7 +332,8 @@ async fn main() -> Result<()> { info!(address = %args.bind_address, "Starting Kubernetes compute driver"); tonic::transport::Server::builder() .layer(openshell_otel::compute_driver_rpc_layer()) - .add_service(service) + .add_service(compute_driver_service) + .add_service(template_reconciler_service) .serve_with_shutdown(args.bind_address, shutdown) .await .into_diagnostic() diff --git a/crates/openshell-driver-kubernetes/src/sandboxclaim.rs b/crates/openshell-driver-kubernetes/src/sandboxclaim.rs new file mode 100644 index 0000000000..4c944f34af --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/sandboxclaim.rs @@ -0,0 +1,1527 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Kubernetes `SandboxClaim` activation support. + +use crate::config::{KubernetesComputeConfig, OperatorNamespaceAllowlist, accepts_auth_namespace}; +use crate::driver::KubernetesComputeDriver; +use crate::extension_api::{ + EXTENSIONS_GROUP as SANDBOX_CLAIM_GROUP, + EXTENSIONS_VERSION_V1BETA1 as SANDBOX_CLAIM_VERSION_V1BETA1, ExtensionApiDiscoveryCache, + SANDBOX_CLAIM_KIND, +}; +use futures::{StreamExt, TryStreamExt}; +use k8s_openapi::api::core::v1::Pod; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; +use kube::api::{Api, ApiResource, ListParams}; +use kube::core::DynamicObject; +use kube::core::gvk::GroupVersionKind; +use kube::runtime::watcher::{self, Event}; +use kube::{Client, Error as KubeError}; +use openshell_core::driver_utils::{ + LABEL_GATEWAY_ID, LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, +}; +use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapActivationRequest, SupervisorBootstrapActivator, +}; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::watch; +use tokio::task::JoinSet; +use tonic::Code; +use tracing::{debug, error, info, warn}; + +const KUBE_API_TIMEOUT: Duration = Duration::from_secs(30); +const ACTIVATION_RESYNC_INTERVAL: Duration = Duration::from_secs(15); +const ACTIVATION_WATCH_RETRY_DELAY: Duration = Duration::from_secs(5); +const ACTIVATION_MAX_CONCURRENCY: usize = 32; +const ACTIVATION_RETRY_ATTEMPTS: usize = 8; +const ACTIVATION_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(250); +const ACTIVATION_RETRY_MAX_DELAY: Duration = Duration::from_secs(5); +const DRIVER_NAME: &str = "kubernetes"; + +const SANDBOX_GROUP: &str = "agents.x-k8s.io"; +const SANDBOX_KIND: &str = "Sandbox"; +const SANDBOX_VERSION_V1BETA1: &str = "v1beta1"; +const SANDBOX_VERSION_V1ALPHA1: &str = "v1alpha1"; +const SANDBOX_VERSIONS: &[&str] = &[SANDBOX_VERSION_V1BETA1, SANDBOX_VERSION_V1ALPHA1]; +const SANDBOX_API_VERSION_FULL_V1BETA1: &str = "agents.x-k8s.io/v1beta1"; +const SANDBOX_API_VERSION_FULL_V1ALPHA1: &str = "agents.x-k8s.io/v1alpha1"; +const SANDBOX_POD_NAME_ANNOTATION: &str = "agents.x-k8s.io/pod-name"; + +/// Watches Kubernetes `SandboxClaim` resources and activates pending +/// supervisor bootstrap streams after revalidating live driver state. +#[derive(Clone)] +pub struct SandboxClaimActivationController { + client: Client, + watch_client: Client, + config: KubernetesComputeConfig, + operator_allowlist: Option, + extension_api_discovery: ExtensionApiDiscoveryCache, +} + +impl SandboxClaimActivationController { + #[must_use] + pub fn new( + client: Client, + watch_client: Client, + config: KubernetesComputeConfig, + operator_allowlist: Option, + ) -> Self { + Self { + client, + watch_client, + config, + operator_allowlist, + extension_api_discovery: ExtensionApiDiscoveryCache::default(), + } + } + + #[must_use] + pub fn from_driver(driver: &KubernetesComputeDriver) -> Self { + Self { + client: driver.client(), + watch_client: driver.watch_client(), + config: driver.config().clone(), + operator_allowlist: driver.operator_allowlist().cloned(), + extension_api_discovery: driver.extension_api_discovery_cache(), + } + } + + pub fn spawn( + &self, + activator: Arc, + registration_rx: watch::Receiver, + shutdown_rx: watch::Receiver, + ) { + let controller = self.clone(); + tokio::spawn(async move { + controller + .run(activator, registration_rx, shutdown_rx) + .await; + }); + } + + async fn run( + self, + activator: Arc, + mut registration_rx: watch::Receiver, + mut shutdown_rx: watch::Receiver, + ) { + loop { + let claim_api = match self + .supported_sandbox_claim_api(self.watch_client.clone()) + .await + { + Ok(api) => api, + Err(err) => { + debug!( + namespace = %self.config.namespace, + error = %err, + retry_after_ms = ACTIVATION_WATCH_RETRY_DELAY.as_millis(), + "SandboxClaim API is not available; warm-pool claim activation will retry" + ); + if wait_for_retry_or_shutdown(ACTIVATION_WATCH_RETRY_DELAY, &mut shutdown_rx) + .await + { + return; + } + continue; + } + }; + + let list_api = self + .sandbox_claim_api_for_workspace_mode(self.client.clone(), claim_api.version) + .api; + let claim_selector = sandbox_claim_selector(&self.config.gateway_id); + let watcher_config = watcher::Config::default().labels(&claim_selector); + let mut stream = watcher::watcher(claim_api.api, watcher_config).boxed(); + let mut resync = tokio::time::interval(ACTIVATION_RESYNC_INTERVAL); + resync.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut activation_tasks = JoinSet::new(); + let mut in_flight = HashSet::new(); + let mut completed = HashSet::new(); + let mut registration_notifications_open = true; + + info!( + namespace = %self.config.namespace, + sandbox_claim_api_version = %claim_api.version, + "Watching Kubernetes SandboxClaims for warm-pool activation" + ); + + let mut restart_watch = false; + while !restart_watch { + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + return; + } + } + changed = registration_rx.changed(), if registration_notifications_open => { + if changed.is_err() { + registration_notifications_open = false; + } else { + invalidate_claim_activation_state( + &mut activation_tasks, + &mut in_flight, + &mut completed, + ); + resync_claim_activations( + &list_api, + &claim_selector, + &mut activation_tasks, + &mut in_flight, + &completed, + self.clone(), + activator.clone(), + ) + .await; + } + } + event = stream.try_next() => match event { + Ok(Some(Event::Apply(claim) | Event::InitApply(claim))) => { + schedule_claim_activation( + &mut activation_tasks, + &mut in_flight, + &completed, + self.clone(), + activator.clone(), + claim, + ); + } + Ok(Some(Event::Delete(claim))) => { + if let Some(key) = claim_activation_key(&claim) { + completed.remove(&key); + } + } + Ok(Some(Event::Init | Event::InitDone)) => {} + Ok(None) => { + warn!( + namespace = %self.config.namespace, + "SandboxClaim watch stream ended; retrying" + ); + restart_watch = true; + } + Err(err) => { + match watcher_error_code(&err) { + Some(403) => error!( + api_group = SANDBOX_CLAIM_GROUP, + api_version = SANDBOX_CLAIM_VERSION_V1BETA1, + kind = SANDBOX_CLAIM_KIND, + operation = "list/watch", + scope = if self.config.is_multi_namespace() { + "cluster-wide" + } else { + self.config.namespace.as_str() + }, + error = %err, + "Kubernetes RBAC configuration error for Agent Sandbox extension API" + ), + Some(404) => { + self.extension_api_discovery.invalidate().await; + } + _ => warn!( + namespace = %self.config.namespace, + error = %err, + "SandboxClaim watch failed; retrying" + ), + } + restart_watch = true; + } + }, + _ = resync.tick() => { + resync_claim_activations( + &list_api, + &claim_selector, + &mut activation_tasks, + &mut in_flight, + &completed, + self.clone(), + activator.clone(), + ) + .await; + } + task_result = activation_tasks.join_next(), if !activation_tasks.is_empty() => { + match task_result { + Some(Ok((key, activated))) => { + in_flight.remove(&key); + if activated { + completed.insert(key); + } + } + Some(Err(err)) => { + warn!( + namespace = %self.config.namespace, + error = %err, + "SandboxClaim activation task failed; restarting reconciliation" + ); + restart_watch = true; + } + None => {} + } + } + } + } + + activation_tasks.abort_all(); + if wait_for_retry_or_shutdown(ACTIVATION_WATCH_RETRY_DELAY, &mut shutdown_rx).await { + return; + } + } + } + + async fn handle_claim( + &self, + claim: DynamicObject, + activator: &(dyn SupervisorBootstrapActivator + '_), + ) -> bool { + let claim_namespace = claim + .metadata + .namespace + .clone() + .unwrap_or_else(|| self.config.namespace.clone()); + if !accepts_auth_namespace( + &self.config, + self.operator_allowlist.as_ref(), + &claim_namespace, + ) { + debug!( + namespace = %claim_namespace, + workspace_mode = %self.config.workspace_mode, + "Ignoring SandboxClaim outside accepted Kubernetes namespace policy" + ); + return false; + } + + let claim = match parse_sandbox_claim(&claim) { + Ok(claim) => claim, + Err(err) => { + warn!( + namespace = %claim_namespace, + error = %err, + "Ignoring invalid SandboxClaim" + ); + return false; + } + }; + debug!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + sandbox_claim_uid = %claim.uid, + warm_pool = ?claim.warm_pool_name, + sandbox_template = ?claim.sandbox_template_name, + sandbox = ?claim.sandbox_name, + pod_ips = ?claim.pod_ips, + "Processing SandboxClaim for warm-pool activation" + ); + let Some(sandbox_name) = claim.sandbox_name.as_deref() else { + debug!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + "SandboxClaim has not selected a Sandbox yet" + ); + return false; + }; + + let sandbox_cr = match self.get_sandbox_cr(&claim_namespace, sandbox_name).await { + Ok(Some(sandbox_cr)) => sandbox_cr, + Ok(None) => { + debug!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + sandbox = %sandbox_name, + "SandboxClaim selected Sandbox is not available yet" + ); + return false; + } + Err(err) => { + warn!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + sandbox = %sandbox_name, + error = %err, + "Failed to read Sandbox selected by SandboxClaim" + ); + return false; + } + }; + + let pod = match self + .resolve_controlled_pod(&claim_namespace, &sandbox_cr) + .await + { + Ok(Some(pod)) => pod, + Ok(None) => return false, + Err(err) => { + warn!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + sandbox = %sandbox_name, + error = %err, + "Failed to resolve Sandbox pod for SandboxClaim activation" + ); + return false; + } + }; + + let request = match activation_request_from_claim_state(&claim, &sandbox_cr, &pod) { + Ok(Some(request)) => request, + Ok(None) => return false, + Err(err) => { + warn!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + sandbox = %sandbox_name, + error = %err, + "SandboxClaim activation validation failed" + ); + return false; + } + }; + + debug!( + namespace = %claim_namespace, + sandbox_claim = %claim.name, + sandbox = %sandbox_name, + sandbox_id = %request.sandbox_id, + instance_id = %request.instance_id, + pod = %pod.metadata.name.as_deref().unwrap_or_default(), + "Resolved SandboxClaim activation target" + ); + + activate_registered_supervisor_with_retry( + activator, + request, + ActivationLogContext { + namespace: claim_namespace.as_str(), + sandbox_claim: claim.name.as_str(), + sandbox: sandbox_name, + }, + ACTIVATION_RETRY_ATTEMPTS, + ACTIVATION_RETRY_INITIAL_DELAY, + ACTIVATION_RETRY_MAX_DELAY, + ) + .await + } + + fn sandbox_claim_api_all(client: Client, version: &'static str) -> SandboxClaimApi { + let gvk = GroupVersionKind::gvk(SANDBOX_CLAIM_GROUP, version, SANDBOX_CLAIM_KIND); + let resource = ApiResource::from_gvk(&gvk); + let api = Api::all_with(client, &resource); + SandboxClaimApi { api, version } + } + + fn sandbox_claim_api_for_workspace_mode( + &self, + client: Client, + version: &'static str, + ) -> SandboxClaimApi { + if self.config.is_multi_namespace() { + return Self::sandbox_claim_api_all(client, version); + } + + let gvk = GroupVersionKind::gvk(SANDBOX_CLAIM_GROUP, version, SANDBOX_CLAIM_KIND); + let resource = ApiResource::from_gvk(&gvk); + let api = Api::namespaced_with(client, &self.config.namespace, &resource); + SandboxClaimApi { api, version } + } + + async fn supported_sandbox_claim_api(&self, client: Client) -> Result { + if !self + .extension_api_discovery + .get(&client) + .await? + .sandbox_claim + { + return Err( + "SandboxClaim API extensions.agents.x-k8s.io/v1beta1 is not available".to_string(), + ); + } + let claim_api = + self.sandbox_claim_api_for_workspace_mode(client, SANDBOX_CLAIM_VERSION_V1BETA1); + Ok(claim_api) + } + + fn sandbox_api(client: Client, namespace: &str, version: &'static str) -> Api { + let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, version, SANDBOX_KIND); + let resource = ApiResource::from_gvk(&gvk); + Api::namespaced_with(client, namespace, &resource) + } + + async fn get_sandbox_cr( + &self, + namespace: &str, + name: &str, + ) -> Result, String> { + for version in SANDBOX_VERSIONS { + let sandbox_api = Self::sandbox_api(self.client.clone(), namespace, version); + match tokio::time::timeout(KUBE_API_TIMEOUT, sandbox_api.get(name)).await { + Ok(Ok(sandbox_cr)) => return Ok(Some(sandbox_cr)), + Ok(Err(KubeError::Api(err))) if err.code == 404 => {} + Ok(Err(err)) if should_try_next_api_version(&err) => {} + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => { + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + } + } + Ok(None) + } + + async fn resolve_controlled_pod( + &self, + namespace: &str, + sandbox_cr: &DynamicObject, + ) -> Result, String> { + let Some(owner_uid) = sandbox_cr.metadata.uid.as_deref() else { + return Err("Sandbox CR is missing uid".to_string()); + }; + + let pods_api: Api = Api::namespaced(self.client.clone(), namespace); + if let Some(pod_name) = sandbox_pod_name(sandbox_cr) { + let pod = match tokio::time::timeout(KUBE_API_TIMEOUT, pods_api.get(&pod_name)).await { + Ok(Ok(pod)) => pod, + Ok(Err(KubeError::Api(err))) if err.code == 404 => { + debug!( + namespace = %namespace, + sandbox = %sandbox_cr.metadata.name.as_deref().unwrap_or_default(), + sandbox_uid = %owner_uid, + pod = %pod_name, + "Annotated Sandbox pod was not found for SandboxClaim activation" + ); + return Ok(None); + } + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => { + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + }; + if !pod_has_sandbox_owner(&pod, owner_uid) { + return Err(format!( + "annotated Sandbox pod {pod_name} is not controlled by the selected Sandbox" + )); + } + return Ok(Some(pod)); + } + + let pods = match sandbox_selector(sandbox_cr) { + Some(selector) => { + let params = ListParams::default().labels(&selector); + list_pods(&pods_api, ¶ms).await? + } + None => list_pods(&pods_api, &ListParams::default()).await?, + }; + + let controlled = pods + .into_iter() + .filter(|pod| pod_has_sandbox_owner(pod, owner_uid)) + .collect::>(); + + match controlled.as_slice() { + [pod] => Ok(Some(pod.clone())), + [] => { + debug!( + namespace = %namespace, + sandbox = %sandbox_cr.metadata.name.as_deref().unwrap_or_default(), + sandbox_uid = %owner_uid, + "No controlled pod found for SandboxClaim activation" + ); + Ok(None) + } + _ => Err(format!( + "expected one controlled Sandbox pod, found {}", + controlled.len() + )), + } + } +} + +fn invalidate_claim_activation_state( + tasks: &mut JoinSet<(String, bool)>, + in_flight: &mut HashSet, + completed: &mut HashSet, +) { + tasks.abort_all(); + *tasks = JoinSet::new(); + in_flight.clear(); + completed.clear(); +} + +async fn resync_claim_activations( + list_api: &Api, + claim_selector: &str, + tasks: &mut JoinSet<(String, bool)>, + in_flight: &mut HashSet, + completed: &HashSet, + controller: SandboxClaimActivationController, + activator: Arc, +) { + match tokio::time::timeout( + KUBE_API_TIMEOUT, + list_api.list(&ListParams::default().labels(claim_selector)), + ) + .await + { + Ok(Ok(claims)) => { + for claim in claims.items { + schedule_claim_activation( + tasks, + in_flight, + completed, + controller.clone(), + activator.clone(), + claim, + ); + } + } + Ok(Err(KubeError::Api(err))) if err.code == 403 => error!( + api_group = SANDBOX_CLAIM_GROUP, + api_version = SANDBOX_CLAIM_VERSION_V1BETA1, + kind = SANDBOX_CLAIM_KIND, + operation = "list", + scope = if controller.config.is_multi_namespace() { + "cluster-wide" + } else { + controller.config.namespace.as_str() + }, + error = %err, + "Kubernetes RBAC configuration error for Agent Sandbox extension API" + ), + Ok(Err(err)) => warn!( + namespace = %controller.config.namespace, + error = %err, + "Failed to resync SandboxClaims for warm-pool activation" + ), + Err(_) => warn!( + namespace = %controller.config.namespace, + timeout_seconds = KUBE_API_TIMEOUT.as_secs(), + "Timed out resyncing SandboxClaims for warm-pool activation" + ), + } +} + +fn sandbox_claim_selector(gateway_id: &str) -> String { + format!("{LABEL_MANAGED_BY}={LABEL_MANAGED_BY_VALUE},{LABEL_GATEWAY_ID}={gateway_id}") +} + +fn schedule_claim_activation( + tasks: &mut JoinSet<(String, bool)>, + in_flight: &mut HashSet, + completed: &HashSet, + controller: SandboxClaimActivationController, + activator: Arc, + claim: DynamicObject, +) { + if tasks.len() >= ACTIVATION_MAX_CONCURRENCY { + debug!( + namespace = %controller.config.namespace, + sandbox_claim = %claim.metadata.name.as_deref().unwrap_or_default(), + sandbox_claim_uid = %claim.metadata.uid.as_deref().unwrap_or_default(), + in_flight = in_flight.len(), + tasks = tasks.len(), + max_concurrency = ACTIVATION_MAX_CONCURRENCY, + resync_after_ms = ACTIVATION_RESYNC_INTERVAL.as_millis(), + "SandboxClaim activation concurrency limit reached; resync will retry" + ); + return; + } + let Some(key) = begin_claim_activation(in_flight, completed, &claim) else { + return; + }; + tasks.spawn(async move { + let activated = controller.handle_claim(claim, activator.as_ref()).await; + (key, activated) + }); +} + +fn begin_claim_activation( + in_flight: &mut HashSet, + completed: &HashSet, + claim: &DynamicObject, +) -> Option { + let key = claim_activation_key(claim)?; + if completed.contains(&key) { + return None; + } + in_flight.insert(key.clone()).then_some(key) +} + +fn claim_activation_key(claim: &DynamicObject) -> Option { + claim + .metadata + .uid + .clone() + .or_else(|| claim.metadata.name.clone()) +} + +async fn wait_for_retry_or_shutdown( + delay: Duration, + shutdown_rx: &mut watch::Receiver, +) -> bool { + tokio::select! { + () = tokio::time::sleep(delay) => false, + changed = shutdown_rx.changed() => changed.is_err() || *shutdown_rx.borrow(), + } +} + +struct SandboxClaimApi { + api: Api, + version: &'static str, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct ParsedSandboxClaim { + name: String, + uid: String, + sandbox_id: Option, + warm_pool_name: Option, + sandbox_template_name: Option, + sandbox_name: Option, + pod_ips: Vec, +} + +struct ActivationLogContext<'a> { + namespace: &'a str, + sandbox_claim: &'a str, + sandbox: &'a str, +} + +async fn activate_registered_supervisor_with_retry( + activator: &(dyn SupervisorBootstrapActivator + '_), + request: SupervisorBootstrapActivationRequest, + context: ActivationLogContext<'_>, + attempts: usize, + initial_delay: Duration, + max_delay: Duration, +) -> bool { + let attempts = attempts.max(1); + let mut delay = initial_delay; + + for attempt in 1..=attempts { + match activator + .activate_registered_supervisor(request.clone()) + .await + { + Ok(()) => { + info!( + namespace = %context.namespace, + sandbox_claim = %context.sandbox_claim, + sandbox = %context.sandbox, + sandbox_id = %request.sandbox_id, + instance_id = %request.instance_id, + attempt, + "Activated warm-pool supervisor from SandboxClaim" + ); + return true; + } + Err(status) if status.code() == Code::AlreadyExists => { + debug!( + namespace = %context.namespace, + sandbox_claim = %context.sandbox_claim, + sandbox = %context.sandbox, + sandbox_id = %request.sandbox_id, + instance_id = %request.instance_id, + attempt, + "Warm-pool supervisor was already activated" + ); + return true; + } + Err(status) if status.code() == Code::NotFound && attempt < attempts => { + debug!( + namespace = %context.namespace, + sandbox_claim = %context.sandbox_claim, + sandbox = %context.sandbox, + sandbox_id = %request.sandbox_id, + instance_id = %request.instance_id, + attempt, + retry_after_ms = delay.as_millis(), + "Warm-pool supervisor registration is not pending yet; retrying activation" + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(max_delay); + } + Err(status) if status.code() == Code::NotFound => { + debug!( + namespace = %context.namespace, + sandbox_claim = %context.sandbox_claim, + sandbox = %context.sandbox, + sandbox_id = %request.sandbox_id, + instance_id = %request.instance_id, + attempts, + "Warm-pool supervisor registration is not pending after retries" + ); + return false; + } + Err(status) => { + warn!( + namespace = %context.namespace, + sandbox_claim = %context.sandbox_claim, + sandbox = %context.sandbox, + sandbox_id = %request.sandbox_id, + instance_id = %request.instance_id, + attempt, + code = ?status.code(), + message = %status.message(), + "Failed to activate warm-pool supervisor from SandboxClaim" + ); + return false; + } + } + } + + false +} + +fn parse_sandbox_claim(obj: &DynamicObject) -> Result { + Ok(ParsedSandboxClaim { + name: required_metadata_field(&obj.metadata.name, "name")?, + uid: required_metadata_field(&obj.metadata.uid, "uid")?, + sandbox_id: obj + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(LABEL_SANDBOX_ID)) + .filter(|id| !id.is_empty()) + .cloned(), + warm_pool_name: string_at(&obj.data, &["spec", "warmPoolRef", "name"]), + sandbox_template_name: string_at(&obj.data, &["spec", "sandboxTemplateRef", "name"]), + sandbox_name: string_at(&obj.data, &["status", "sandbox", "name"]), + pod_ips: strings_at(&obj.data, &["status", "sandbox", "podIPs"]), + }) +} + +fn required_metadata_field(value: &Option, field: &str) -> Result { + value + .as_ref() + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| format!("SandboxClaim is missing metadata.{field}")) +} + +fn activation_request_from_claim_state( + claim: &ParsedSandboxClaim, + sandbox_cr: &DynamicObject, + pod: &Pod, +) -> Result, String> { + let Some(claim_sandbox_name) = claim.sandbox_name.as_deref() else { + return Ok(None); + }; + let sandbox_name = sandbox_cr.metadata.name.as_deref().unwrap_or_default(); + if sandbox_name != claim_sandbox_name { + return Err(format!( + "SandboxClaim selected Sandbox {claim_sandbox_name}, but live Sandbox is {sandbox_name}" + )); + } + let activation_guard = sandbox_cr + .metadata + .uid + .as_ref() + .filter(|uid| !uid.is_empty()) + .cloned() + .ok_or_else(|| "Sandbox CR is missing uid".to_string())?; + let sandbox_cr_sandbox_id = sandbox_cr + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(LABEL_SANDBOX_ID)) + .filter(|id| !id.is_empty()) + .cloned(); + let sandbox_id = match (sandbox_cr_sandbox_id, claim.sandbox_id.clone()) { + (Some(cr_sandbox_id), Some(claim_sandbox_id)) if cr_sandbox_id != claim_sandbox_id => { + return Err(format!( + "sandbox id mismatch: Sandbox CR label {cr_sandbox_id} != SandboxClaim {claim_sandbox_id}" + )); + } + (Some(cr_sandbox_id), _) => cr_sandbox_id, + (None, Some(claim_sandbox_id)) => claim_sandbox_id, + (None, None) => { + return Err( + "SandboxClaim and selected Sandbox are missing OpenShell sandbox id label" + .to_string(), + ); + } + }; + let instance_id = pod + .metadata + .uid + .as_ref() + .filter(|uid| !uid.is_empty()) + .cloned() + .ok_or_else(|| "Sandbox pod is missing uid".to_string())?; + if !pod_has_sandbox_owner(pod, &activation_guard) { + return Err("Sandbox pod is not controlled by the selected Sandbox".to_string()); + } + + Ok(Some(SupervisorBootstrapActivationRequest { + driver: DRIVER_NAME.to_string(), + instance_id, + sandbox_id, + activation_guard, + reason: format!("SandboxClaim/{}", claim.name), + })) +} + +async fn list_pods(api: &Api, params: &ListParams) -> Result, String> { + match tokio::time::timeout(KUBE_API_TIMEOUT, api.list(params)).await { + Ok(Ok(list)) => Ok(list.items), + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )), + } +} + +fn sandbox_selector(obj: &DynamicObject) -> Option { + string_at(&obj.data, &["status", "selector"]) +} + +fn sandbox_pod_name(obj: &DynamicObject) -> Option { + obj.metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(SANDBOX_POD_NAME_ANNOTATION)) + .filter(|pod_name| !pod_name.is_empty()) + .cloned() +} + +fn pod_has_sandbox_owner(pod: &Pod, owner_uid: &str) -> bool { + pod.metadata + .owner_references + .as_deref() + .unwrap_or_default() + .iter() + .any(|owner| is_supported_sandbox_owner_reference(owner) && owner.uid == owner_uid) +} + +fn is_supported_sandbox_owner_reference(owner: &OwnerReference) -> bool { + owner.kind == SANDBOX_KIND + && owner.controller == Some(true) + && matches!( + owner.api_version.as_str(), + SANDBOX_API_VERSION_FULL_V1BETA1 | SANDBOX_API_VERSION_FULL_V1ALPHA1 + ) +} + +fn should_try_next_api_version(err: &KubeError) -> bool { + matches!(err, KubeError::Api(api) if api.code == 404) +} + +fn watcher_error_code(err: &watcher::Error) -> Option { + match err { + watcher::Error::InitialListFailed(KubeError::Api(err)) + | watcher::Error::WatchStartFailed(KubeError::Api(err)) + | watcher::Error::WatchFailed(KubeError::Api(err)) + | watcher::Error::WatchError(err) => Some(err.code), + _ => None, + } +} + +fn string_at(value: &serde_json::Value, path: &[&str]) -> Option { + path.iter() + .try_fold(value, |current, segment| current.get(segment)) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn strings_at(value: &serde_json::Value, path: &[&str]) -> Vec { + path.iter() + .try_fold(value, |current, segment| current.get(segment)) + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .filter_map(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http_body_util::Full; + use kube::core::ObjectMeta; + use serde_json::json; + use std::collections::BTreeMap; + use std::convert::Infallible; + use std::sync::Mutex; + use tonic::Status; + use tonic::async_trait; + + struct FakeActivator { + outcomes: Mutex>>, + requests: Mutex>, + } + + impl FakeActivator { + fn new(outcomes: Vec>) -> Self { + Self { + outcomes: Mutex::new(outcomes), + requests: Mutex::new(Vec::new()), + } + } + + fn request_count(&self) -> usize { + self.requests.lock().expect("requests mutex poisoned").len() + } + } + + #[async_trait] + impl SupervisorBootstrapActivator for FakeActivator { + async fn activate_registered_supervisor( + &self, + request: SupervisorBootstrapActivationRequest, + ) -> Result<(), Status> { + self.requests + .lock() + .expect("requests mutex poisoned") + .push(request); + self.outcomes + .lock() + .expect("outcomes mutex poisoned") + .remove(0) + } + } + + fn recording_client() -> (Client, Arc>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let service = tower::service_fn(move |request: http::Request| { + let captured = Arc::clone(&captured); + async move { + let path = request.uri().path().to_string(); + captured + .lock() + .expect("requests mutex poisoned") + .push(path.clone()); + let body = if path == "/apis/extensions.agents.x-k8s.io/v1beta1" { + br#"{"apiVersion":"v1","kind":"APIResourceList","groupVersion":"extensions.agents.x-k8s.io/v1beta1","resources":[{"name":"sandboxclaims","singularName":"sandboxclaim","namespaced":true,"kind":"SandboxClaim","verbs":["get","list","watch"]}]}"# + .as_slice() + } else { + br#"{"apiVersion":"extensions.agents.x-k8s.io/v1beta1","kind":"SandboxClaimList","metadata":{"resourceVersion":"1"},"items":[]}"# + .as_slice() + }; + Ok::<_, Infallible>( + http::Response::builder() + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::copy_from_slice(body))) + .expect("valid response"), + ) + } + }); + (Client::new(service, "default"), requests) + } + + #[test] + fn claim_selector_scopes_activation_to_gateway() { + assert_eq!( + sandbox_claim_selector("gateway-a"), + "openshell.ai/managed-by=openshell,openshell.ai/gateway-id=gateway-a" + ); + } + + #[tokio::test] + async fn claim_activation_uses_namespaced_api_in_shared_workspace_mode() { + let (client, requests) = recording_client(); + let config = KubernetesComputeConfig { + workspace_mode: crate::config::WorkspaceMode::Shared, + namespace: "shared-sandboxes".to_string(), + ..KubernetesComputeConfig::default() + }; + let controller = + SandboxClaimActivationController::new(client.clone(), client, config, None); + + let api = controller + .supported_sandbox_claim_api(controller.client.clone()) + .await + .expect("namespaced claim API should be available"); + api.api.list(&ListParams::default()).await.unwrap(); + + assert_eq!( + requests.lock().expect("requests mutex poisoned").as_slice(), + [ + "/apis/extensions.agents.x-k8s.io/v1beta1", + "/apis/extensions.agents.x-k8s.io/v1beta1/namespaces/shared-sandboxes/sandboxclaims" + ] + ); + } + + #[tokio::test] + async fn claim_activation_uses_cluster_api_in_multi_namespace_mode() { + let (client, requests) = recording_client(); + let config = KubernetesComputeConfig { + workspace_mode: crate::config::WorkspaceMode::Managed, + ..KubernetesComputeConfig::default() + }; + let controller = + SandboxClaimActivationController::new(client.clone(), client, config, None); + + let api = controller + .supported_sandbox_claim_api(controller.client.clone()) + .await + .expect("cluster claim API should be available"); + api.api.list(&ListParams::default()).await.unwrap(); + + assert_eq!( + requests.lock().expect("requests mutex poisoned").as_slice(), + [ + "/apis/extensions.agents.x-k8s.io/v1beta1", + "/apis/extensions.agents.x-k8s.io/v1beta1/sandboxclaims" + ] + ); + } + + fn dynamic_object( + group: &'static str, + version: &'static str, + kind: &'static str, + name: &str, + uid: &str, + data: serde_json::Value, + ) -> DynamicObject { + let gvk = GroupVersionKind::gvk(group, version, kind); + let resource = ApiResource::from_gvk(&gvk); + let mut obj = DynamicObject::new(name, &resource); + obj.metadata.uid = Some(uid.to_string()); + obj.data = data; + obj + } + + #[test] + fn claim_activation_is_deduplicated_by_uid() { + let first = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({}), + ); + let mut relisted = first.clone(); + relisted.metadata.name = Some("renamed-in-test".to_string()); + let mut in_flight = HashSet::new(); + let mut completed = HashSet::new(); + + assert_eq!( + begin_claim_activation(&mut in_flight, &completed, &first).as_deref(), + Some("claim-uid") + ); + assert_eq!( + begin_claim_activation(&mut in_flight, &completed, &relisted), + None + ); + + in_flight.remove("claim-uid"); + assert_eq!( + begin_claim_activation(&mut in_flight, &completed, &relisted).as_deref(), + Some("claim-uid") + ); + + in_flight.remove("claim-uid"); + completed.insert("claim-uid".to_string()); + assert_eq!( + begin_claim_activation(&mut in_flight, &completed, &relisted), + None + ); + } + + #[test] + fn claim_activation_key_falls_back_to_name() { + let mut claim = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({}), + ); + claim.metadata.uid = None; + let mut in_flight = HashSet::new(); + let completed = HashSet::new(); + + assert_eq!( + begin_claim_activation(&mut in_flight, &completed, &claim).as_deref(), + Some("claim-a") + ); + } + + #[test] + fn registration_change_invalidates_claim_activation_state() { + let mut tasks = JoinSet::new(); + let mut in_flight = HashSet::from(["claim-in-flight".to_string()]); + let mut completed = HashSet::from(["claim-complete".to_string()]); + + invalidate_claim_activation_state(&mut tasks, &mut in_flight, &mut completed); + + assert!(tasks.is_empty()); + assert!(in_flight.is_empty()); + assert!(completed.is_empty()); + } + + fn sandbox_cr(name: &str, uid: &str, sandbox_id: &str, version: &'static str) -> DynamicObject { + let mut obj = dynamic_object( + SANDBOX_GROUP, + version, + SANDBOX_KIND, + name, + uid, + json!({"status": {"selector": "agents.x-k8s.io/sandbox=sandbox-a"}}), + ); + obj.metadata + .labels + .get_or_insert_with(BTreeMap::new) + .insert(LABEL_SANDBOX_ID.to_string(), sandbox_id.to_string()); + obj + } + + fn sandbox_pod(name: &str, uid: &str, owner_uid: &str, api_version: &str) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(name.to_string()), + uid: Some(uid.to_string()), + owner_references: Some(vec![OwnerReference { + api_version: api_version.to_string(), + kind: SANDBOX_KIND.to_string(), + name: "sandbox-a".to_string(), + uid: owner_uid.to_string(), + controller: Some(true), + block_owner_deletion: None, + }]), + ..ObjectMeta::default() + }, + ..Pod::default() + } + } + + #[test] + fn parses_v1beta1_sandbox_claim_status() { + let obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({ + "spec": {"warmPoolRef": {"name": "pool-a"}}, + "status": { + "sandbox": { + "name": "sandbox-a", + "podIPs": ["10.0.0.10", "fd00::10"] + } + } + }), + ); + + let claim = parse_sandbox_claim(&obj).unwrap(); + + assert_eq!(claim.name, "claim-a"); + assert_eq!(claim.uid, "claim-uid"); + assert_eq!(claim.sandbox_id, None); + assert_eq!(claim.warm_pool_name.as_deref(), Some("pool-a")); + assert_eq!(claim.sandbox_name.as_deref(), Some("sandbox-a")); + assert_eq!(claim.pod_ips, vec!["10.0.0.10", "fd00::10"]); + } + + #[test] + fn claim_without_selected_sandbox_does_not_activate() { + let obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({"spec": {"warmPoolRef": {"name": "pool-a"}}}), + ); + let claim = parse_sandbox_claim(&obj).unwrap(); + let sandbox = sandbox_cr( + "sandbox-a", + "sandbox-uid", + "openshell-sandbox", + SANDBOX_VERSION_V1BETA1, + ); + let pod = sandbox_pod( + "pod-a", + "pod-uid", + "sandbox-uid", + SANDBOX_API_VERSION_FULL_V1BETA1, + ); + + assert_eq!( + activation_request_from_claim_state(&claim, &sandbox, &pod).unwrap(), + None + ); + } + + #[test] + fn activation_request_uses_live_sandbox_and_pod_identity() { + let obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({ + "spec": {"warmPoolRef": {"name": "pool-a"}}, + "status": {"sandbox": {"name": "sandbox-a"}} + }), + ); + let claim = parse_sandbox_claim(&obj).unwrap(); + let sandbox = sandbox_cr( + "sandbox-a", + "sandbox-uid", + "openshell-sandbox", + SANDBOX_VERSION_V1ALPHA1, + ); + let pod = sandbox_pod( + "pod-a", + "pod-uid", + "sandbox-uid", + SANDBOX_API_VERSION_FULL_V1ALPHA1, + ); + + let request = activation_request_from_claim_state(&claim, &sandbox, &pod) + .unwrap() + .unwrap(); + + assert_eq!(request.driver, "kubernetes"); + assert_eq!(request.instance_id, "pod-uid"); + assert_eq!(request.sandbox_id, "openshell-sandbox"); + assert_eq!(request.activation_guard, "sandbox-uid"); + assert_eq!(request.reason, "SandboxClaim/claim-a"); + } + + #[test] + fn activation_request_uses_claim_sandbox_id_when_sandbox_lacks_label() { + let mut obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({"status": {"sandbox": {"name": "sandbox-a"}}}), + ); + obj.metadata.labels = Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + "openshell-sandbox".to_string(), + )])); + let claim = parse_sandbox_claim(&obj).unwrap(); + let mut sandbox = sandbox_cr("sandbox-a", "sandbox-uid", "", SANDBOX_VERSION_V1BETA1); + sandbox.metadata.labels = None; + let pod = sandbox_pod( + "pod-a", + "pod-uid", + "sandbox-uid", + SANDBOX_API_VERSION_FULL_V1BETA1, + ); + + let request = activation_request_from_claim_state(&claim, &sandbox, &pod) + .unwrap() + .unwrap(); + + assert_eq!(request.sandbox_id, "openshell-sandbox"); + } + + #[test] + fn activation_request_rejects_mismatched_sandbox_and_claim_ids() { + let mut obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({"status": {"sandbox": {"name": "sandbox-a"}}}), + ); + obj.metadata.labels = Some(BTreeMap::from([( + LABEL_SANDBOX_ID.to_string(), + "claim-sandbox".to_string(), + )])); + let claim = parse_sandbox_claim(&obj).unwrap(); + let sandbox = sandbox_cr( + "sandbox-a", + "sandbox-uid", + "cr-sandbox", + SANDBOX_VERSION_V1BETA1, + ); + let pod = sandbox_pod( + "pod-a", + "pod-uid", + "sandbox-uid", + SANDBOX_API_VERSION_FULL_V1BETA1, + ); + + let err = activation_request_from_claim_state(&claim, &sandbox, &pod).unwrap_err(); + + assert!(err.contains("sandbox id mismatch")); + assert!(err.contains("cr-sandbox")); + assert!(err.contains("claim-sandbox")); + } + + #[tokio::test] + async fn activation_retry_handles_registration_after_claim_binding() { + let activator = FakeActivator::new(vec![Err(Status::not_found("not pending")), Ok(())]); + let request = SupervisorBootstrapActivationRequest { + driver: DRIVER_NAME.to_string(), + instance_id: "pod-uid".to_string(), + sandbox_id: "sandbox-id".to_string(), + activation_guard: "sandbox-uid".to_string(), + reason: "SandboxClaim/claim-a".to_string(), + }; + + let activated = activate_registered_supervisor_with_retry( + &activator, + request, + ActivationLogContext { + namespace: "openshell", + sandbox_claim: "claim-a", + sandbox: "sandbox-a", + }, + 2, + Duration::ZERO, + Duration::ZERO, + ) + .await; + + assert!(activated); + assert_eq!(activator.request_count(), 2); + } + + #[tokio::test] + async fn incomplete_activation_remains_eligible_for_resync() { + let activator = FakeActivator::new(vec![Err(Status::not_found("not pending"))]); + let request = SupervisorBootstrapActivationRequest { + driver: DRIVER_NAME.to_string(), + instance_id: "pod-uid".to_string(), + sandbox_id: "sandbox-id".to_string(), + activation_guard: "sandbox-uid".to_string(), + reason: "SandboxClaim/claim-a".to_string(), + }; + + let activated = activate_registered_supervisor_with_retry( + &activator, + request, + ActivationLogContext { + namespace: "openshell", + sandbox_claim: "claim-a", + sandbox: "sandbox-a", + }, + 1, + Duration::ZERO, + Duration::ZERO, + ) + .await; + + assert!(!activated); + assert_eq!(activator.request_count(), 1); + } + + #[test] + fn activation_request_rejects_missing_sandbox_id_labels() { + let obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({"status": {"sandbox": {"name": "sandbox-a"}}}), + ); + let claim = parse_sandbox_claim(&obj).unwrap(); + let mut sandbox = sandbox_cr("sandbox-a", "sandbox-uid", "", SANDBOX_VERSION_V1BETA1); + sandbox.metadata.labels = None; + let pod = sandbox_pod( + "pod-a", + "pod-uid", + "sandbox-uid", + SANDBOX_API_VERSION_FULL_V1BETA1, + ); + + let err = activation_request_from_claim_state(&claim, &sandbox, &pod).unwrap_err(); + + assert!(err.contains("missing OpenShell sandbox id label")); + } + + #[test] + fn activation_request_rejects_uncontrolled_pod() { + let obj = dynamic_object( + SANDBOX_CLAIM_GROUP, + SANDBOX_CLAIM_VERSION_V1BETA1, + SANDBOX_CLAIM_KIND, + "claim-a", + "claim-uid", + json!({"status": {"sandbox": {"name": "sandbox-a"}}}), + ); + let claim = parse_sandbox_claim(&obj).unwrap(); + let sandbox = sandbox_cr( + "sandbox-a", + "sandbox-uid", + "openshell-sandbox", + SANDBOX_VERSION_V1BETA1, + ); + let pod = sandbox_pod( + "pod-a", + "pod-uid", + "other-sandbox-uid", + SANDBOX_API_VERSION_FULL_V1BETA1, + ); + + assert!(activation_request_from_claim_state(&claim, &sandbox, &pod).is_err()); + } + + #[test] + fn sandbox_selector_reads_status_selector() { + let sandbox = sandbox_cr( + "sandbox-a", + "sandbox-uid", + "openshell-sandbox", + SANDBOX_VERSION_V1BETA1, + ); + + assert_eq!( + sandbox_selector(&sandbox).as_deref(), + Some("agents.x-k8s.io/sandbox=sandbox-a") + ); + } + + #[test] + fn sandbox_pod_name_reads_pod_name_annotation() { + let mut sandbox = sandbox_cr( + "sandbox-a", + "sandbox-uid", + "openshell-sandbox", + SANDBOX_VERSION_V1BETA1, + ); + sandbox.metadata.annotations = Some(BTreeMap::from([( + SANDBOX_POD_NAME_ANNOTATION.to_string(), + "pod-a".to_string(), + )])); + + assert_eq!(sandbox_pod_name(&sandbox).as_deref(), Some("pod-a")); + } + + #[test] + fn api_version_probe_retries_only_404_errors() { + let unavailable = KubeError::Api(kube::core::ErrorResponse { + status: "404 Not Found".to_string(), + message: "could not find the requested resource".to_string(), + reason: "NotFound".to_string(), + code: 404, + }); + let forbidden = KubeError::Api(kube::core::ErrorResponse { + status: "Failure".to_string(), + message: "forbidden".to_string(), + reason: "Forbidden".to_string(), + code: 403, + }); + + assert!(should_try_next_api_version(&unavailable)); + assert!(!should_try_next_api_version(&forbidden)); + } +} diff --git a/crates/openshell-driver-kubernetes/src/warm_pool.rs b/crates/openshell-driver-kubernetes/src/warm_pool.rs new file mode 100644 index 0000000000..e4d6f6b162 --- /dev/null +++ b/crates/openshell-driver-kubernetes/src/warm_pool.rs @@ -0,0 +1,2410 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Kubernetes warm-pool allocation and template reconciliation. + +// The module is private, while selected items form its crate-internal interface +// with the driver and its existing tests. +#![allow(clippy::redundant_pub_crate)] + +use crate::config::{KubernetesComputeConfig, OperatorNamespaceAllowlist, WorkspaceMode}; +use crate::driver::{ + KUBE_API_TIMEOUT, KubernetesDriverError, MAX_KUBE_NAME_LEN, SandboxPodParams, + annotation_or_label, condition_from_value, is_openshell_managed, kube_error_code, + log_extension_api_permission_error, resolve_sandbox_identity_for_config, sandbox_annotations, + sandbox_id_from_object, sandbox_labels, sandbox_to_k8s_spec, validate_kubernetes_dns1123_label, + validate_sidecar_proxy_identity, watcher_error_code, +}; +use crate::extension_api::{ + EXTENSIONS_GROUP, EXTENSIONS_VERSION_V1BETA1, ExtensionApiDiscoveryCache, SANDBOX_CLAIM_KIND, + SANDBOX_TEMPLATE_KIND, SANDBOX_WARM_POOL_KIND, extension_api_unavailable, +}; +use futures::{Stream, StreamExt, TryStreamExt}; +use kube::api::{Api, ApiResource, DeleteParams, ListParams, Patch, PatchParams, PostParams}; +use kube::core::gvk::GroupVersionKind; +use kube::core::{DynamicObject, ObjectMeta}; +use kube::runtime::watcher::{self, Event}; +use kube::{Client, Error as KubeError}; +use openshell_core::driver_utils::{ + LABEL_GATEWAY_ID, LABEL_MANAGED_BY, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, + LABEL_SANDBOX_WORKSPACE, +}; +use openshell_core::proto::compute::v1::{ + DriverSandbox as Sandbox, DriverSandboxSpec as SandboxSpec, + DriverSandboxStatus as SandboxStatus, DriverSandboxTemplateRef, DriverSandboxTemplateResource, + DriverSandboxTemplateStartup, +}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{RwLock, mpsc}; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{debug, error, info, warn}; + +struct ExtensionApi { + api: Api, + resource: ApiResource, +} + +pub(super) fn extension_resource(kind: &str) -> ApiResource { + let gvk = GroupVersionKind::gvk(EXTENSIONS_GROUP, EXTENSIONS_VERSION_V1BETA1, kind); + ApiResource::from_gvk(&gvk) +} + +fn namespaced_extension_api(client: Client, namespace: &str, kind: &str) -> ExtensionApi { + let resource = extension_resource(kind); + let api = Api::namespaced_with(client, namespace, &resource); + ExtensionApi { api, resource } +} + +fn all_extension_api(client: Client, kind: &str) -> ExtensionApi { + let resource = extension_resource(kind); + let api = Api::all_with(client, &resource); + ExtensionApi { api, resource } +} + +fn sandbox_claim_api(client: Client, namespace: &str) -> ExtensionApi { + namespaced_extension_api(client, namespace, SANDBOX_CLAIM_KIND) +} + +fn sandbox_claim_api_all(client: Client) -> ExtensionApi { + all_extension_api(client, SANDBOX_CLAIM_KIND) +} + +fn sandbox_warm_pool_api(client: Client, namespace: &str) -> ExtensionApi { + namespaced_extension_api(client, namespace, SANDBOX_WARM_POOL_KIND) +} + +fn sandbox_warm_pool_api_all(client: Client) -> ExtensionApi { + all_extension_api(client, SANDBOX_WARM_POOL_KIND) +} + +fn sandbox_template_api(client: Client, namespace: &str) -> ExtensionApi { + namespaced_extension_api(client, namespace, SANDBOX_TEMPLATE_KIND) +} + +fn sandbox_template_api_all(client: Client) -> ExtensionApi { + all_extension_api(client, SANDBOX_TEMPLATE_KIND) +} + +/// Whether this driver instance has a gateway-side controller capable of +/// completing a pending warm supervisor registration. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum WarmActivationSupport { + Available, + Unavailable, +} + +/// Driver-local façade for warm-pool state and controller lifecycle. +/// +/// Claim inventory deliberately remains available when allocation is disabled, +/// so this component is always present on a Kubernetes driver instance. +#[derive(Clone)] +pub(super) struct WarmPoolManager { + pub(super) cache: WarmPoolCache, + pub(super) discovery: ExtensionApiDiscoveryCache, + pub(super) activation_support: WarmActivationSupport, +} + +impl WarmPoolManager { + async fn list_claim_objects( + &self, + client: &Client, + config: &KubernetesComputeConfig, + params: &ListParams, + operation: &'static str, + ) -> Result, String> { + let availability = self.extension_api_availability(client).await?; + if !availability.sandbox_claim { + return Ok(Vec::new()); + } + + let claim_api = if config.is_multi_namespace() { + sandbox_claim_api_all(client.clone()) + } else { + sandbox_claim_api(client.clone(), &config.namespace) + }; + match tokio::time::timeout(KUBE_API_TIMEOUT, claim_api.api.list(params)).await { + Ok(Ok(list)) => Ok(list.items), + Ok(Err(err)) if extension_api_unavailable(&err) => { + self.discovery.invalidate().await; + Ok(Vec::new()) + } + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + let scope = if config.is_multi_namespace() { + "cluster-wide" + } else { + config.namespace.as_str() + }; + Err(log_extension_api_permission_error( + SANDBOX_CLAIM_KIND, + operation, + scope, + &err, + )) + } + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )), + } + } + + pub(super) async fn list_claims( + &self, + client: &Client, + config: &KubernetesComputeConfig, + selector: &str, + ) -> Result, String> { + let objects = self + .list_claim_objects( + client, + config, + &ListParams::default().labels(selector), + "list", + ) + .await?; + Ok(objects + .into_iter() + .filter_map(|obj| sandbox_from_claim_object(&config.namespace, obj).ok()) + .collect()) + } + + pub(super) async fn get_claim( + &self, + client: &Client, + config: &KubernetesComputeConfig, + selector: &str, + ) -> Result, String> { + let objects = self + .list_claim_objects( + client, + config, + &ListParams::default().labels(selector), + "get", + ) + .await?; + Ok(objects + .into_iter() + .find_map(|obj| sandbox_from_claim_object(&config.namespace, obj).ok())) + } + + pub(super) async fn delete_claim( + &self, + client: &Client, + config: &KubernetesComputeConfig, + sandbox_id: &str, + selector: &str, + ) -> Result { + let objects = self + .list_claim_objects( + client, + config, + &ListParams::default().labels(selector), + "list for delete", + ) + .await?; + let Some(obj) = objects.into_iter().next() else { + return Ok(false); + }; + let Some(claim_name) = obj.metadata.name.clone() else { + return Ok(false); + }; + let claim_namespace = obj + .metadata + .namespace + .clone() + .unwrap_or_else(|| config.namespace.clone()); + let preconditions = kube::api::Preconditions { + uid: obj.metadata.uid, + resource_version: obj.metadata.resource_version, + }; + let claim_api = sandbox_claim_api(client.clone(), &claim_namespace); + let params = DeleteParams::default().preconditions(preconditions); + match tokio::time::timeout(KUBE_API_TIMEOUT, claim_api.api.delete(&claim_name, ¶ms)) + .await + { + Ok(Ok(_)) => { + info!( + sandbox_id, + namespace = %claim_namespace, + sandbox_claim = %claim_name, + "SandboxClaim deleted from Kubernetes" + ); + Ok(true) + } + Ok(Err(KubeError::Api(err))) if err.code == 404 => Ok(false), + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + Err(log_extension_api_permission_error( + SANDBOX_CLAIM_KIND, + "delete", + &claim_namespace, + &err, + )) + } + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )), + } + } + + async fn reconcile_template( + &self, + client: &Client, + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + template: &DriverSandboxTemplateResource, + ) -> Result<(), KubernetesDriverError> { + let maybe_rendered = + render_warm_pool_template(client.clone(), config, operator_allowlist, template) + .await + .map_err(KubernetesDriverError::InvalidArgument)?; + let Some(rendered) = maybe_rendered else { + garbage_collect_warm_pool_template( + client.clone(), + config, + operator_allowlist, + &template.id, + &template.workspace, + ) + .await + .map_err(KubernetesDriverError::Message)?; + return Ok(()); + }; + apply_rendered_warm_pool_template(client.clone(), &rendered) + .await + .map_err(KubernetesDriverError::Message)?; + garbage_collect_superseded_warm_pool_template_resources(client.clone(), &rendered) + .await + .map_err(KubernetesDriverError::Message)?; + info!( + template_id = %rendered.source.id, + template_name = %rendered.source.name, + workspace = %rendered.source.workspace, + namespace = %rendered.target_namespace, + warm_pool = %rendered.generated_name, + replicas = rendered.replicas, + "Reconciled sandbox template warm pool" + ); + Ok(()) + } + + pub(super) async fn reconcile_templates( + &self, + client: &Client, + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + templates: &[DriverSandboxTemplateResource], + ) -> Result<(u32, u32), KubernetesDriverError> { + let availability = self + .extension_api_availability(client) + .await + .map_err(KubernetesDriverError::Unavailable)?; + if !availability.sandbox_template || !availability.sandbox_warm_pool { + debug!( + sandbox_template = availability.sandbox_template, + sandbox_warm_pool = availability.sandbox_warm_pool, + "Skipping sandbox template reconciliation because the required Agent Sandbox extension APIs are absent" + ); + return Ok((0, 0)); + } + + if matches!(config.workspace_mode, WorkspaceMode::Operator) { + let allowlist = operator_allowlist.ok_or_else(|| { + KubernetesDriverError::Precondition( + "operator mode requires a namespace allowlist".to_string(), + ) + })?; + tokio::time::timeout( + KUBE_API_TIMEOUT, + allowlist.wait_until_initially_synced(), + ) + .await + .map_err(|_| { + KubernetesDriverError::Unavailable(format!( + "operator namespace allowlist did not complete its initial synchronization within {}s; deferring warm-pool reconciliation", + KUBE_API_TIMEOUT.as_secs() + )) + })?; + } + + let mut desired_ids = HashSet::with_capacity(templates.len()); + let mut reconciled = 0usize; + if self.allocation_enabled(config) && availability.supports_warm_allocation() { + for template in templates { + match self + .reconcile_template(client, config, operator_allowlist, template) + .await + { + Ok(()) => { + desired_ids.insert(template.id.as_str()); + reconciled += 1; + } + Err(KubernetesDriverError::InvalidArgument(err)) => { + warn!( + template_id = %template.id, + template_name = %template.name, + workspace = %template.workspace, + error = %err, + "Skipping invalid sandbox template during warm-pool reconciliation" + ); + } + Err(err) => return Err(err), + } + } + } + let desired_ids = + if self.allocation_enabled(config) && availability.supports_warm_allocation() { + desired_ids + } else { + HashSet::new() + }; + let pruned = prune_stale_warm_pool_template_resources(client.clone(), config, &desired_ids) + .await + .map_err(KubernetesDriverError::Message)?; + Ok(( + u32::try_from(reconciled).unwrap_or(u32::MAX), + u32::try_from(pruned).unwrap_or(u32::MAX), + )) + } + + pub(super) fn new( + discovery: ExtensionApiDiscoveryCache, + activation_support: WarmActivationSupport, + ) -> Self { + Self { + cache: WarmPoolCache::default(), + discovery, + activation_support, + } + } + + pub(super) fn allocation_enabled(&self, config: &KubernetesComputeConfig) -> bool { + config.warm_pooling.enabled && self.activation_support == WarmActivationSupport::Available + } + + pub(super) fn supports_activation(&self) -> bool { + self.activation_support == WarmActivationSupport::Available + } + + pub(super) async fn extension_api_availability( + &self, + client: &Client, + ) -> Result { + self.discovery.get(client).await + } + + pub(super) fn discovery_cache(&self) -> ExtensionApiDiscoveryCache { + self.discovery.clone() + } + + pub(super) fn spawn_cache_controller( + &self, + client: Client, + watch_client: Client, + config: &KubernetesComputeConfig, + ) { + let cache = self.cache.clone(); + let context = WarmPoolCacheControllerContext { + client, + fallback_namespace: config.namespace.clone(), + gateway_id: config.gateway_id.clone(), + extension_api_discovery: self.discovery.clone(), + multi_namespace: config.is_multi_namespace(), + }; + + tokio::spawn(async move { + run_warm_pool_cache_controller(cache, watch_client, context).await; + }); + } +} + +pub(super) const CLAIM_CREATE_RECONCILE_ATTEMPTS: usize = 3; +async fn list_generated_warm_pools_with_client( + client: Client, + fallback_namespace: &str, + gateway_id: &str, + multi_namespace: bool, +) -> Result, String> { + let lp = ListParams::default().labels(&owned_generated_warm_pool_label_selector(gateway_id)); + let warm_pool_api = if multi_namespace { + sandbox_warm_pool_api_all(client) + } else { + sandbox_warm_pool_api(client, fallback_namespace) + }; + let list = match tokio::time::timeout(KUBE_API_TIMEOUT, warm_pool_api.api.list(&lp)).await { + Ok(Ok(list)) => list, + Ok(Err(err)) if extension_api_unavailable(&err) => return Ok(Vec::new()), + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + let scope = if multi_namespace { + "cluster-wide" + } else { + fallback_namespace + }; + return Err(log_extension_api_permission_error( + SANDBOX_WARM_POOL_KIND, + "list", + scope, + &err, + )); + } + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => { + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + }; + + Ok(list + .items + .into_iter() + .filter_map(generated_warm_pool_from_object) + .collect()) +} + +async fn template_fingerprint_for_warm_pool_with_client( + client: Client, + pool: &GeneratedWarmPool, +) -> Result, String> { + let template_api = sandbox_template_api(client, &pool.template_namespace); + match tokio::time::timeout(KUBE_API_TIMEOUT, template_api.api.get(&pool.template_name)).await { + Ok(Ok(template)) => sandbox_template_fingerprint(&template).map(Some), + Ok(Err(KubeError::Api(err))) if err.code == 404 => Ok(None), + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + Err(log_extension_api_permission_error( + SANDBOX_TEMPLATE_KIND, + "get", + &pool.template_namespace, + &err, + )) + } + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )), + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum AllocationDecision { + Claimed, + UseDirectSandbox, +} + +impl WarmPoolManager { + pub(super) async fn try_allocate( + &self, + client: &Client, + config: &KubernetesComputeConfig, + sandbox: &Sandbox, + target_namespace: &str, + sandbox_template: &DriverSandboxTemplateRef, + rendered_sandbox: &serde_json::Value, + ) -> Result { + let request_fingerprint = match sandbox_spec_fingerprint(rendered_sandbox) { + Ok(fingerprint) => fingerprint, + Err(err) => { + info!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + error = %err, + "Could not fingerprint sandbox request for warm-pool matching; falling back to direct Sandbox" + ); + return Ok(AllocationDecision::UseDirectSandbox); + } + }; + let claimed = self + .try_create_claim( + client, + config, + sandbox, + target_namespace, + sandbox_template, + &request_fingerprint, + ) + .await?; + if claimed { + Ok(AllocationDecision::Claimed) + } else { + info!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + request_fingerprint, + "Warm-pool request fingerprint input" + ); + Ok(AllocationDecision::UseDirectSandbox) + } + } + + async fn matching_pool( + &self, + target_namespace: &str, + sandbox_template: &DriverSandboxTemplateRef, + request_fingerprint: &str, + ) -> Option { + match self + .cache + .matching_pool(target_namespace, sandbox_template, request_fingerprint) + .await + { + WarmPoolCacheLookup::NotReady => { + info!( + namespace = %target_namespace, + "Warm-pool cache is not ready; falling back to direct Sandbox" + ); + None + } + WarmPoolCacheLookup::NoMatch => { + let cached_warm_pool_templates = self + .cache + .entries_for_namespace(target_namespace) + .await + .into_iter() + .map(|entry| { + format!( + "{}/{}:{}", + entry.template_namespace, + entry.template_name, + entry.template_fingerprint + ) + }) + .collect::>(); + info!( + namespace = %target_namespace, + template_id = %sandbox_template.id, + template_name = %sandbox_template.name, + template_workspace = %sandbox_template.workspace, + template_resource_version = sandbox_template.resource_version, + request_fingerprint, + cached_warm_pool_template_count = cached_warm_pool_templates.len(), + cached_warm_pool_templates = ?cached_warm_pool_templates, + "No OpenShell-generated warm pool matches sandbox request; falling back to direct Sandbox" + ); + None + } + WarmPoolCacheLookup::Match(warm_pool) => { + debug!( + namespace = %target_namespace, + template_id = %sandbox_template.id, + template_name = %sandbox_template.name, + template_workspace = %sandbox_template.workspace, + template_resource_version = sandbox_template.resource_version, + request_fingerprint, + warm_pool = %warm_pool.name, + template_namespace = %warm_pool.template_namespace, + template = %warm_pool.template_name, + "OpenShell-generated warm pool matches sandbox request" + ); + Some(warm_pool) + } + WarmPoolCacheLookup::Ambiguous(count) => { + warn!( + namespace = %target_namespace, + count, + "Multiple OpenShell-generated warm pools match sandbox request; falling back to direct Sandbox" + ); + None + } + } + } + + async fn try_create_claim( + &self, + client: &Client, + config: &KubernetesComputeConfig, + sandbox: &Sandbox, + target_namespace: &str, + sandbox_template: &DriverSandboxTemplateRef, + request_fingerprint: &str, + ) -> Result { + if !self.allocation_enabled(config) { + return Ok(false); + } + let availability = self + .extension_api_availability(client) + .await + .map_err(KubernetesDriverError::Unavailable)?; + if !availability.supports_warm_allocation() { + return Ok(false); + } + + let Some(warm_pool) = self + .matching_pool(target_namespace, sandbox_template, request_fingerprint) + .await + else { + return Ok(false); + }; + + let claim_api = sandbox_claim_api(client.clone(), target_namespace); + let claim = + sandbox_claim_to_k8s_object(config, sandbox, &warm_pool.name, &claim_api.resource); + let create_result = tokio::time::timeout( + KUBE_API_TIMEOUT, + claim_api.api.create(&PostParams::default(), &claim), + ) + .await; + match create_result { + Ok(Ok(_result)) => { + info!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + warm_pool = %warm_pool.name, + "SandboxClaim created in Kubernetes successfully" + ); + Ok(true) + } + Ok(Err(err)) if extension_api_unavailable(&err) => { + self.discovery.invalidate().await; + debug!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + error = %err, + "SandboxClaim API is unavailable; falling back to direct Sandbox" + ); + Ok(false) + } + Ok(Err(err)) if kube_error_code(&err) == Some(403) => Err( + KubernetesDriverError::Precondition(log_extension_api_permission_error( + SANDBOX_CLAIM_KIND, + "create", + target_namespace, + &err, + )), + ), + Ok(Err(err)) if claim_create_result_is_ambiguous(&err) => { + warn!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + warm_pool = %warm_pool.name, + error = %err, + "SandboxClaim create result is ambiguous; reconciling by name" + ); + self.reconcile_claim_create(&claim_api.api, &claim).await + } + Ok(Err(err)) => { + warn!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + warm_pool = %warm_pool.name, + error = %err, + "SandboxClaim create was definitively rejected; falling back to direct Sandbox" + ); + Ok(false) + } + Err(_elapsed) => { + warn!( + sandbox_id = %sandbox.id, + sandbox_name = %sandbox.name, + namespace = %target_namespace, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Timed out creating SandboxClaim; reconciling by name" + ); + self.reconcile_claim_create(&claim_api.api, &claim).await + } + } + } + + async fn reconcile_claim_create( + &self, + api: &Api, + desired: &DynamicObject, + ) -> Result { + let name = desired.metadata.name.as_deref().ok_or_else(|| { + KubernetesDriverError::InvalidArgument("SandboxClaim name is required".to_string()) + })?; + + for attempt in 1..=CLAIM_CREATE_RECONCILE_ATTEMPTS { + match tokio::time::timeout(KUBE_API_TIMEOUT, api.get(name)).await { + Ok(Ok(existing)) => { + validate_existing_sandbox_claim(desired, &existing)?; + info!( + sandbox_claim = %name, + attempt, + "Reconciled existing SandboxClaim after ambiguous create" + ); + return Ok(true); + } + Ok(Err(KubeError::Api(err))) if err.code == 404 => { + debug!( + sandbox_claim = %name, + attempt, + "SandboxClaim not visible after ambiguous create; retrying idempotent create" + ); + match tokio::time::timeout( + KUBE_API_TIMEOUT, + api.create(&PostParams::default(), desired), + ) + .await + { + Ok(Ok(_)) => { + info!( + sandbox_claim = %name, + attempt, + "Created SandboxClaim while reconciling ambiguous create" + ); + return Ok(true); + } + Ok(Err(err)) if claim_create_result_is_ambiguous(&err) => { + debug!( + sandbox_claim = %name, + attempt, + error = %err, + "Idempotent SandboxClaim create remains ambiguous" + ); + } + Ok(Err(err)) => { + warn!( + sandbox_claim = %name, + attempt, + error = %err, + "Idempotent SandboxClaim create was rejected after an ambiguous write" + ); + } + Err(_) => { + warn!( + sandbox_claim = %name, + attempt, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Idempotent SandboxClaim create timed out" + ); + } + } + } + Ok(Err(err)) => { + warn!( + sandbox_claim = %name, + attempt, + error = %err, + "Failed to reconcile ambiguous SandboxClaim create" + ); + } + Err(_) => { + warn!( + sandbox_claim = %name, + attempt, + timeout_secs = KUBE_API_TIMEOUT.as_secs(), + "Timed out reconciling ambiguous SandboxClaim create" + ); + } + } + + if attempt < CLAIM_CREATE_RECONCILE_ATTEMPTS { + tokio::time::sleep(CLAIM_CREATE_RECONCILE_DELAY).await; + } + } + + Err(KubernetesDriverError::Unavailable(format!( + "SandboxClaim '{name}' create result remains unknown; preserving provisioning state for reconciliation" + ))) + } +} + +pub(super) const CLAIM_CREATE_RECONCILE_DELAY: Duration = Duration::from_millis(250); +pub(super) const WARM_POOL_CACHE_RETRY_DELAY: Duration = Duration::from_secs(10); +pub(super) const LABEL_WARM_POOL_ENABLED: &str = "openshell.ai/enabled"; +pub(super) const LABEL_ALLOCATION: &str = "openshell.ai/allocation"; +pub(super) const LABEL_ALLOCATION_SANDBOX_CLAIM: &str = "sandbox-claim"; +pub(super) const LABEL_WARM_POOL_TEMPLATE: &str = "openshell.ai/warm-pool-template"; +pub(super) const LABEL_WARM_POOL_TEMPLATE_ID: &str = "openshell.ai/warm-pool-template-id"; +pub(super) const LABEL_WARM_POOL_MANAGED_BY: &str = "openshell.ai/managed-by"; +pub(super) const LABEL_WARM_POOL_MANAGED_BY_VALUE: &str = "openshell-kubernetes-driver"; +pub(super) const ANNOTATION_WARM_POOL_TEMPLATE_NAME: &str = "openshell.ai/warm-pool-template-name"; +pub(super) const ANNOTATION_WARM_POOL_TEMPLATE_ID: &str = "openshell.ai/warm-pool-template-id"; +pub(super) const ANNOTATION_WARM_POOL_TEMPLATE_WORKSPACE: &str = + "openshell.ai/warm-pool-template-workspace"; +pub(super) const ANNOTATION_WARM_POOL_SOURCE_RESOURCE_VERSION: &str = + "openshell.ai/source-resource-version"; +pub(super) const ANNOTATION_WARM_POOL_TEMPLATE_FINGERPRINT: &str = + "openshell.ai/template-fingerprint"; +pub(super) const POD_ANNOTATION_SANDBOX_ID: &str = "openshell.ai/sandbox-id"; +pub(super) const WARM_POOL_TEMPLATE_NAME_PREFIX: &str = "openshell-wp"; +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) struct GeneratedWarmPool { + pub(super) namespace: String, + pub(super) name: String, + pub(super) template_namespace: String, + pub(super) template_name: String, + pub(super) source_template_id: String, + pub(super) source_template_name: String, + pub(super) source_template_workspace: String, + pub(super) source_template_resource_version: u64, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) struct WarmPoolCacheEntry { + pub(super) namespace: String, + pub(super) name: String, + pub(super) template_namespace: String, + pub(super) template_name: String, + pub(super) source_template_id: String, + pub(super) source_template_name: String, + pub(super) source_template_workspace: String, + pub(super) source_template_resource_version: u64, + pub(super) template_fingerprint: String, +} + +#[derive(Debug, Clone)] +pub(super) struct WarmPoolTemplateSource { + pub(super) name: String, + pub(super) id: String, + pub(super) workspace: String, + pub(super) resource_version: u64, +} + +#[derive(Debug, Clone)] +pub(super) struct RenderedWarmPoolTemplate { + pub(super) source: WarmPoolTemplateSource, + pub(super) gateway_id: String, + pub(super) target_namespace: String, + pub(super) generated_name: String, + pub(super) replicas: u32, + pub(super) template_spec: serde_json::Value, + pub(super) fingerprint: String, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct WarmPoolCache { + state: Arc>, +} + +#[derive(Debug, Default)] +pub(super) struct WarmPoolCacheState { + ready: bool, + entries: BTreeMap<(String, String), WarmPoolCacheEntry>, +} + +#[derive(Debug, Eq, PartialEq)] +pub(super) enum WarmPoolCacheLookup { + NotReady, + NoMatch, + Match(WarmPoolCacheEntry), + Ambiguous(usize), +} + +impl WarmPoolCache { + pub(super) async fn replace_entries(&self, entries: Vec) { + let mut state = self.state.write().await; + state.ready = true; + state.entries = entries + .into_iter() + .map(|entry| ((entry.namespace.clone(), entry.name.clone()), entry)) + .collect(); + } + + pub(super) async fn mark_not_ready(&self) { + let mut state = self.state.write().await; + state.ready = false; + state.entries.clear(); + } + + pub(super) async fn matching_pool( + &self, + namespace: &str, + sandbox_template: &DriverSandboxTemplateRef, + template_fingerprint: &str, + ) -> WarmPoolCacheLookup { + let state = self.state.read().await; + if !state.ready { + return WarmPoolCacheLookup::NotReady; + } + + let mut matches = state + .entries + .values() + .filter(|entry| { + entry.namespace == namespace + && entry.source_template_id == sandbox_template.id + && entry.source_template_name == sandbox_template.name + && entry.source_template_workspace == sandbox_template.workspace + && entry.source_template_resource_version == sandbox_template.resource_version + && entry.template_fingerprint == template_fingerprint + }) + .cloned() + .collect::>(); + + match matches.len() { + 0 => WarmPoolCacheLookup::NoMatch, + 1 => WarmPoolCacheLookup::Match(matches.pop().expect("one match exists")), + count => WarmPoolCacheLookup::Ambiguous(count), + } + } + + pub(super) async fn entries_for_namespace(&self, namespace: &str) -> Vec { + let state = self.state.read().await; + state + .entries + .values() + .filter(|entry| entry.namespace == namespace) + .cloned() + .collect() + } +} +pub(super) fn dynamic_sandbox_claim_watcher( + client: Client, + discovery: ExtensionApiDiscoveryCache, + namespace: String, + cluster_wide: bool, + selector: String, +) -> Pin> + Send>> { + let (tx, rx) = mpsc::channel(256); + + tokio::spawn(async move { + loop { + let availability = match discovery.get(&client).await { + Ok(availability) => availability, + Err(err) => { + warn!( + error = %err, + "Could not discover SandboxClaim API; claim watch deferred until retry" + ); + tokio::select! { + () = tx.closed() => return, + () = tokio::time::sleep(WARM_POOL_CACHE_RETRY_DELAY) => {} + } + continue; + } + }; + if !availability.sandbox_claim { + tokio::select! { + () = tx.closed() => return, + () = tokio::time::sleep(WARM_POOL_CACHE_RETRY_DELAY) => {} + } + continue; + } + + let claim_api = if cluster_wide { + sandbox_claim_api_all(client.clone()) + } else { + sandbox_claim_api(client.clone(), &namespace) + }; + let config = watcher::Config::default().labels(&selector); + let mut stream = watcher::watcher(claim_api.api, config).boxed(); + let mut retry = false; + while let Some(event) = stream.next().await { + match event { + Ok(event) => { + if tx.send(event).await.is_err() { + return; + } + } + Err(err) => { + let scope = if cluster_wide { + "cluster-wide" + } else { + namespace.as_str() + }; + match watcher_error_code(&err) { + Some(403) => error!( + api_group = EXTENSIONS_GROUP, + api_version = EXTENSIONS_VERSION_V1BETA1, + kind = SANDBOX_CLAIM_KIND, + operation = "list/watch", + scope, + error = %err, + "Kubernetes RBAC configuration error for Agent Sandbox extension API" + ), + Some(404) => discovery.invalidate().await, + _ => warn!( + scope, + error = %err, + "SandboxClaim watcher failed; retrying after discovery" + ), + } + retry = true; + break; + } + } + } + + if !retry { + warn!("SandboxClaim watcher ended; retrying after discovery"); + } + tokio::select! { + () = tx.closed() => return, + () = tokio::time::sleep(WARM_POOL_CACHE_RETRY_DELAY) => {} + } + } + }); + + Box::pin(ReceiverStream::new(rx)) +} +pub(super) struct WarmPoolCacheControllerContext { + pub(super) client: Client, + pub(super) fallback_namespace: String, + pub(super) gateway_id: String, + pub(super) extension_api_discovery: ExtensionApiDiscoveryCache, + pub(super) multi_namespace: bool, +} + +pub(super) async fn run_warm_pool_cache_controller( + cache: WarmPoolCache, + watch_client: Client, + context: WarmPoolCacheControllerContext, +) { + loop { + let availability = match context.extension_api_discovery.get(&context.client).await { + Ok(availability) => availability, + Err(err) => { + cache.mark_not_ready().await; + warn!( + namespace = %context.fallback_namespace, + error = %err, + "Could not discover Agent Sandbox extension APIs; warm-pool allocation disabled until retry" + ); + tokio::time::sleep(WARM_POOL_CACHE_RETRY_DELAY).await; + continue; + } + }; + if !availability.supports_warm_allocation() { + cache.mark_not_ready().await; + tokio::time::sleep(WARM_POOL_CACHE_RETRY_DELAY).await; + continue; + } + + refresh_warm_pool_cache(&cache, &context).await; + + let warm_pool_api = if context.multi_namespace { + sandbox_warm_pool_api_all(watch_client.clone()) + } else { + sandbox_warm_pool_api(watch_client.clone(), &context.fallback_namespace) + }; + let template_api = if context.multi_namespace { + sandbox_template_api_all(watch_client.clone()) + } else { + sandbox_template_api(watch_client.clone(), &context.fallback_namespace) + }; + + let watcher_config = watcher::Config::default().labels( + &owned_generated_warm_pool_label_selector(&context.gateway_id), + ); + let mut warm_pool_stream = + watcher::watcher(warm_pool_api.api, watcher_config.clone()).boxed(); + let mut template_stream = watcher::watcher(template_api.api, watcher_config).boxed(); + + loop { + tokio::select! { + event = warm_pool_stream.try_next() => { + if !handle_warm_pool_cache_watch_event( + &cache, + &context, + event, + SANDBOX_WARM_POOL_KIND, + ) + .await + { + break; + } + } + event = template_stream.try_next() => { + if !handle_warm_pool_cache_watch_event( + &cache, + &context, + event, + SANDBOX_TEMPLATE_KIND, + ) + .await + { + break; + } + } + } + } + + cache.mark_not_ready().await; + tokio::time::sleep(WARM_POOL_CACHE_RETRY_DELAY).await; + } +} + +pub(super) async fn handle_warm_pool_cache_watch_event( + cache: &WarmPoolCache, + context: &WarmPoolCacheControllerContext, + event: Result>, watcher::Error>, + kind: &str, +) -> bool { + match event { + Ok(Some(Event::Apply(_) | Event::Delete(_) | Event::InitDone)) => { + refresh_warm_pool_cache(cache, context).await; + true + } + Ok(Some(Event::Init | Event::InitApply(_))) => true, + Ok(None) => { + warn!( + kind, + "Warm-pool cache watch stream ended; warm-pool allocation disabled until retry" + ); + false + } + Err(err) => { + let scope = if context.multi_namespace { + "cluster-wide" + } else { + &context.fallback_namespace + }; + match watcher_error_code(&err) { + Some(403) => error!( + api_group = EXTENSIONS_GROUP, + api_version = EXTENSIONS_VERSION_V1BETA1, + kind, + operation = "list/watch", + scope, + error = %err, + "Kubernetes RBAC configuration error for Agent Sandbox extension API" + ), + Some(404) => context.extension_api_discovery.invalidate().await, + _ => warn!( + kind, + error = %err, + "Warm-pool cache watch failed; warm-pool allocation disabled until retry" + ), + } + false + } + } +} + +pub(super) async fn refresh_warm_pool_cache( + cache: &WarmPoolCache, + context: &WarmPoolCacheControllerContext, +) { + let pools = match list_generated_warm_pools_with_client( + context.client.clone(), + &context.fallback_namespace, + &context.gateway_id, + context.multi_namespace, + ) + .await + { + Ok(pools) => pools, + Err(err) => { + cache.mark_not_ready().await; + warn!( + namespace = %context.fallback_namespace, + error = %err, + "Failed to refresh warm-pool cache; warm-pool allocation disabled until retry" + ); + return; + } + }; + + let mut entries = Vec::new(); + for pool in pools { + let template_fingerprint = + match template_fingerprint_for_warm_pool_with_client(context.client.clone(), &pool) + .await + { + Ok(Some(fingerprint)) => fingerprint, + Ok(None) => { + debug!( + namespace = %pool.namespace, + warm_pool = %pool.name, + template_namespace = %pool.template_namespace, + template = %pool.template_name, + "Skipping warm pool with missing or unreadable SandboxTemplate" + ); + continue; + } + Err(err) => { + warn!( + namespace = %pool.namespace, + warm_pool = %pool.name, + template_namespace = %pool.template_namespace, + template = %pool.template_name, + error = %err, + "Skipping warm pool after SandboxTemplate fingerprint failed" + ); + continue; + } + }; + + debug!( + namespace = %pool.namespace, + warm_pool = %pool.name, + template_namespace = %pool.template_namespace, + template = %pool.template_name, + template_fingerprint = %template_fingerprint, + "Cached OpenShell-generated warm-pool template fingerprint" + ); + + entries.push(WarmPoolCacheEntry { + namespace: pool.namespace, + name: pool.name, + template_namespace: pool.template_namespace, + template_name: pool.template_name, + source_template_id: pool.source_template_id, + source_template_name: pool.source_template_name, + source_template_workspace: pool.source_template_workspace, + source_template_resource_version: pool.source_template_resource_version, + template_fingerprint, + }); + } + + let count = entries.len(); + cache.replace_entries(entries).await; + debug!( + namespace = %context.fallback_namespace, + count, + "Warm-pool cache refreshed" + ); +} + +pub(super) async fn render_warm_pool_template( + client: Client, + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + template: &DriverSandboxTemplateResource, +) -> Result, String> { + if !config.warm_pooling.enabled { + return Ok(None); + } + validate_warm_pool_template(template)?; + if !template_requires_warm_pool( + template + .desired_service_level + .as_ref() + .and_then(|slo| slo.startup.as_ref()), + config + .warm_pooling + .templates + .effective_ready_within_threshold(), + )? { + return Ok(None); + } + + let target_namespace = + config.namespace_for_workspace(&template.workspace, operator_allowlist)?; + let spec = warm_pool_template_to_sandbox_spec(template)?; + let replicas = requested_warm_pool_replicas( + template + .desired_service_level + .as_ref() + .and_then(|slo| slo.startup.as_ref()), + config.warm_pooling.templates.effective_max_replicas(), + ); + let (template_user_id, template_group_id, _) = + resolve_sandbox_identity_for_config(client, config, &target_namespace).await; + let params = SandboxPodParams { + default_image: &config.default_image, + image_pull_policy: &config.image_pull_policy, + image_pull_secrets: &config.image_pull_secrets, + supervisor_image: &config.supervisor_image, + supervisor_image_pull_policy: &config.supervisor_image_pull_policy, + supervisor_sideload_method: config.supervisor_sideload_method, + topology: config.topology, + proxy_uid: config.sidecar.proxy_uid, + process_binary_aware_network_policy: config.sidecar.process_binary_aware_network_policy, + https_proxy: config.https_proxy.as_deref(), + no_proxy: config.no_proxy.as_deref(), + proxy_auth_secret_name: config.proxy_auth_secret_name.as_deref(), + proxy_auth_secret_key: config.proxy_auth_secret_key.as_deref(), + proxy_auth_allow_insecure: config.proxy_auth_allow_insecure == Some(true), + proxy_connect_by_hostname: config.proxy_connect_by_hostname == Some(true), + service_account_name: &config.service_account_name, + sandbox_id: "", + sandbox_name: "", + grpc_endpoint: &config.grpc_endpoint, + ssh_socket_path: &config.ssh_socket_path, + client_tls_secret_name: &config.client_tls_secret_name, + host_gateway_ip: &config.host_gateway_ip, + enable_user_namespaces: config.enable_user_namespaces, + app_armor_profile: config.app_armor_profile.as_ref(), + workspace_default_storage_size: &config.workspace_default_storage_size, + workspace_storage_class: &config.workspace_storage_class, + default_runtime_class_name: &config.default_runtime_class_name, + sa_token_ttl_secs: config.effective_sa_token_ttl_secs(), + provider_spiffe_enabled: config.provider_spiffe_enabled(), + provider_spiffe_workload_api_socket_path: &config.provider_spiffe_workload_api_socket_path, + sandbox_uid: template_user_id, + sandbox_gid: template_group_id, + }; + validate_sidecar_proxy_identity(¶ms).map_err(|err| err.to_string())?; + + let mut rendered = sandbox_to_k8s_spec(Some(&spec), ¶ms)?; + remove_warm_pool_template_identity_env(&mut rendered); + ensure_pod_dns_policy(&mut rendered); + ensure_network_policy_management_unmanaged(&mut rendered); + let fingerprint = sandbox_spec_fingerprint(&rendered)?; + let generated_name = + generated_warm_pool_template_name(&template.name, &template.id, &fingerprint); + validate_kubernetes_dns1123_label(&generated_name, "generated warm-pool resource name")?; + let template_spec = rendered + .get("spec") + .cloned() + .ok_or_else(|| "rendered sandbox spec is missing spec".to_string())?; + let source = WarmPoolTemplateSource { + name: template.name.clone(), + id: template.id.clone(), + workspace: template.workspace.clone(), + resource_version: template.resource_version, + }; + + Ok(Some(RenderedWarmPoolTemplate { + source, + gateway_id: config.gateway_id.clone(), + target_namespace, + generated_name, + replicas, + template_spec, + fingerprint, + })) +} + +pub(super) fn validate_warm_pool_template( + template: &DriverSandboxTemplateResource, +) -> Result<(), String> { + if template.id.trim().is_empty() { + return Err("template id must not be empty".to_string()); + } + if template.name.trim().is_empty() { + return Err("template name must not be empty".to_string()); + } + if template.workspace.trim().is_empty() { + return Err("workspace must not be empty".to_string()); + } + if template.workspace.chars().any(char::is_control) { + return Err("workspace must not contain control characters".to_string()); + } + validate_warm_pool_template_environment( + &template + .template + .as_ref() + .map_or_else(BTreeMap::new, |runtime_template| { + runtime_template.environment.clone().into_iter().collect() + }), + )?; + if let Some(count) = template + .resource_requirements + .as_ref() + .and_then(|requirements| requirements.gpu.as_ref()) + .and_then(|gpu| gpu.count) + && count == 0 + { + return Err("resource_requirements.gpu.count must be greater than 0".to_string()); + } + Ok(()) +} + +pub(super) fn validate_warm_pool_template_environment( + env: &BTreeMap, +) -> Result<(), String> { + for (key, value) in env { + if !is_valid_env_key(key) { + return Err(format!( + "environment keys must match ^[A-Za-z_][A-Za-z0-9_]*$; got '{key}'" + )); + } + if key.starts_with("OPENSHELL_") { + return Err(format!( + "environment keys starting with OPENSHELL_ are reserved; got '{key}'" + )); + } + if value.chars().any(char::is_control) { + return Err(format!( + "environment value for '{key}' must not contain control characters" + )); + } + } + Ok(()) +} + +pub(super) fn is_valid_env_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +pub(super) fn template_requires_warm_pool( + startup: Option<&DriverSandboxTemplateStartup>, + threshold: Duration, +) -> Result { + let Some(ready_within) = startup.and_then(|startup| startup.ready_within.as_ref()) else { + return Ok(false); + }; + let ready_within = prost_duration_to_std(ready_within)?; + Ok(ready_within < threshold) +} + +pub(super) fn prost_duration_to_std(duration: &prost_types::Duration) -> Result { + if duration.seconds < 0 || duration.nanos < 0 { + return Err("ready_within must not be negative".to_string()); + } + let seconds = u64::try_from(duration.seconds) + .map_err(|_| "ready_within seconds exceed supported range".to_string())?; + let nanos = u32::try_from(duration.nanos) + .map_err(|_| "ready_within nanos exceed supported range".to_string())?; + Duration::from_secs(seconds) + .checked_add(Duration::from_nanos(u64::from(nanos))) + .ok_or_else(|| "ready_within exceeds supported range".to_string()) +} + +pub(super) fn requested_warm_pool_replicas( + startup: Option<&DriverSandboxTemplateStartup>, + max_replicas: u32, +) -> u32 { + let requested = startup.map_or(1, |startup| startup.max_burst).max(1); + requested.min(max_replicas) +} + +pub(super) fn warm_pool_template_to_sandbox_spec( + template: &DriverSandboxTemplateResource, +) -> Result { + let runtime_template = template + .template + .clone() + .ok_or_else(|| "template runtime template is required".to_string())?; + Ok(SandboxSpec { + environment: runtime_template.environment.clone(), + template: Some(runtime_template), + resource_requirements: template.resource_requirements, + ..Default::default() + }) +} + +pub(super) fn ensure_pod_dns_policy(rendered: &mut serde_json::Value) { + if let Some(spec) = rendered + .pointer_mut("/spec/podTemplate/spec") + .and_then(serde_json::Value::as_object_mut) + { + spec.entry("dnsPolicy".to_string()) + .or_insert_with(|| serde_json::json!("ClusterFirst")); + } +} + +pub(super) fn ensure_network_policy_management_unmanaged(rendered: &mut serde_json::Value) { + if let Some(spec) = rendered + .get_mut("spec") + .and_then(serde_json::Value::as_object_mut) + { + spec.insert( + "networkPolicyManagement".to_string(), + serde_json::json!("Unmanaged"), + ); + } +} + +pub(super) fn remove_warm_pool_template_identity_env(rendered: &mut serde_json::Value) { + if let Some(containers) = rendered + .pointer_mut("/spec/podTemplate/spec/containers") + .and_then(serde_json::Value::as_array_mut) + { + for container in containers { + remove_sandbox_identity_env(container); + } + } +} + +pub(super) fn generated_warm_pool_template_name( + template_name: &str, + template_id: &str, + fingerprint: &str, +) -> String { + let fingerprint_suffix = fingerprint + .strip_prefix("sha256:") + .unwrap_or(fingerprint) + .chars() + .filter(char::is_ascii_hexdigit) + .take(8) + .collect::() + .to_ascii_lowercase(); + let fingerprint_suffix = if fingerprint_suffix.is_empty() { + "00000000".to_string() + } else { + fingerprint_suffix + }; + // Template IDs are globally unique across workspaces. Keep an identity + // segment separate from the workload fingerprint so same-named templates + // with identical specs cannot address the same resource in shared mode. + let identity_suffix = hex_encode(&Sha256::digest(template_id.as_bytes())) + .chars() + .take(16) + .collect::(); + let prefix = sanitize_dns_label_segment(template_name); + let reserved = + WARM_POOL_TEMPLATE_NAME_PREFIX.len() + 3 + identity_suffix.len() + fingerprint_suffix.len(); + let max_prefix_len = MAX_KUBE_NAME_LEN.saturating_sub(reserved); + let mut trimmed = prefix.chars().take(max_prefix_len).collect::(); + trimmed = trimmed.trim_matches('-').to_string(); + if trimmed.is_empty() { + trimmed = "template".to_string(); + } + format!("{WARM_POOL_TEMPLATE_NAME_PREFIX}-{trimmed}-{identity_suffix}-{fingerprint_suffix}") +} + +pub(super) fn sanitize_dns_label_segment(value: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + for byte in value.bytes() { + let ch = if byte.is_ascii_lowercase() || byte.is_ascii_digit() { + byte as char + } else if byte.is_ascii_uppercase() { + (byte as char).to_ascii_lowercase() + } else { + '-' + }; + if ch == '-' { + if !last_dash { + out.push(ch); + } + last_dash = true; + } else { + out.push(ch); + last_dash = false; + } + } + let trimmed = out.trim_matches('-').to_string(); + if trimmed.is_empty() { + "template".to_string() + } else { + trimmed + } +} + +pub(super) async fn apply_rendered_warm_pool_template( + client: Client, + rendered: &RenderedWarmPoolTemplate, +) -> Result<(), String> { + let template_api = sandbox_template_api(client.clone(), &rendered.target_namespace); + let warm_pool_api = sandbox_warm_pool_api(client.clone(), &rendered.target_namespace); + let template = rendered_warm_pool_template_object(rendered, &template_api.resource); + let warm_pool = rendered_warm_pool_object(rendered, &warm_pool_api.resource); + apply_dynamic_object( + &template_api.api, + &rendered.generated_name, + &template, + SANDBOX_TEMPLATE_KIND, + &rendered.target_namespace, + ) + .await?; + apply_dynamic_object( + &warm_pool_api.api, + &rendered.generated_name, + &warm_pool, + SANDBOX_WARM_POOL_KIND, + &rendered.target_namespace, + ) + .await?; + Ok(()) +} + +pub(super) async fn apply_dynamic_object( + api: &Api, + name: &str, + obj: &DynamicObject, + kind: &str, + namespace: &str, +) -> Result<(), String> { + match tokio::time::timeout(KUBE_API_TIMEOUT, api.create(&PostParams::default(), obj)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(KubeError::Api(err))) if err.code == 409 => { + let patch = dynamic_object_merge_patch(obj); + match tokio::time::timeout( + KUBE_API_TIMEOUT, + api.patch(name, &PatchParams::default(), &Patch::Merge(&patch)), + ) + .await + { + Ok(Ok(_)) => Ok(()), + Ok(Err(err)) if kube_error_code(&err) == Some(403) => Err( + log_extension_api_permission_error(kind, "patch", namespace, &err), + ), + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )), + } + } + Ok(Err(err)) if kube_error_code(&err) == Some(403) => Err( + log_extension_api_permission_error(kind, "create", namespace, &err), + ), + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )), + } +} + +pub(super) fn dynamic_object_merge_patch(obj: &DynamicObject) -> serde_json::Value { + serde_json::json!({ + "metadata": { + "labels": obj.metadata.labels, + "annotations": obj.metadata.annotations, + }, + "spec": obj.data.get("spec").cloned().unwrap_or_else(|| serde_json::json!({})), + }) +} + +pub(super) fn rendered_warm_pool_template_object( + rendered: &RenderedWarmPoolTemplate, + resource: &ApiResource, +) -> DynamicObject { + let mut obj = DynamicObject::new(&rendered.generated_name, resource); + obj.metadata = ObjectMeta { + name: Some(rendered.generated_name.clone()), + namespace: Some(rendered.target_namespace.clone()), + labels: Some(warm_pool_template_generated_labels(rendered)), + annotations: Some(warm_pool_template_generated_annotations(rendered)), + ..Default::default() + }; + obj.data = serde_json::json!({ + "spec": rendered.template_spec, + }); + obj +} + +pub(super) fn rendered_warm_pool_object( + rendered: &RenderedWarmPoolTemplate, + resource: &ApiResource, +) -> DynamicObject { + let mut obj = DynamicObject::new(&rendered.generated_name, resource); + obj.metadata = ObjectMeta { + name: Some(rendered.generated_name.clone()), + namespace: Some(rendered.target_namespace.clone()), + labels: Some(warm_pool_template_generated_labels(rendered)), + annotations: Some(warm_pool_template_generated_annotations(rendered)), + ..Default::default() + }; + obj.data = serde_json::json!({ + "spec": { + "replicas": rendered.replicas, + "sandboxTemplateRef": { + "name": rendered.generated_name + } + } + }); + obj +} + +pub(super) fn warm_pool_template_generated_labels( + rendered: &RenderedWarmPoolTemplate, +) -> BTreeMap { + BTreeMap::from([ + (LABEL_WARM_POOL_ENABLED.to_string(), "true".to_string()), + ( + LABEL_WARM_POOL_MANAGED_BY.to_string(), + LABEL_WARM_POOL_MANAGED_BY_VALUE.to_string(), + ), + (LABEL_GATEWAY_ID.to_string(), rendered.gateway_id.clone()), + ( + LABEL_WARM_POOL_TEMPLATE.to_string(), + label_value_for_template_name(&rendered.source.name), + ), + ( + LABEL_WARM_POOL_TEMPLATE_ID.to_string(), + label_value_for_template_id(&rendered.source.id), + ), + ]) +} + +pub(super) fn label_value_for_template_name(name: &str) -> String { + let sanitized = sanitize_dns_label_segment(name); + let mut value = sanitized.chars().take(63).collect::(); + value = value.trim_matches('-').to_string(); + if value.is_empty() { + "template".to_string() + } else { + value + } +} + +pub(super) fn label_value_for_template_id(id: &str) -> String { + let sanitized = sanitize_dns_label_segment(id); + let mut value = sanitized.chars().take(63).collect::(); + value = value.trim_matches('-').to_string(); + if value.is_empty() { + "template".to_string() + } else { + value + } +} + +pub(super) fn warm_pool_template_generated_annotations( + rendered: &RenderedWarmPoolTemplate, +) -> BTreeMap { + BTreeMap::from([ + ( + ANNOTATION_WARM_POOL_TEMPLATE_NAME.to_string(), + rendered.source.name.clone(), + ), + ( + ANNOTATION_WARM_POOL_TEMPLATE_ID.to_string(), + rendered.source.id.clone(), + ), + ( + ANNOTATION_WARM_POOL_TEMPLATE_WORKSPACE.to_string(), + rendered.source.workspace.clone(), + ), + ( + ANNOTATION_WARM_POOL_SOURCE_RESOURCE_VERSION.to_string(), + rendered.source.resource_version.to_string(), + ), + ( + ANNOTATION_WARM_POOL_TEMPLATE_FINGERPRINT.to_string(), + rendered.fingerprint.clone(), + ), + ( + LABEL_SANDBOX_WORKSPACE.to_string(), + rendered.source.workspace.clone(), + ), + ]) +} + +pub(super) async fn garbage_collect_superseded_warm_pool_template_resources( + client: Client, + rendered: &RenderedWarmPoolTemplate, +) -> Result<(), String> { + garbage_collect_warm_pool_template_resources( + client, + &rendered.target_namespace, + &rendered.source.id, + Some(&rendered.generated_name), + ) + .await +} + +pub(super) async fn garbage_collect_warm_pool_template( + client: Client, + config: &KubernetesComputeConfig, + operator_allowlist: Option<&OperatorNamespaceAllowlist>, + template_id: &str, + workspace: &str, +) -> Result<(), String> { + if template_id.is_empty() { + return Ok(()); + } + let namespace = config.namespace_for_workspace(workspace, operator_allowlist)?; + garbage_collect_warm_pool_template_resources(client, &namespace, template_id, None).await +} + +pub(super) async fn garbage_collect_warm_pool_template_resources( + client: Client, + target_namespace: &str, + template_id: &str, + keep_name: Option<&str>, +) -> Result<(), String> { + let selector = format!( + "{LABEL_WARM_POOL_MANAGED_BY}={LABEL_WARM_POOL_MANAGED_BY_VALUE},{LABEL_WARM_POOL_TEMPLATE_ID}={}", + label_value_for_template_id(template_id) + ); + let lp = ListParams::default().labels(&selector); + let warm_pool_api = sandbox_warm_pool_api(client.clone(), target_namespace); + delete_matching_dynamic_objects( + &warm_pool_api.api, + &lp, + keep_name, + SANDBOX_WARM_POOL_KIND, + target_namespace, + ) + .await?; + let template_api = sandbox_template_api(client, target_namespace); + delete_matching_dynamic_objects( + &template_api.api, + &lp, + keep_name, + SANDBOX_TEMPLATE_KIND, + target_namespace, + ) + .await?; + Ok(()) +} + +pub(super) async fn prune_stale_warm_pool_template_resources( + client: Client, + config: &KubernetesComputeConfig, + desired_ids: &HashSet<&str>, +) -> Result { + let selector = owned_generated_warm_pool_label_selector(&config.gateway_id); + let lp = ListParams::default().labels(&selector); + let mut pruned = 0usize; + for kind in [SANDBOX_WARM_POOL_KIND, SANDBOX_TEMPLATE_KIND] { + let extension_api = if config.is_multi_namespace() { + all_extension_api(client.clone(), kind) + } else { + namespaced_extension_api(client.clone(), &config.namespace, kind) + }; + let objects = + match tokio::time::timeout(KUBE_API_TIMEOUT, extension_api.api.list(&lp)).await { + Ok(Ok(list)) => list.items, + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + let scope = if config.is_multi_namespace() { + "cluster-wide" + } else { + config.namespace.as_str() + }; + return Err(log_extension_api_permission_error( + kind, "list", scope, &err, + )); + } + Ok(Err(KubeError::Api(err))) if err.code == 404 => Vec::new(), + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => return Err(kubernetes_api_timeout_message("listing", kind)), + }; + + for object in objects { + let Some(template_id) = object + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(ANNOTATION_WARM_POOL_TEMPLATE_ID)) + else { + continue; + }; + if desired_ids.contains(template_id.as_str()) { + continue; + } + let (Some(namespace), Some(name)) = ( + object.metadata.namespace.as_deref(), + object.metadata.name.as_deref(), + ) else { + continue; + }; + let namespaced_api = namespaced_extension_api(client.clone(), namespace, kind); + match tokio::time::timeout( + KUBE_API_TIMEOUT, + namespaced_api.api.delete(name, &DeleteParams::default()), + ) + .await + { + Ok(Ok(_)) => { + pruned = pruned.saturating_add(1); + } + Ok(Err(KubeError::Api(err))) if err.code == 404 => { + pruned = pruned.saturating_add(1); + } + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + return Err(log_extension_api_permission_error( + kind, "delete", namespace, &err, + )); + } + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => return Err(kubernetes_api_timeout_message("deleting", kind)), + } + } + } + Ok(pruned) +} + +pub(super) fn kubernetes_api_timeout_message(action: &str, kind: &str) -> String { + format!( + "timed out after {}s {action} {kind} resources", + KUBE_API_TIMEOUT.as_secs() + ) +} + +pub(super) async fn delete_matching_dynamic_objects( + api: &Api, + lp: &ListParams, + keep_name: Option<&str>, + kind: &str, + namespace: &str, +) -> Result<(), String> { + let list = match tokio::time::timeout(KUBE_API_TIMEOUT, api.list(lp)).await { + Ok(Ok(list)) => list, + Ok(Err(KubeError::Api(err))) if err.code == 404 => return Ok(()), + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + return Err(log_extension_api_permission_error( + kind, "list", namespace, &err, + )); + } + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => { + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + }; + for obj in list.items { + let Some(name) = obj.metadata.name.as_deref() else { + continue; + }; + if keep_name == Some(name) { + continue; + } + match tokio::time::timeout(KUBE_API_TIMEOUT, api.delete(name, &DeleteParams::default())) + .await + { + Ok(Ok(_)) => {} + Ok(Err(KubeError::Api(err))) if err.code == 404 => {} + Ok(Err(err)) if kube_error_code(&err) == Some(403) => { + return Err(log_extension_api_permission_error( + kind, "delete", namespace, &err, + )); + } + Ok(Err(err)) => return Err(err.to_string()), + Err(_) => { + return Err(format!( + "timed out after {}s waiting for Kubernetes API", + KUBE_API_TIMEOUT.as_secs() + )); + } + } + } + Ok(()) +} + +pub(super) fn sandbox_claim_to_k8s_object( + config: &KubernetesComputeConfig, + sandbox: &Sandbox, + warm_pool_name: &str, + resource: &ApiResource, +) -> DynamicObject { + let kube_name = config.kube_resource_name(&sandbox.workspace, &sandbox.name); + let mut obj = DynamicObject::new(&kube_name, resource); + let mut labels = sandbox_labels(sandbox, Some(&config.gateway_id)); + labels.insert( + LABEL_ALLOCATION.to_string(), + LABEL_ALLOCATION_SANDBOX_CLAIM.to_string(), + ); + obj.metadata = ObjectMeta { + name: Some(kube_name), + labels: Some(labels), + annotations: Some(sandbox_annotations(sandbox)), + ..Default::default() + }; + obj.data = serde_json::json!({ + "spec": { + "lifecycle": { + "shutdownPolicy": "Delete" + }, + "warmPoolRef": { + "name": warm_pool_name + } + } + }); + obj +} + +pub(super) fn claim_create_result_is_ambiguous(err: &KubeError) -> bool { + match err { + KubeError::Api(response) => { + response.code == 408 || response.code == 409 || response.code >= 500 + } + _ => true, + } +} + +pub(super) fn validate_existing_sandbox_claim( + desired: &DynamicObject, + existing: &DynamicObject, +) -> Result<(), KubernetesDriverError> { + let desired_name = desired.metadata.name.as_deref().unwrap_or_default(); + let existing_name = existing.metadata.name.as_deref().unwrap_or_default(); + if desired_name.is_empty() || existing_name != desired_name { + return Err(KubernetesDriverError::Precondition(format!( + "existing SandboxClaim name '{existing_name}' does not match requested claim '{desired_name}'" + ))); + } + + for key in [ + LABEL_MANAGED_BY, + LABEL_GATEWAY_ID, + LABEL_SANDBOX_ID, + LABEL_SANDBOX_NAME, + LABEL_SANDBOX_WORKSPACE, + LABEL_ALLOCATION, + ] { + let expected = desired + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(key)) + .map(String::as_str) + .unwrap_or_default(); + let actual = existing + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(key)) + .map(String::as_str) + .unwrap_or_default(); + if expected.is_empty() || actual != expected { + return Err(KubernetesDriverError::Precondition(format!( + "existing SandboxClaim '{desired_name}' has conflicting {key} metadata" + ))); + } + } + + let expected_pool = string_at(&desired.data, &["spec", "warmPoolRef", "name"]); + let actual_pool = string_at(&existing.data, &["spec", "warmPoolRef", "name"]); + if expected_pool.is_none() || actual_pool != expected_pool { + return Err(KubernetesDriverError::Precondition(format!( + "existing SandboxClaim '{desired_name}' targets a different warm pool" + ))); + } + + Ok(()) +} + +pub(super) fn generated_warm_pool_label_selector() -> String { + format!( + "{LABEL_WARM_POOL_ENABLED}=true,{LABEL_WARM_POOL_MANAGED_BY}={LABEL_WARM_POOL_MANAGED_BY_VALUE}" + ) +} + +pub(super) fn owned_generated_warm_pool_label_selector(gateway_id: &str) -> String { + format!( + "{},{LABEL_GATEWAY_ID}={gateway_id}", + generated_warm_pool_label_selector() + ) +} + +pub(super) fn generated_warm_pool_from_object(obj: DynamicObject) -> Option { + let namespace = obj.metadata.namespace.clone()?; + let name = obj.metadata.name.clone()?; + let annotations = obj.metadata.annotations.as_ref()?; + let template_name = string_at(&obj.data, &["spec", "sandboxTemplateRef", "name"])?; + let template_namespace = string_at(&obj.data, &["spec", "sandboxTemplateRef", "namespace"]) + .unwrap_or_else(|| namespace.clone()); + let source_template_id = annotations + .get(ANNOTATION_WARM_POOL_TEMPLATE_ID) + .filter(|value| !value.is_empty())? + .clone(); + let source_template_name = annotations + .get(ANNOTATION_WARM_POOL_TEMPLATE_NAME) + .filter(|value| !value.is_empty())? + .clone(); + let source_template_workspace = annotations + .get(ANNOTATION_WARM_POOL_TEMPLATE_WORKSPACE) + .filter(|value| !value.is_empty())? + .clone(); + let source_template_resource_version = annotations + .get(ANNOTATION_WARM_POOL_SOURCE_RESOURCE_VERSION) + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0)?; + Some(GeneratedWarmPool { + namespace, + name, + template_namespace, + template_name, + source_template_id, + source_template_name, + source_template_workspace, + source_template_resource_version, + }) +} + +pub(super) fn sandbox_template_fingerprint(obj: &DynamicObject) -> Result { + sandbox_spec_fingerprint(&obj.data) +} + +pub(super) fn sandbox_spec_fingerprint(data: &serde_json::Value) -> Result { + let spec = data + .get("spec") + .ok_or_else(|| "object is missing spec".to_string())?; + let mut normalized = spec.clone(); + normalize_template_spec_for_fingerprint(&mut normalized); + stable_json_fingerprint(&normalized) +} + +pub(super) fn normalize_template_spec_for_fingerprint(value: &mut serde_json::Value) { + remove_path_if_value( + value, + &["envVarsInjectionPolicy"], + &serde_json::json!("Disallowed"), + ); + remove_path_if_value( + value, + &["networkPolicyManagement"], + &serde_json::json!("Unmanaged"), + ); + remove_path_if_value(value, &["operatingMode"], &serde_json::json!("Running")); + remove_path_if_value(value, &["replicas"], &serde_json::json!(1)); + remove_path_if_value(value, &["shutdownPolicy"], &serde_json::json!("Retain")); + remove_path_if_value( + value, + &["podTemplate", "spec", "dnsPolicy"], + &serde_json::json!("ClusterFirst"), + ); + remove_path_if_value( + value, + &["volumeClaimTemplatesPolicy"], + &serde_json::json!("Disallowed"), + ); + remove_path( + value, + &[ + "podTemplate", + "metadata", + "annotations", + POD_ANNOTATION_SANDBOX_ID, + ], + ); + remove_path( + value, + &["podTemplate", "metadata", "labels", LABEL_SANDBOX_ID], + ); + remove_empty_object_path(value, &["podTemplate", "metadata", "annotations"]); + remove_empty_object_path(value, &["podTemplate", "metadata", "labels"]); + remove_empty_object_path(value, &["podTemplate", "metadata"]); + if let Some(containers) = value + .pointer_mut("/podTemplate/spec/containers") + .and_then(serde_json::Value::as_array_mut) + { + for container in containers { + remove_sandbox_identity_env(container); + remove_empty_container_resources(container); + remove_default_volume_mount_read_only(container); + } + } + if let Some(init_containers) = value + .pointer_mut("/podTemplate/spec/initContainers") + .and_then(serde_json::Value::as_array_mut) + { + for container in init_containers { + remove_empty_container_resources(container); + remove_default_volume_mount_read_only(container); + } + } +} + +pub(super) fn remove_sandbox_identity_env(container: &mut serde_json::Value) { + let Some(env) = container + .get_mut("env") + .and_then(serde_json::Value::as_array_mut) + else { + return; + }; + env.retain(|entry| { + !matches!( + entry.get("name").and_then(serde_json::Value::as_str), + Some(name) + if name == openshell_core::sandbox_env::SANDBOX_ID + || name == openshell_core::sandbox_env::SANDBOX + || name == openshell_core::sandbox_env::MAIN_PROCESS_SPEC + ) + }); +} + +pub(super) fn remove_path(value: &mut serde_json::Value, path: &[&str]) { + let Some((last, parents)) = path.split_last() else { + return; + }; + let mut current = value; + for key in parents { + let Some(next) = current.get_mut(*key) else { + return; + }; + current = next; + } + if let Some(object) = current.as_object_mut() { + object.remove(*last); + } +} + +pub(super) fn remove_path_if_value( + value: &mut serde_json::Value, + path: &[&str], + expected: &serde_json::Value, +) { + let Some((last, parents)) = path.split_last() else { + return; + }; + let mut current = value; + for key in parents { + let Some(next) = current.get_mut(*key) else { + return; + }; + current = next; + } + if current.get(*last) == Some(expected) + && let Some(object) = current.as_object_mut() + { + object.remove(*last); + } +} + +pub(super) fn remove_empty_container_resources(container: &mut serde_json::Value) { + if container + .get("resources") + .is_some_and(|resources| resources.as_object().is_some_and(serde_json::Map::is_empty)) + && let Some(object) = container.as_object_mut() + { + object.remove("resources"); + } +} + +pub(super) fn remove_default_volume_mount_read_only(container: &mut serde_json::Value) { + let Some(volume_mounts) = container + .get_mut("volumeMounts") + .and_then(serde_json::Value::as_array_mut) + else { + return; + }; + for mount in volume_mounts { + remove_path_if_value(mount, &["readOnly"], &serde_json::json!(false)); + } +} + +pub(super) fn remove_empty_object_path(value: &mut serde_json::Value, path: &[&str]) { + let Some((last, parents)) = path.split_last() else { + return; + }; + let mut current = value; + for key in parents { + let Some(next) = current.get_mut(*key) else { + return; + }; + current = next; + } + if current + .get(*last) + .is_some_and(|entry| entry.as_object().is_some_and(serde_json::Map::is_empty)) + && let Some(object) = current.as_object_mut() + { + object.remove(*last); + } +} + +pub(super) fn stable_json_fingerprint(value: &serde_json::Value) -> Result { + let canonical = canonical_json_value(value); + let bytes = serde_json::to_vec(&canonical) + .map_err(|err| format!("failed to serialize canonical template: {err}"))?; + let digest = Sha256::digest(bytes); + Ok(hex_encode(&digest)) +} + +pub(super) fn canonical_json_value(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(canonical_json_value).collect()) + } + serde_json::Value::Object(object) => { + let mut sorted = serde_json::Map::new(); + let mut keys = object.keys().collect::>(); + keys.sort(); + for key in keys { + if let Some(value) = object.get(key) { + sorted.insert(key.clone(), canonical_json_value(value)); + } + } + serde_json::Value::Object(sorted) + } + other => other.clone(), + } +} + +pub(super) fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +pub(super) fn string_at(data: &serde_json::Value, path: &[&str]) -> Option { + let mut current = data; + for key in path { + current = current.get(*key)?; + } + current + .as_str() + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +pub(super) fn sandbox_from_claim_object( + default_namespace: &str, + obj: DynamicObject, +) -> Result { + let kube_name = obj.metadata.name.clone().unwrap_or_default(); + if !is_openshell_managed(&obj) { + debug!(object = %kube_name, "skipping SandboxClaim not managed by openshell"); + return Err(format!("SandboxClaim {kube_name} not managed by openshell")); + } + let id = sandbox_id_from_object(&obj)?; + let Some(name) = annotation_or_label(&obj, LABEL_SANDBOX_NAME) else { + return Err(format!("SandboxClaim {kube_name} missing sandbox name")); + }; + let Some(workspace) = annotation_or_label(&obj, LABEL_SANDBOX_WORKSPACE) else { + return Err(format!( + "SandboxClaim {kube_name} missing sandbox workspace" + )); + }; + let namespace = obj + .metadata + .namespace + .clone() + .unwrap_or_else(|| default_namespace.to_string()); + Ok(Sandbox { + id, + name, + namespace, + spec: None, + status: Some(claim_status_from_object(&obj)), + workspace, + }) +} + +pub(super) fn claim_status_from_object(obj: &DynamicObject) -> SandboxStatus { + let status_obj = obj + .data + .get("status") + .and_then(serde_json::Value::as_object); + let sandbox_name = status_obj + .and_then(|status| status.get("sandbox")) + .and_then(|value| value.get("name")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let instance_id = string_at(&obj.data, &["status", "sandbox", "podName"]) + .or_else(|| string_at(&obj.data, &["status", "sandbox", "agentPod"])) + .or_else(|| string_at(&obj.data, &["status", "sandbox", "pod", "name"])) + .unwrap_or_default(); + let conditions = status_obj + .and_then(|status| status.get("conditions")) + .and_then(serde_json::Value::as_array) + .map(|items| { + items + .iter() + .filter_map(condition_from_value) + .collect::>() + }) + .unwrap_or_default(); + + SandboxStatus { + sandbox_name, + instance_id, + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions, + deleting: obj.metadata.deletion_timestamp.is_some(), + } +} + +pub(super) fn update_claim_indexes( + sandbox_name_to_id: &mut std::collections::HashMap, + agent_pod_to_id: &mut std::collections::HashMap, + claim_name: &str, + sandbox: &Sandbox, +) { + if !claim_name.is_empty() { + sandbox_name_to_id.insert(claim_name.to_string(), sandbox.id.clone()); + } + if let Some(status) = sandbox.status.as_ref() { + if !status.sandbox_name.is_empty() { + sandbox_name_to_id.insert(status.sandbox_name.clone(), sandbox.id.clone()); + } + if !status.instance_id.is_empty() { + agent_pod_to_id.insert(status.instance_id.clone(), sandbox.id.clone()); + } + } +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 62f134205b..5edb3f10da 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -293,6 +293,9 @@ impl MxcComputeBackend { driver_reports_runtime_readiness: true, resource_capabilities: None, rootfs_tar_staging_dir: String::new(), + rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, } } diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 50eb014691..0713c1cbb3 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -531,6 +531,8 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, }) } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 896daa48a5..94ab9547ee 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -786,6 +786,8 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, } } @@ -6817,6 +6819,7 @@ mod tests { client .create_sandbox(request_with_traceparent(CreateSandboxRequest { sandbox: None, + sandbox_template: None, })) .await .is_err() @@ -6924,6 +6927,7 @@ mod tests { }; let request = request_with_traceparent(CreateSandboxRequest { sandbox: Some(sandbox), + sandbox_template: None, }); let mut client = traced_driver_client(driver.clone()).await; diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml index f9d02027e0..d8e0a28563 100644 --- a/crates/openshell-gateway/Cargo.toml +++ b/crates/openshell-gateway/Cargo.toml @@ -27,6 +27,7 @@ openshell-driver-docker = { path = "../openshell-driver-docker", optional = true openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes", optional = true } openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } hyper-util = { workspace = true, optional = true } +kube = { workspace = true, optional = true } nix = { workspace = true, optional = true } serde = { workspace = true, optional = true } rustix = { workspace = true, optional = true } @@ -45,6 +46,7 @@ in-tree-compute-drivers = [ "dep:openshell-driver-podman", "dep:openshell-otel", "dep:hyper-util", + "dep:kube", "dep:nix", "dep:serde", "dep:rustix", diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index 54ae5e5de3..6d96577e47 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -212,10 +212,49 @@ impl openshell_server::ComputeDriverFactory for KubernetesFactory { ) .await .map_err(|error| openshell_core::Error::execution(error.to_string()))?; + let sandbox_claim_activation = driver + .capabilities() + .map_err(openshell_core::Error::execution)? + .supports_warm_supervisor_bootstrap + .then(|| kubernetes_sandbox_claim_activation(&driver)); let driver = openshell_driver_kubernetes::ComputeDriverService::new_in_process(driver); - Ok(openshell_server::ComputeDriverInstance::InProcess( - std::sync::Arc::new(driver), - )) + let sandbox_template_reconciler = std::sync::Arc::new(driver.clone()); + Ok( + openshell_server::ComputeDriverInstance::InProcessWithSupervisorBootstrap { + driver: std::sync::Arc::new(driver), + supervisor_bootstrap_identity: None, + sandbox_claim_activation, + sandbox_template_reconciler: Some(sandbox_template_reconciler), + }, + ) + } +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +fn kubernetes_sandbox_claim_activation( + driver: &openshell_driver_kubernetes::KubernetesComputeDriver, +) -> std::sync::Arc { + let activation = + openshell_driver_kubernetes::SandboxClaimActivationController::from_driver(driver); + std::sync::Arc::new(KubernetesSandboxClaimActivationSpawner(activation)) +} + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +struct KubernetesSandboxClaimActivationSpawner( + openshell_driver_kubernetes::SandboxClaimActivationController, +); + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +impl openshell_server::SandboxClaimActivationSpawner for KubernetesSandboxClaimActivationSpawner { + fn spawn( + &self, + activator: std::sync::Arc< + dyn openshell_core::supervisor_bootstrap::SupervisorBootstrapActivator, + >, + registration_rx: tokio::sync::watch::Receiver, + shutdown_rx: tokio::sync::watch::Receiver, + ) { + self.0.spawn(activator, registration_rx, shutdown_rx); } } diff --git a/crates/openshell-otel/src/grpc.rs b/crates/openshell-otel/src/grpc.rs index 65d498eccb..b561787d1f 100644 --- a/crates/openshell-otel/src/grpc.rs +++ b/crates/openshell-otel/src/grpc.rs @@ -63,6 +63,8 @@ where } pub const COMPUTE_DRIVER_RPC_SERVICE: &str = "openshell.compute.v1.ComputeDriver"; +pub const SANDBOX_TEMPLATE_RECONCILER_RPC_SERVICE: &str = + "openshell.compute.v1.SandboxTemplateReconciler"; /// Low-cardinality semantic-convention identity for a compute-driver RPC. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -74,17 +76,25 @@ pub struct ComputeDriverRpc { impl ComputeDriverRpc { const fn new(method: &'static str, operation: &'static str) -> Self { + Self::new_for_service(COMPUTE_DRIVER_RPC_SERVICE, method, operation) + } + + const fn new_for_service( + service: &'static str, + method: &'static str, + operation: &'static str, + ) -> Self { Self { - service: COMPUTE_DRIVER_RPC_SERVICE, + service, method, operation, } } } -/// Typed identities for every RPC in the generated compute-driver service. +/// Typed identities for every RPC in the generated compute-driver extension services. pub mod rpc { - use super::ComputeDriverRpc; + use super::{ComputeDriverRpc, SANDBOX_TEMPLATE_RECONCILER_RPC_SERVICE}; pub const AUTHENTICATE_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( "AuthenticateSandbox", @@ -102,6 +112,11 @@ pub mod rpc { "ValidateSandboxCreate", "openshell.compute.v1.ComputeDriver/ValidateSandboxCreate", ); + pub const RECONCILE_SANDBOX_TEMPLATES: ComputeDriverRpc = ComputeDriverRpc::new_for_service( + SANDBOX_TEMPLATE_RECONCILER_RPC_SERVICE, + "ReconcileSandboxTemplates", + "openshell.compute.v1.SandboxTemplateReconciler/ReconcileSandboxTemplates", + ); pub const CREATE_SANDBOX: ComputeDriverRpc = ComputeDriverRpc::new( "CreateSandbox", "openshell.compute.v1.ComputeDriver/CreateSandbox", @@ -194,6 +209,7 @@ pub fn compute_driver_rpc_operation(path: &str) -> Option { Some("GetCapabilities") => Some(rpc::GET_CAPABILITIES), Some("GetGatewayListenerRequirements") => Some(rpc::GET_GATEWAY_LISTENER_REQUIREMENTS), Some("ValidateSandboxCreate") => Some(rpc::VALIDATE_SANDBOX_CREATE), + Some("ReconcileSandboxTemplates") => Some(rpc::RECONCILE_SANDBOX_TEMPLATES), Some("CreateSandbox") => Some(rpc::CREATE_SANDBOX), Some("GetSandbox") => Some(rpc::GET_SANDBOX), Some("ListSandboxes") => Some(rpc::LIST_SANDBOXES), @@ -317,6 +333,7 @@ mod tests { rpc::GET_CAPABILITIES, rpc::GET_GATEWAY_LISTENER_REQUIREMENTS, rpc::VALIDATE_SANDBOX_CREATE, + rpc::RECONCILE_SANDBOX_TEMPLATES, rpc::CREATE_SANDBOX, rpc::GET_SANDBOX, rpc::LIST_SANDBOXES, diff --git a/crates/openshell-otel/src/lib.rs b/crates/openshell-otel/src/lib.rs index 7a9162ab92..8912a10a9a 100644 --- a/crates/openshell-otel/src/lib.rs +++ b/crates/openshell-otel/src/lib.rs @@ -14,8 +14,9 @@ pub use driver::{ pub use grpc::{ COMPUTE_DRIVER_RPC_SERVICE, ComputeDriverRpc, ComputeDriverRpcSpan, RecordGrpcFailure, - RecordGrpcStatus, TracedGrpcStream, compute_driver_rpc_layer, compute_driver_rpc_operation, - grpc_status_code_name, record_grpc_status, rpc, + RecordGrpcStatus, SANDBOX_TEMPLATE_RECONCILER_RPC_SERVICE, TracedGrpcStream, + compute_driver_rpc_layer, compute_driver_rpc_operation, grpc_status_code_name, + record_grpc_status, rpc, }; pub use propagation::{ HeaderMapExtractor, MetadataMapInjector, TraceContextInterceptor, current_trace_context_carrier, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index d86c94b97b..de5ae4d7d5 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -127,39 +127,39 @@ pub async fn run_sandbox( network_enabled: bool, process_enabled: bool, upstream_proxy_args: openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs, + log_push_activation: Option, ) -> Result { - let (program, args) = command - .split_first() - .ok_or_else(|| miette::miette!("No command specified"))?; - - // Initialize the process-wide OCSF context early so that events emitted - // during policy loading (filesystem config, validation) have a context. - // Proxy IP/port use defaults here; they are only significant for network - // events which happen after the netns is created. - { - let hostname = std::fs::read_to_string("/etc/hostname").map_or_else( - |_| "openshell-sandbox".to_string(), - |s| s.trim().to_string(), - ); - - if !openshell_ocsf::ctx::set_ctx(SandboxContext { - sandbox_id: sandbox_id.clone().unwrap_or_default(), - sandbox_name: sandbox.as_deref().unwrap_or_default().to_string(), - container_image: std::env::var("OPENSHELL_CONTAINER_IMAGE").unwrap_or_default(), - hostname, - product_version: openshell_core::VERSION.to_string(), - proxy_ip: std::net::IpAddr::from([127, 0, 0, 1]), - proxy_port: 3128, - }) { - debug!("OCSF context already initialized, keeping existing"); - } - } + let mut command = command; + let mut interactive = interactive; + let mut await_main_process_attachment = await_main_process_attachment; + #[cfg(target_os = "linux")] + let mut activation_main_process_config = None; + let mut sandbox_id = sandbox_id; + let mut sandbox = sandbox; let sidecar_network_enforcement = sidecar_network_enforcement_enabled(); let process_enforcement_mode = process_enforcement_mode(); let process_uses_sidecar_control = process_enabled && !network_enabled && sidecar_network_enforcement; let mut process_control_connection = None; + #[cfg(target_os = "linux")] + let sidecar_control_server = if network_enabled && sidecar_network_enforcement { + let socket = sidecar_control_socket().ok_or_else(|| { + miette::miette!( + "{} is required for sidecar topology", + openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET + ) + })?; + Some(sidecar_control::spawn_pending_server( + &socket, + sidecar_expected_peer()?, + )?) + } else { + None + }; + #[cfg(not(target_os = "linux"))] + let sidecar_control_server: Option = None; + let sidecar_bootstrap = if process_uses_sidecar_control { let socket = sidecar_control_socket().ok_or_else(|| { miette::miette!( @@ -178,6 +178,23 @@ pub async fn run_sandbox( None }; + if let Some(bootstrap) = sidecar_bootstrap.as_ref() { + apply_sidecar_bootstrap_identity( + &mut sandbox_id, + &mut sandbox, + bootstrap.sandbox_identity.as_ref(), + )?; + } + + if let Some(config) = sidecar_bootstrap + .as_ref() + .and_then(|bootstrap| bootstrap.main_process_config.as_ref()) + { + command.clone_from(&config.command); + interactive = config.tty; + await_main_process_attachment = config.await_main_process_attachment; + } + // Extension credentials are owned by this supervisor and shared by every // gateway connection it opens, so the middleware registry's bearer slots // and the policy poll loop that rotates them stay the same objects. @@ -185,6 +202,106 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); + if sidecar_bootstrap.is_none() + && sandbox_id.is_none() + && let Some(endpoint) = openshell_endpoint.as_deref() + && std::env::var(openshell_core::sandbox_env::K8S_SA_TOKEN_FILE) + .ok() + .is_some_and(|path| !path.is_empty()) + { + #[cfg(target_os = "linux")] + let activation = if let Some(server) = sidecar_control_server.as_ref() { + let mut connection_closed = server.connection_closed(); + tokio::select! { + activation = openshell_core::grpc_client::register_supervisor(endpoint) => { + activation? + } + result = connection_closed.wait_for(|closed| *closed) => { + result.map_err(|_| { + miette::miette!( + "sidecar control connection monitor closed before activation" + ) + })?; + return Err(miette::miette!( + "process supervisor disconnected before sandbox activation" + )); + } + } + } else { + openshell_core::grpc_client::register_supervisor(endpoint).await? + }; + #[cfg(not(target_os = "linux"))] + let activation = openshell_core::grpc_client::register_supervisor(endpoint).await?; + if sandbox.is_none() && !activation.sandbox_name.is_empty() { + sandbox = Some(activation.sandbox_name); + } + sandbox_id = Some(activation.sandbox_id); + if let Some(main_process_spec) = activation + .startup_metadata + .get(openshell_core::sandbox_env::MAIN_PROCESS_SPEC) + { + let config = openshell_core::sandbox_env::MainProcessConfig::decode(main_process_spec) + .map_err(|error| miette::miette!("{error}"))?; + command.clone_from(&config.command); + interactive = config.tty; + await_main_process_attachment = config.await_main_process_attachment; + #[cfg(target_os = "linux")] + { + activation_main_process_config = Some(config); + } + } + } + + // Warm supervisors can receive their identity either directly from the + // gateway or from the network sidecar. Release log push only after either + // bootstrap path has populated the authoritative sandbox ID. + if let (Some(log_push_activation), Some(sandbox_id)) = + (log_push_activation.as_ref(), sandbox_id.as_ref()) + { + log_push_activation.activate(sandbox_id.clone()); + } + + if command.is_empty() { + let shell = openshell_core::shell::detect_login_shell(); + info!(shell = %shell, "no command specified; resolved default login shell"); + command = vec![shell, "-l".to_string()]; + } + + let (program, args) = command + .split_first() + .ok_or_else(|| miette::miette!("No command specified"))?; + + #[cfg(target_os = "linux")] + let sidecar_server_identity = if network_enabled && sidecar_network_enforcement { + Some(required_sidecar_sandbox_identity( + sandbox_id.as_deref(), + sandbox.as_deref(), + )?) + } else { + None + }; + + // Initialize the process-wide OCSF context before policy loading emits + // structured events, but after Kubernetes supervisor registration can fill + // in the sandbox identity for warm-pool pods. + { + let hostname = std::fs::read_to_string("/etc/hostname").map_or_else( + |_| "openshell-sandbox".to_string(), + |s| s.trim().to_string(), + ); + + if !openshell_ocsf::ctx::set_ctx(SandboxContext { + sandbox_id: sandbox_id.clone().unwrap_or_default(), + sandbox_name: sandbox.as_deref().unwrap_or_default().to_string(), + container_image: std::env::var("OPENSHELL_CONTAINER_IMAGE").unwrap_or_default(), + hostname, + product_version: openshell_core::VERSION.to_string(), + proxy_ip: std::net::IpAddr::from([127, 0, 0, 1]), + proxy_port: 3128, + }) { + debug!("OCSF context already initialized, keeping existing"); + } + } let sandbox_name_for_agg = sandbox.clone(); let ( mut policy, @@ -562,42 +679,30 @@ pub async fn run_sandbox( }; #[cfg(target_os = "linux")] - let sidecar_control_server = if network_enabled && sidecar_network_enforcement { + if let Some(server) = sidecar_control_server.as_ref() { if !matches!(policy.network.mode, NetworkMode::Proxy) { return Err(miette::miette!( "sidecar network enforcement requires proxy network mode" )); } - let socket = sidecar_control_socket().ok_or_else(|| { - miette::miette!( - "{} is required for sidecar topology", - openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET - ) - })?; let proto = retained_proto.as_ref().ok_or_else(|| { miette::miette!( "sidecar topology requires gateway policy data for the process supervisor" ) })?; let ca_paths = networking.as_ref().and_then(|n| n.ca_file_paths.clone()); - Some(sidecar_control::spawn_server( - &socket, - sidecar_control::BootstrapData { - policy_proto: proto.clone(), - provider_env_revision: provider_credentials.snapshot().revision, - provider_env_generation: 0, - provider_child_env: provider_env.clone(), - agent_proposals_enabled: agent_proposals.enabled(), - proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), - proxy_ca_bundle_path: ca_paths.as_ref().map(|paths| paths.1.clone()), - }, - sidecar_expected_peer()?, - )?) - } else { - None - }; - #[cfg(not(target_os = "linux"))] - let sidecar_control_server: Option = None; + server.activate(sidecar_control::BootstrapData { + policy_proto: proto.clone(), + main_process_config: activation_main_process_config.clone(), + sandbox_identity: sidecar_server_identity, + provider_env_revision: provider_credentials.snapshot().revision, + provider_env_generation: 0, + provider_child_env: provider_env.clone(), + agent_proposals_enabled: agent_proposals.enabled(), + proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), + proxy_ca_bundle_path: ca_paths.as_ref().map(|paths| paths.1.clone()), + })?; + } let sidecar_control_publisher = sidecar_control_server .as_ref() @@ -1226,6 +1331,67 @@ type LoadedPolicyBundle = ( type MainProcessExitAckWaiter = Arc)>>>; +#[cfg(target_os = "linux")] +fn required_sidecar_sandbox_identity( + sandbox_id: Option<&str>, + sandbox_name: Option<&str>, +) -> Result { + let sandbox_id = sandbox_id + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + miette::miette!("sidecar topology requires an activated sandbox identity") + })?; + + Ok(sidecar_control::ActivatedSandboxIdentity { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.unwrap_or_default().to_string(), + }) +} + +fn apply_sidecar_bootstrap_identity( + sandbox_id: &mut Option, + sandbox_name: &mut Option, + bootstrap_identity: Option<&sidecar_control::ActivatedSandboxIdentity>, +) -> Result<()> { + if let Some(identity) = bootstrap_identity { + if identity.sandbox_id.is_empty() { + return Err(miette::miette!( + "sidecar bootstrap contained an empty sandbox ID" + )); + } + + if let Some(local_id) = sandbox_id.as_deref().filter(|value| !value.is_empty()) + && local_id != identity.sandbox_id.as_str() + { + return Err(miette::miette!( + "sidecar bootstrap sandbox ID does not match the local sandbox identity" + )); + } + + if !identity.sandbox_name.is_empty() + && let Some(local_name) = sandbox_name.as_deref().filter(|value| !value.is_empty()) + && local_name != identity.sandbox_name.as_str() + { + return Err(miette::miette!( + "sidecar bootstrap sandbox name does not match the local sandbox identity" + )); + } + + *sandbox_id = Some(identity.sandbox_id.clone()); + if !identity.sandbox_name.is_empty() { + *sandbox_name = Some(identity.sandbox_name.clone()); + } + } + + if sandbox_id.as_deref().is_none_or(str::is_empty) { + return Err(miette::miette!( + "process sidecar requires sandbox identity from its environment or bootstrap" + )); + } + + Ok(()) +} + fn load_policy_from_sidecar_bootstrap( bootstrap: &sidecar_control::BootstrapData, ) -> Result { @@ -2602,7 +2768,8 @@ async fn load_policy( Err(miette::miette!( "Sandbox policy required. Provide one of:\n\ - --policy-rules and --policy-data (or OPENSHELL_POLICY_RULES and OPENSHELL_POLICY_DATA env vars)\n\ - - --sandbox-id and --openshell-endpoint (or OPENSHELL_SANDBOX_ID and OPENSHELL_ENDPOINT env vars)" + - --sandbox-id and --openshell-endpoint (or OPENSHELL_SANDBOX_ID and OPENSHELL_ENDPOINT env vars)\n\ + - OPENSHELL_ENDPOINT and OPENSHELL_K8S_SA_TOKEN_FILE for Kubernetes supervisor registration" )) } @@ -4511,6 +4678,80 @@ fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String mod tests { use super::*; + fn sidecar_identity(id: &str, name: &str) -> sidecar_control::ActivatedSandboxIdentity { + sidecar_control::ActivatedSandboxIdentity { + sandbox_id: id.to_string(), + sandbox_name: name.to_string(), + } + } + + #[test] + fn sidecar_bootstrap_identity_populates_warm_process_supervisor() { + let mut sandbox_id = None; + let mut sandbox_name = None; + let identity = sidecar_identity("sandbox-id", "warm-sandbox"); + + apply_sidecar_bootstrap_identity(&mut sandbox_id, &mut sandbox_name, Some(&identity)) + .unwrap(); + + assert_eq!(sandbox_id.as_deref(), Some("sandbox-id")); + assert_eq!(sandbox_name.as_deref(), Some("warm-sandbox")); + } + + #[test] + fn sidecar_bootstrap_identity_accepts_matching_cold_identity() { + let mut sandbox_id = Some("sandbox-id".to_string()); + let mut sandbox_name = Some("cold-sandbox".to_string()); + let identity = sidecar_identity("sandbox-id", "cold-sandbox"); + + apply_sidecar_bootstrap_identity(&mut sandbox_id, &mut sandbox_name, Some(&identity)) + .unwrap(); + + assert_eq!(sandbox_id.as_deref(), Some("sandbox-id")); + assert_eq!(sandbox_name.as_deref(), Some("cold-sandbox")); + } + + #[test] + fn sidecar_bootstrap_identity_rejects_mismatched_local_identity() { + let mut sandbox_id = Some("local-id".to_string()); + let mut sandbox_name = Some("local-name".to_string()); + let identity = sidecar_identity("bootstrap-id", "bootstrap-name"); + + let error = + apply_sidecar_bootstrap_identity(&mut sandbox_id, &mut sandbox_name, Some(&identity)) + .unwrap_err(); + + assert!(error.to_string().contains("sandbox ID does not match")); + assert_eq!(sandbox_id.as_deref(), Some("local-id")); + assert_eq!(sandbox_name.as_deref(), Some("local-name")); + } + + #[test] + fn sidecar_bootstrap_identity_rejects_mismatched_local_name() { + let mut sandbox_id = Some("sandbox-id".to_string()); + let mut sandbox_name = Some("local-name".to_string()); + let identity = sidecar_identity("sandbox-id", "bootstrap-name"); + + let error = + apply_sidecar_bootstrap_identity(&mut sandbox_id, &mut sandbox_name, Some(&identity)) + .unwrap_err(); + + assert!(error.to_string().contains("sandbox name does not match")); + assert_eq!(sandbox_id.as_deref(), Some("sandbox-id")); + assert_eq!(sandbox_name.as_deref(), Some("local-name")); + } + + #[test] + fn sidecar_bootstrap_identity_rejects_unattributed_warm_supervisor() { + let mut sandbox_id = None; + let mut sandbox_name = None; + + let error = + apply_sidecar_bootstrap_identity(&mut sandbox_id, &mut sandbox_name, None).unwrap_err(); + + assert!(error.to_string().contains("requires sandbox identity")); + } + #[test] fn transparent_tcp_capability_requires_exact_driver_marker() { let required = openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 440676ad14..06773a0c60 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -572,21 +572,21 @@ fn main() -> Result<()> { let result = runtime.block_on(async move { // Set up optional log push layer (gRPC mode only). - let log_push_state = if let (Some(sandbox_id), Some(endpoint)) = - (&args.sandbox_id, &args.openshell_endpoint) - { - let (tx, handle) = openshell_supervisor_process::log_push::spawn_log_push_task( - endpoint.clone(), - sandbox_id.clone(), - ); - let layer = - openshell_supervisor_process::log_push::LogPushLayer::new(sandbox_id.clone(), tx); - Some((layer, handle)) + let log_push_state = if let Some(endpoint) = &args.openshell_endpoint { + let (layer, activation, handle) = + openshell_supervisor_process::log_push::spawn_log_push( + endpoint.clone(), + args.sandbox_id.clone(), + ); + Some((layer, activation, handle)) } else { None }; - let push_layer = log_push_state.as_ref().map(|(layer, _)| layer.clone()); - let _log_push_handle = log_push_state.map(|(_, handle)| handle); + let push_layer = log_push_state.as_ref().map(|(layer, _, _)| layer.clone()); + let log_push_activation = log_push_state + .as_ref() + .map(|(_, activation, _)| activation.clone()); + let _log_push_handle = log_push_state.map(|(_, _, handle)| handle); // Shared flag: the sandbox poll loop toggles this when the // `ocsf_json_enabled` setting changes. The JSONL layer checks it @@ -673,12 +673,6 @@ fn main() -> Result<()> { ) }; - // An omitted command (the gateway leaves the default empty rather than - // baking a shell it cannot verify) is resolved to a login shell here, in - // the supervisor, so it matches the sandbox image: bash when present, - // otherwise /bin/sh (e.g. Alpine). An explicit command is used verbatim. - let command = resolve_default_command(command); - info!(command = ?command, "Starting sandbox"); // Note: "Starting sandbox" stays as plain info!() since the OCSF context // is not yet initialized at this point (run_sandbox hasn't been called). @@ -712,6 +706,7 @@ fn main() -> Result<()> { args.mode.network, args.mode.process, upstream_proxy_args, + log_push_activation, ) .await }); @@ -733,20 +728,6 @@ fn main() -> Result<()> { std::process::exit(exit_code); } -/// Resolve an omitted canonical command to a login shell that exists in this -/// sandbox image. Empty means "use the default": the gateway leaves an omitted -/// command empty rather than persisting a shell it cannot verify, so the -/// supervisor picks one here against the real sandbox filesystem (bash when -/// present, otherwise `/bin/sh`). An explicit command is returned unchanged. -fn resolve_default_command(command: Vec) -> Vec { - if !command.is_empty() { - return command; - } - let shell = openshell_core::shell::detect_login_shell(); - info!(shell = %shell, "no command specified; resolved default login shell"); - vec![shell, "-l".to_string()] -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-sandbox/src/sidecar_control.rs b/crates/openshell-sandbox/src/sidecar_control.rs index 11f3e68e23..31a9bc6981 100644 --- a/crates/openshell-sandbox/src/sidecar_control.rs +++ b/crates/openshell-sandbox/src/sidecar_control.rs @@ -17,12 +17,20 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::UnixListener; use tokio::net::unix::OwnedWriteHalf; -use tokio::sync::{Mutex, broadcast, mpsc}; +use tokio::sync::{Mutex, broadcast, mpsc, watch}; use tracing::{debug, info, warn}; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivatedSandboxIdentity { + pub sandbox_id: String, + pub sandbox_name: String, +} + #[derive(Debug, Clone)] pub struct BootstrapData { pub policy_proto: openshell_core::proto::SandboxPolicy, + pub main_process_config: Option, + pub sandbox_identity: Option, pub provider_env_revision: u64, pub provider_env_generation: u64, pub provider_child_env: HashMap, @@ -70,13 +78,16 @@ pub enum ControlUpdate { #[derive(Clone)] pub struct Publisher { - state: Arc>, + state: Arc>>, updates: broadcast::Sender, } impl Publisher { pub fn publish_provider_env(&self, revision: u64, provider_child_env: HashMap) { let mut state = self.state.write().expect("sidecar control state poisoned"); + let state = state + .as_mut() + .expect("sidecar control bootstrap must be activated before publishing updates"); if revision == state.provider_env_revision { return; } @@ -104,6 +115,9 @@ impl Publisher { ) { { let mut state = self.state.write().expect("sidecar control state poisoned"); + let state = state + .as_mut() + .expect("sidecar control bootstrap must be activated before publishing updates"); state.policy_proto = policy_proto.clone(); } @@ -117,6 +131,9 @@ impl Publisher { pub fn publish_agent_proposals(&self, enabled: bool, config_revision: u64) { { let mut state = self.state.write().expect("sidecar control state poisoned"); + let state = state + .as_mut() + .expect("sidecar control bootstrap must be activated before publishing updates"); if state.agent_proposals_enabled == enabled { return; } @@ -140,11 +157,41 @@ impl Publisher { pub struct ServerHandle { publisher: Publisher, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + bootstrap_ready: watch::Sender, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + connection_closed: watch::Receiver, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] entrypoint_rx: mpsc::Receiver, connection_task: tokio::task::JoinHandle<()>, } impl ServerHandle { + /// Release the authenticated process supervisor with one complete, + /// activation-bound bootstrap snapshot. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub fn activate(&self, bootstrap: BootstrapData) -> Result<()> { + let mut state = self + .publisher + .state + .write() + .expect("sidecar control state poisoned"); + if state.is_some() { + return Err(miette::miette!( + "sidecar control bootstrap was activated more than once" + )); + } + *state = Some(bootstrap); + drop(state); + self.bootstrap_ready.send_replace(true); + Ok(()) + } + + /// Observe termination of the sole authoritative control connection. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub fn connection_closed(&self) -> watch::Receiver { + self.connection_closed.clone() + } + pub fn publisher(&self) -> Publisher { self.publisher.clone() } @@ -185,6 +232,10 @@ enum WireClientMessage { enum WireServerMessage { BootstrapResponse { policy_proto: Vec, + #[serde(default)] + main_process_config: Option, + #[serde(default)] + sandbox_identity: Option, provider_env_revision: u64, provider_env_generation: u64, provider_child_env: HashMap, @@ -216,6 +267,8 @@ impl BootstrapData { fn to_wire(&self) -> WireServerMessage { WireServerMessage::BootstrapResponse { policy_proto: self.policy_proto.encode_to_vec(), + main_process_config: self.main_process_config.clone(), + sandbox_identity: self.sandbox_identity.clone(), provider_env_revision: self.provider_env_revision, provider_env_generation: self.provider_env_generation, provider_child_env: self.provider_child_env.clone(), @@ -238,6 +291,8 @@ impl TryFrom for BootstrapData { fn try_from(message: WireServerMessage) -> Result { let WireServerMessage::BootstrapResponse { policy_proto, + main_process_config, + sandbox_identity, provider_env_revision, provider_env_generation, provider_child_env, @@ -261,6 +316,8 @@ impl TryFrom for BootstrapData { Ok(Self { policy_proto, + main_process_config, + sandbox_identity, provider_env_revision, provider_env_generation, provider_child_env, @@ -321,12 +378,24 @@ impl TryFrom for ControlUpdate { } } -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[cfg(test)] pub fn spawn_server( path: &Path, bootstrap: BootstrapData, expected_peer: ExpectedPeer, ) -> Result { + let server = spawn_pending_server(path, expected_peer)?; + server.activate(bootstrap)?; + Ok(server) +} + +/// Bind the sidecar control socket before gateway activation is available. +/// +/// The process supervisor may connect and authenticate immediately, but it +/// receives no bootstrap response until [`ServerHandle::activate`] supplies a +/// complete activation-bound snapshot. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub fn spawn_pending_server(path: &Path, expected_peer: ExpectedPeer) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .into_diagnostic() @@ -367,26 +436,36 @@ pub fn spawn_server( })?; } - let state = Arc::new(RwLock::new(bootstrap)); + let state = Arc::new(RwLock::new(None)); + let (bootstrap_ready, bootstrap_ready_rx) = watch::channel(false); + let (connection_closed_tx, connection_closed) = watch::channel(false); let (updates, _) = broadcast::channel(32); let (entrypoint_tx, entrypoint_rx) = mpsc::channel(8); let publisher = Publisher { state: state.clone(), updates: updates.clone(), }; - - let connection_task = tokio::spawn(accept_authoritative_connection( - listener, - path.to_path_buf(), - expected_peer, - state, - updates, - entrypoint_tx, - )); - info!(path = %path.display(), "Sidecar control socket listening"); + let socket_path = path.to_path_buf(); + + let connection_task = tokio::spawn(async move { + accept_authoritative_connection( + listener, + socket_path, + expected_peer, + state, + bootstrap_ready_rx, + updates, + entrypoint_tx, + ) + .await; + connection_closed_tx.send_replace(true); + }); + info!(path = %path.display(), "Sidecar control socket listening for activation"); Ok(ServerHandle { publisher, + bootstrap_ready, + connection_closed, entrypoint_rx, connection_task, }) @@ -397,7 +476,8 @@ async fn accept_authoritative_connection( listener: UnixListener, socket_path: PathBuf, expected_peer: ExpectedPeer, - state: Arc>, + state: Arc>>, + bootstrap_ready: watch::Receiver, updates: broadcast::Sender, entrypoint_tx: mpsc::Sender, ) { @@ -424,7 +504,15 @@ async fn accept_authoritative_connection( ); } - if let Err(err) = handle_connection(stream, expected_peer, state, updates, entrypoint_tx).await + if let Err(err) = handle_connection( + stream, + expected_peer, + state, + bootstrap_ready, + updates, + entrypoint_tx, + ) + .await { warn!(error = %err, "Authoritative sidecar control connection closed"); } @@ -434,7 +522,8 @@ async fn accept_authoritative_connection( async fn handle_connection( stream: tokio::net::UnixStream, expected_peer: ExpectedPeer, - state: Arc>, + state: Arc>>, + mut bootstrap_ready: watch::Receiver, updates: broadcast::Sender, entrypoint_tx: mpsc::Sender, ) -> Result<()> { @@ -494,9 +583,40 @@ async fn handle_connection( // be missed between the snapshot and the live update stream nor omitted // from the snapshot itself. let mut update_rx = updates.subscribe(); - let bootstrap = { - let state = state.read().expect("sidecar control state poisoned"); - state.to_wire() + let bootstrap = loop { + let pending_bootstrap = state + .read() + .expect("sidecar control state poisoned") + .as_ref() + .map(BootstrapData::to_wire); + if let Some(bootstrap) = pending_bootstrap { + break bootstrap; + } + + tokio::select! { + result = bootstrap_ready.changed() => { + result.map_err(|_| { + miette::miette!("sidecar control bootstrap sender closed before activation") + })?; + } + line = lines.next_line() => { + let Some(line) = line.into_diagnostic()? else { + return Ok(()); + }; + match decode_client_message(&line)? { + WireClientMessage::BootstrapRequest { .. } => { + debug!("Ignoring duplicate sidecar bootstrap request before activation"); + } + WireClientMessage::EntrypointStarted { .. } + | WireClientMessage::MainProcessExited { .. } + | WireClientMessage::MainProcessFinalized { .. } => { + return Err(miette::miette!( + "sidecar control client sent entrypoint event before activation" + )); + } + } + } + } }; write_json_line(&mut writer, &bootstrap).await?; @@ -753,6 +873,8 @@ mod tests { fn bootstrap_message(policy: &SandboxPolicy) -> WireServerMessage { WireServerMessage::BootstrapResponse { policy_proto: policy.encode_to_vec(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), @@ -830,6 +952,16 @@ mod tests { version: 7, ..SandboxPolicy::default() }, + main_process_config: Some(openshell_core::sandbox_env::MainProcessConfig { + version: openshell_core::sandbox_env::MainProcessConfig::VERSION, + command: vec!["echo".to_string(), "hello world".to_string()], + tty: true, + await_main_process_attachment: true, + }), + sandbox_identity: Some(ActivatedSandboxIdentity { + sandbox_id: "019cafe0-1234-7890-abcd-0123456789ab".to_string(), + sandbox_name: "warm-sidecar".to_string(), + }), provider_env_revision: 3, provider_env_generation: 0, provider_child_env: env.clone(), @@ -844,9 +976,25 @@ mod tests { .unwrap(); assert_eq!(received.policy_proto.version, 7); + assert_eq!( + received.main_process_config, + Some(openshell_core::sandbox_env::MainProcessConfig { + version: openshell_core::sandbox_env::MainProcessConfig::VERSION, + command: vec!["echo".to_string(), "hello world".to_string()], + tty: true, + await_main_process_attachment: true, + }) + ); assert_eq!(received.provider_env_revision, 3); assert_eq!(received.provider_env_generation, 0); assert_eq!(received.provider_child_env, env); + assert_eq!( + received.sandbox_identity, + Some(ActivatedSandboxIdentity { + sandbox_id: "019cafe0-1234-7890-abcd-0123456789ab".to_string(), + sandbox_name: "warm-sidecar".to_string(), + }) + ); assert!(received.agent_proposals_enabled); assert_eq!( received.proxy_ca_cert_path, @@ -858,6 +1006,104 @@ mod tests { ); } + #[tokio::test] + async fn pending_server_withholds_bootstrap_until_activation() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_pending_server(&socket, current_peer()).unwrap(); + assert!(socket.exists(), "pending server must bind immediately"); + + let client_socket = socket.clone(); + let mut client = tokio::spawn(async move { + connect_process_client(&client_socket, Duration::from_secs(1)).await + }); + tokio::time::timeout(Duration::from_secs(1), async { + while socket.exists() { + tokio::task::yield_now().await; + } + }) + .await + .expect("pending server must accept and authenticate the process supervisor"); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut client) + .await + .is_err(), + "process supervisor must remain blocked before activation" + ); + + server + .activate(BootstrapData { + policy_proto: SandboxPolicy { + version: 9, + ..SandboxPolicy::default() + }, + main_process_config: None, + sandbox_identity: Some(ActivatedSandboxIdentity { + sandbox_id: "sandbox-warm".to_string(), + sandbox_name: "warm".to_string(), + }), + provider_env_revision: 0, + provider_env_generation: 0, + provider_child_env: HashMap::new(), + agent_proposals_enabled: false, + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }) + .unwrap(); + + let (bootstrap, _connection) = tokio::time::timeout(Duration::from_secs(1), client) + .await + .expect("activation must release the process supervisor") + .expect("process client task must not panic") + .unwrap(); + assert_eq!(bootstrap.policy_proto.version, 9); + assert_eq!( + bootstrap.sandbox_identity, + Some(ActivatedSandboxIdentity { + sandbox_id: "sandbox-warm".to_string(), + sandbox_name: "warm".to_string(), + }) + ); + } + + #[tokio::test] + async fn pending_server_reports_process_disconnect_before_activation() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_pending_server(&socket, current_peer()).unwrap(); + let mut connection_closed = server.connection_closed(); + + let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap(); + write_json_line( + &mut stream, + &WireClientMessage::BootstrapRequest { + supervisor_pid: std::process::id(), + }, + ) + .await + .unwrap(); + drop(stream); + + tokio::time::timeout( + Duration::from_secs(1), + connection_closed.wait_for(|closed| *closed), + ) + .await + .expect("network sidecar must observe a pre-activation disconnect") + .expect("disconnect monitor must remain live"); + } + + #[test] + fn bootstrap_without_identity_remains_wire_compatible() { + let message = decode_server_message( + r#"{"type":"bootstrap_response","policy_proto":[],"main_process_config":null,"provider_env_revision":0,"provider_env_generation":0,"provider_child_env":{},"agent_proposals_enabled":false,"proxy_ca_cert_path":null,"proxy_ca_bundle_path":null}"#, + ) + .unwrap(); + + let bootstrap = BootstrapData::try_from(message).unwrap(); + assert!(bootstrap.sandbox_identity.is_none()); + } + #[tokio::test] async fn provider_env_updates_use_generation_not_fingerprint_order() { let dir = tempfile::tempdir().unwrap(); @@ -866,6 +1112,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: u64::MAX, provider_env_generation: 7, provider_child_env: HashMap::from([("TOKEN".to_string(), "first".to_string())]), @@ -950,6 +1198,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), @@ -991,6 +1241,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), @@ -1072,6 +1324,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), @@ -1107,6 +1361,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), @@ -1137,6 +1393,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), @@ -1168,6 +1426,8 @@ mod tests { &socket, BootstrapData { policy_proto: SandboxPolicy::default(), + main_process_config: None, + sandbox_identity: None, provider_env_revision: 0, provider_env_generation: 0, provider_child_env: HashMap::new(), diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 89cc68bf0f..9da444db0a 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -400,6 +400,16 @@ impl OpenShell for TestOpenShell { Ok(Response::new(proto::CreateSshSessionResponse::default())) } + type RegisterSupervisorStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn register_supervisor( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn expose_service( &self, _: tonic::Request, diff --git a/crates/openshell-server-macros/src/lib.rs b/crates/openshell-server-macros/src/lib.rs index e85d316293..f3a2d26212 100644 --- a/crates/openshell-server-macros/src/lib.rs +++ b/crates/openshell-server-macros/src/lib.rs @@ -54,6 +54,7 @@ struct RpcAuth { enum AuthMode { Unauthenticated, Sandbox, + SupervisorRegistration, Bearer, Dual, } @@ -116,11 +117,11 @@ impl RpcAuth { }; match mode { - AuthMode::Unauthenticated | AuthMode::Sandbox => { + AuthMode::Unauthenticated | AuthMode::Sandbox | AuthMode::SupervisorRegistration => { if let Some(ref s) = scope { return Err(Error::new( s.span(), - "`scope` is only valid for `auth = \"bearer\"` or `auth = \"dual\"` (sandbox principals don't carry scopes)", + "`scope` is only valid for `auth = \"bearer\"` or `auth = \"dual\"` (sandbox and supervisor-registration principals don't carry scopes)", )); } if role.is_some() { @@ -154,12 +155,13 @@ fn parse_auth_mode(value: &LitStr) -> Result { match value.value().as_str() { "unauthenticated" => Ok(AuthMode::Unauthenticated), "sandbox" => Ok(AuthMode::Sandbox), + "supervisor_registration" => Ok(AuthMode::SupervisorRegistration), "bearer" => Ok(AuthMode::Bearer), "dual" => Ok(AuthMode::Dual), other => Err(Error::new( value.span(), format!( - "invalid auth mode `{other}`; expected one of `unauthenticated`, `sandbox`, `bearer`, `dual`" + "invalid auth mode `{other}`; expected one of `unauthenticated`, `sandbox`, `supervisor_registration`, `bearer`, `dual`" ), )), } @@ -288,6 +290,9 @@ fn expand(args: &AuthzArgs, item: &mut ItemImpl) -> Result { quote! { crate::auth::method_authz::AuthMode::Sandbox } } + AuthMode::SupervisorRegistration => { + quote! { crate::auth::method_authz::AuthMode::SupervisorRegistration } + } AuthMode::Bearer => quote! { crate::auth::method_authz::AuthMode::Bearer }, AuthMode::Dual => quote! { crate::auth::method_authz::AuthMode::Dual }, }; diff --git a/crates/openshell-server/src/auth/authenticator.rs b/crates/openshell-server/src/auth/authenticator.rs index 5511ef79db..a5f8c54bd2 100644 --- a/crates/openshell-server/src/auth/authenticator.rs +++ b/crates/openshell-server/src/auth/authenticator.rs @@ -14,8 +14,8 @@ //! //! Live authenticators slotting into the chain: //! - [`super::sandbox_jwt::SandboxJwtAuthenticator`] — gateway-minted JWTs -//! - [`super::compute_driver::ComputeDriverAuthenticator`] — driver-native -//! sandbox bootstrap credentials (path-scoped to `IssueSandboxToken`) +//! - [`super::k8s_sa::SupervisorBootstrapAuthenticator`] — driver-provided +//! bootstrap tokens (path-scoped to supervisor bootstrap RPCs) //! - [`super::oidc::OidcAuthenticator`] — user OIDC Bearer tokens use super::principal::Principal; use async_trait::async_trait; diff --git a/crates/openshell-server/src/auth/compute_driver.rs b/crates/openshell-server/src/auth/compute_driver.rs index 04caee61b1..8a0fb8b302 100644 --- a/crates/openshell-server/src/auth/compute_driver.rs +++ b/crates/openshell-server/src/auth/compute_driver.rs @@ -9,8 +9,9 @@ use crate::compute::ComputeRuntime; use async_trait::async_trait; use tonic::Status; -/// The only public gateway method on which driver-native credentials apply. +/// Public gateway methods on which driver-native credentials apply. pub const ISSUE_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/IssueSandboxToken"; +pub const REGISTER_SUPERVISOR_POD_PATH: &str = "/openshell.v1.OpenShell/RegisterSupervisorPod"; #[derive(Clone, Debug)] pub struct ComputeDriverAuthenticator { @@ -30,7 +31,10 @@ impl Authenticator for ComputeDriverAuthenticator { headers: &http::HeaderMap, path: &str, ) -> Result, Status> { - if path != ISSUE_SANDBOX_TOKEN_PATH { + if !matches!( + path, + ISSUE_SANDBOX_TOKEN_PATH | REGISTER_SUPERVISOR_POD_PATH + ) { return Ok(None); } @@ -109,7 +113,23 @@ mod tests { } #[tokio::test] - async fn authenticator_is_scoped_to_issue_path() { + async fn authenticator_is_scoped_to_bootstrap_paths() { + let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await; + + let principal = auth + .authenticate( + &bearer_headers("driver-credential"), + REGISTER_SUPERVISOR_POD_PATH, + ) + .await + .unwrap() + .expect("driver credential should authenticate"); + + assert!(matches!(principal, Principal::Sandbox(_))); + } + + #[tokio::test] + async fn authenticator_ignores_non_bootstrap_paths() { let auth = authenticator(NoopTestDriver::failing_sandbox_authentication( Code::Unavailable, "driver must not be called", diff --git a/crates/openshell-server/src/auth/descriptor_authz.rs b/crates/openshell-server/src/auth/descriptor_authz.rs index baea879bb7..61b013bb2e 100644 --- a/crates/openshell-server/src/auth/descriptor_authz.rs +++ b/crates/openshell-server/src/auth/descriptor_authz.rs @@ -121,6 +121,7 @@ impl DescriptorAuthTable { let auth_mode = match auth_mode_str.as_str() { "unauthenticated" => AuthMode::Unauthenticated, "sandbox" => AuthMode::Sandbox, + "supervisor_registration" => AuthMode::SupervisorRegistration, "bearer" => AuthMode::Bearer, "dual" => AuthMode::Dual, other => { diff --git a/crates/openshell-server/src/auth/guard.rs b/crates/openshell-server/src/auth/guard.rs index edcd6bc013..c1e87b6e48 100644 --- a/crates/openshell-server/src/auth/guard.rs +++ b/crates/openshell-server/src/auth/guard.rs @@ -20,6 +20,8 @@ use tracing::info; /// scope at the router level). /// - [`Principal::Sandbox`] must reference the same canonical UUID it /// was authenticated with. +/// - [`Principal::SupervisorBootstrap`] is rejected — bootstrap registration +/// identity is not a sandbox-scoped credential. /// - [`Principal::Anonymous`] is rejected — sandbox-class methods are /// never anonymously callable. /// @@ -44,6 +46,9 @@ pub fn ensure_sandbox_scope(principal: &Principal, claimed_sandbox_id: &str) -> )) } } + Principal::SupervisorBootstrap(_) => Err(Status::permission_denied( + "sandbox-scoped methods require a sandbox principal", + )), Principal::Anonymous => Err(Status::unauthenticated( "sandbox-scoped methods require an authenticated caller", )), @@ -84,7 +89,7 @@ pub fn ensure_sandbox_principal_scope( ensure_sandbox_scope(principal, claimed_sandbox_id)?; Ok(p.clone()) } - Principal::User(_) => Err(Status::permission_denied( + Principal::User(_) | Principal::SupervisorBootstrap(_) => Err(Status::permission_denied( "supervisor RPCs require a sandbox principal", )), Principal::Anonymous => Err(Status::unauthenticated( diff --git a/crates/openshell-server/src/auth/k8s_sa.rs b/crates/openshell-server/src/auth/k8s_sa.rs new file mode 100644 index 0000000000..311ab4e0e8 --- /dev/null +++ b/crates/openshell-server/src/auth/k8s_sa.rs @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Supervisor bootstrap authenticator adapter. +//! +//! The Kubernetes-specific `TokenReview` and pod lookup logic lives in the +//! Kubernetes driver. This module is intentionally gateway-small: it is +//! path-scoped to supervisor bootstrap RPCs, extracts a bearer token, delegates +//! validation to the active driver's bootstrap identity provider, and turns the +//! resulting registration-only identity into the principal shape expected by the +//! gateway auth router. + +use super::authenticator::Authenticator; +use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; +use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapBinding, SupervisorBootstrapIdentityProvider, +}; +use std::sync::Arc; +use tonic::Status; +use tonic::async_trait; +use tracing::{debug, warn}; + +/// Legacy gRPC method path accepted by this authenticator. All other +/// non-bootstrap paths fall through so a gateway-minted JWT or user credential +/// is required there. +pub const ISSUE_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/IssueSandboxToken"; +/// Supervisor registration path accepted by this authenticator. +pub const REGISTER_SUPERVISOR_PATH: &str = "/openshell.v1.OpenShell/RegisterSupervisor"; + +/// Path-scoped authenticator backed by the active compute driver's bootstrap +/// identity provider. +pub struct SupervisorBootstrapAuthenticator { + provider: Arc, +} + +impl std::fmt::Debug for SupervisorBootstrapAuthenticator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SupervisorBootstrapAuthenticator") + .finish_non_exhaustive() + } +} + +impl SupervisorBootstrapAuthenticator { + pub fn new(provider: Arc) -> Self { + Self { provider } + } +} + +#[async_trait] +impl Authenticator for SupervisorBootstrapAuthenticator { + async fn authenticate( + &self, + headers: &http::HeaderMap, + path: &str, + ) -> Result, Status> { + if path != ISSUE_SANDBOX_TOKEN_PATH && path != REGISTER_SUPERVISOR_PATH { + return Ok(None); + } + + let Some(token) = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + else { + return Ok(None); + }; + + let Some(identity) = self.provider.authenticate_registration(token).await? else { + debug!("supervisor bootstrap token did not authenticate; falling through"); + return Ok(None); + }; + + if path == REGISTER_SUPERVISOR_PATH { + return Ok(Some(Principal::SupervisorBootstrap(identity))); + } + + let SupervisorBootstrapBinding::BoundSandbox { sandbox_id } = identity.binding else { + warn!( + driver = %identity.driver, + instance_id = %identity.instance_id, + "bootstrap identity is not bound to a sandbox; rejecting legacy token issue" + ); + return Err(Status::permission_denied( + "supervisor instance is not bound to a sandbox identity", + )); + }; + + Ok(Some(Principal::Sandbox(SandboxPrincipal { + sandbox_id, + source: SandboxIdentitySource::SupervisorBootstrap { + driver: identity.driver, + instance_id: identity.instance_id, + }, + trust_domain: Some("openshell".to_string()), + }))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::supervisor_bootstrap::SupervisorBootstrapIdentity; + use std::sync::Mutex; + + struct FakeProvider { + outcome: Result, Status>, + seen_tokens: Mutex>, + } + + impl FakeProvider { + fn returning(outcome: Result, Status>) -> Self { + Self { + outcome, + seen_tokens: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl SupervisorBootstrapIdentityProvider for FakeProvider { + async fn authenticate_registration( + &self, + token: &str, + ) -> Result, Status> { + self.seen_tokens.lock().unwrap().push(token.to_string()); + match &self.outcome { + Ok(identity) => Ok(identity.clone()), + Err(status) => Err(Status::new(status.code(), status.message())), + } + } + } + + fn bearer_headers(token: &str) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + headers.insert( + "authorization", + http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + headers + } + + fn identity(binding: SupervisorBootstrapBinding) -> SupervisorBootstrapIdentity { + SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: "uid-a".to_string(), + binding, + } + } + + #[tokio::test] + async fn authenticates_on_bootstrap_paths_only() { + let provider = Arc::new(FakeProvider::returning(Ok(Some(identity( + SupervisorBootstrapBinding::BoundSandbox { + sandbox_id: "sandbox-a".to_string(), + }, + ))))); + let auth = SupervisorBootstrapAuthenticator::new(provider.clone()); + + let issue = auth + .authenticate(&bearer_headers("bootstrap-token"), ISSUE_SANDBOX_TOKEN_PATH) + .await + .unwrap() + .expect("expected principal"); + match issue { + Principal::Sandbox(p) => { + assert_eq!(p.sandbox_id, "sandbox-a"); + assert!(matches!( + p.source, + SandboxIdentitySource::SupervisorBootstrap { .. } + )); + } + _ => panic!("expected sandbox principal"), + } + + let register = auth + .authenticate(&bearer_headers("bootstrap-token"), REGISTER_SUPERVISOR_PATH) + .await + .unwrap() + .expect("expected principal"); + assert!(matches!(register, Principal::SupervisorBootstrap(_))); + + let off_path = auth + .authenticate( + &bearer_headers("bootstrap-token"), + "/openshell.v1.OpenShell/GetSandboxConfig", + ) + .await + .unwrap(); + assert!(off_path.is_none()); + assert_eq!(provider.seen_tokens.lock().unwrap().len(), 2); + } + + #[tokio::test] + async fn missing_bearer_yields_none() { + let provider = Arc::new(FakeProvider::returning(Ok(None))); + let auth = SupervisorBootstrapAuthenticator::new(provider); + let result = auth + .authenticate(&http::HeaderMap::new(), ISSUE_SANDBOX_TOKEN_PATH) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn issue_token_rejects_unbound_identity() { + let provider = Arc::new(FakeProvider::returning(Ok(Some(identity( + SupervisorBootstrapBinding::WarmPending { + activation_guard: "cr-uid-a".to_string(), + }, + ))))); + let auth = SupervisorBootstrapAuthenticator::new(provider); + let err = auth + .authenticate(&bearer_headers("bootstrap-token"), ISSUE_SANDBOX_TOKEN_PATH) + .await + .expect_err("unbound identity must be rejected"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn register_accepts_unbound_identity() { + let provider = Arc::new(FakeProvider::returning(Ok(Some(identity( + SupervisorBootstrapBinding::WarmPending { + activation_guard: "cr-uid-a".to_string(), + }, + ))))); + let auth = SupervisorBootstrapAuthenticator::new(provider); + let principal = auth + .authenticate(&bearer_headers("bootstrap-token"), REGISTER_SUPERVISOR_PATH) + .await + .unwrap() + .expect("expected principal"); + + match principal { + Principal::SupervisorBootstrap(identity) => { + assert_eq!( + identity.binding, + SupervisorBootstrapBinding::WarmPending { + activation_guard: "cr-uid-a".to_string(), + } + ); + } + _ => panic!("expected bootstrap principal"), + } + } +} diff --git a/crates/openshell-server/src/auth/method_authz.rs b/crates/openshell-server/src/auth/method_authz.rs index 35ff82e925..7a5e61509b 100644 --- a/crates/openshell-server/src/auth/method_authz.rs +++ b/crates/openshell-server/src/auth/method_authz.rs @@ -17,6 +17,9 @@ pub enum AuthMode { /// Only callable by a `Principal::Sandbox` (gateway-minted sandbox JWT). /// See `auth/sandbox_jwt.rs`. Sandbox, + /// Only callable by a supervisor bootstrap principal during supervisor + /// registration. This does not grant sandbox-scoped RPC access. + SupervisorRegistration, /// Bearer (OIDC) authentication required. Bearer, /// Either sandbox principal or Bearer; scope and role apply on @@ -75,6 +78,16 @@ pub fn is_sandbox_callable(method: &str) -> bool { ) } +/// `true` if the method is callable by a Kubernetes supervisor registration +/// principal. +#[must_use] +pub fn is_supervisor_registration_callable(method: &str) -> bool { + matches!( + lookup(method).map(|m| m.auth_mode), + Some(AuthMode::SupervisorRegistration) + ) +} + /// `true` if the method is callable by a `Principal::User` (`bearer` or /// `dual` auth mode). /// @@ -86,7 +99,9 @@ pub fn is_sandbox_callable(method: &str) -> bool { #[must_use] pub fn is_user_callable(method: &str) -> bool { match lookup(method).map(|m| m.auth_mode) { - Some(AuthMode::Sandbox | AuthMode::Unauthenticated) => false, + Some(AuthMode::Sandbox | AuthMode::SupervisorRegistration | AuthMode::Unauthenticated) => { + false + } Some(AuthMode::Bearer | AuthMode::Dual) | None => true, } } diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index b39ff7bfa9..bedbebe015 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -10,12 +10,12 @@ pub mod authenticator; pub mod authz; -pub mod compute_driver; pub mod descriptor_authz; pub mod extension_mint_limit; pub mod guard; mod http; pub mod identity; +pub mod k8s_sa; pub mod method_authz; pub mod oidc; pub mod principal; diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 9567cc62d2..0d749f8a55 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -15,6 +15,7 @@ //! to prevent cross-sandbox access (see issue #1354). use super::identity::Identity; +use openshell_core::supervisor_bootstrap::SupervisorBootstrapIdentity; /// Who is calling. /// @@ -28,6 +29,13 @@ pub enum Principal { /// sandbox UUID. The wrapped `sandbox_id` MUST match any sandbox referenced /// in the request body for sandbox-class methods. Sandbox(#[allow(dead_code)] SandboxPrincipal), + /// Driver runtime instance authenticated for supervisor bootstrap + /// registration only. + /// + /// This is intentionally not a sandbox principal: warm pods can be valid + /// driver instances before they are claimed by a sandbox. The router only + /// allows this principal to call `RegisterSupervisor`. + SupervisorBootstrap(SupervisorBootstrapIdentity), /// Truly unauthenticated caller (health probes, reflection). Sandbox-class /// and user-class methods reject this variant. #[allow(dead_code)] @@ -70,8 +78,7 @@ pub enum SandboxIdentitySource { /// Per-sandbox client certificate. Reserved for channel-bound sandbox /// identity. BootstrapCert { fingerprint: String }, - /// Driver-native credential used to bootstrap a gateway-minted JWT via - /// `IssueSandboxToken`. The named compute driver authenticated only the - /// sandbox identity; the gateway still authorizes the exchange. - ComputeDriver { driver_name: String }, + /// Driver-provided bootstrap token used to bootstrap a gateway-minted JWT. + /// Populated only on supervisor bootstrap RPC paths. + SupervisorBootstrap { driver: String, instance_id: String }, } diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 4b086f49cd..e3617f29d3 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -8,7 +8,7 @@ //! supervisor-to-gateway gRPC calls. This module implements both sides of the //! gateway-controlled token: //! - [`SandboxJwtIssuer`] mints fresh tokens (called from -//! `handle_create_sandbox` and the `IssueSandboxToken` RPC). +//! `handle_create_sandbox` and the Kubernetes bootstrap RPCs). //! - [`SandboxJwtAuthenticator`] validates tokens on inbound requests and //! produces a [`Principal::Sandbox`] with [`SandboxIdentitySource::BootstrapJwt`]. //! diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index f368298dd3..8deb00197f 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -50,5 +50,8 @@ mod tests { assert!(!is_sandbox_callable( "/openshell.v1.OpenShell/ApproveDraftChunk" )); + assert!(!is_sandbox_callable( + "/openshell.v1.OpenShell/RegisterSupervisor" + )); } } diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index e23d2287a3..5acc60b14f 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -124,6 +124,9 @@ pub async fn authorize_workspace( workspace, grant: AuthGrant::Sandbox, }), + Principal::SupervisorBootstrap(_) => Err(Status::permission_denied( + "supervisor registration principals cannot access workspace resources", + )), Principal::Anonymous => Err(Status::unauthenticated("authentication required")), } } @@ -159,6 +162,9 @@ pub fn require_platform_admin(admin_role: &str, principal: &Principal) -> Result Principal::Sandbox(_) => Err(Status::permission_denied( "sandbox principals cannot perform cross-workspace operations", )), + Principal::SupervisorBootstrap(_) => Err(Status::permission_denied( + "supervisor registration principals cannot perform cross-workspace operations", + )), Principal::Anonymous => Err(Status::unauthenticated("authentication required")), } } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 70356e1add..251e3a9aec 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -24,22 +24,32 @@ use openshell_core::proto::compute::v1::{ AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, DriverSandboxTemplate, - EnsureWorkspaceRequest, EnsureWorkspaceResponse, + DriverSandboxTemplateRef, DriverSandboxTemplateResource, DriverSandboxTemplateServiceLevel, + DriverSandboxTemplateStartup, EnsureWorkspaceRequest, EnsureWorkspaceResponse, GatewayListenerRequirement as ProtoGatewayListenerRequirement, GetCapabilitiesRequest, GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, GetSandboxRequest, GpuResourceRequirements as DriverGpuResourceRequirements, - ListSandboxesRequest, ResourceCapabilities as DriverResourceCapabilities, + ListSandboxesRequest, ReconcileSandboxTemplatesRequest, ReconcileSandboxTemplatesResponse, + ResourceCapabilities as DriverResourceCapabilities, ResourceRequirements as DriverSandboxResourceRequirements, StartSandboxRequest, StopSandboxRequest, ValidateSandboxCreateRequest, WatchSandboxesEvent, WatchSandboxesRequest, - compute_driver_client::ComputeDriverClient, compute_driver_server::ComputeDriver, - gateway_listener_requirement::Selector, watch_sandboxes_event, + authenticate_sandbox_response, compute_driver_client::ComputeDriverClient, + compute_driver_server::ComputeDriver, gateway_listener_requirement::Selector, + sandbox_template_reconciler_client::SandboxTemplateReconcilerClient, + sandbox_template_reconciler_server::SandboxTemplateReconciler, watch_sandboxes_event, }; use openshell_core::proto::{ - PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, + PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxResources, SandboxServiceLevel, + SandboxSpec, SandboxStartup, SandboxStatus, SandboxTemplate, SandboxWorkloadConfig, + SandboxWorkloadTemplate, ServiceEndpoint, SshSession, +}; +use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapActivator, SupervisorBootstrapBinding, SupervisorBootstrapIdentity, + SupervisorBootstrapIdentityProvider, }; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; +#[cfg(not(target_os = "windows"))] use prost::Message; use std::collections::HashMap; use std::fmt; @@ -63,6 +73,7 @@ pub type DriverWatchStream = Pin> + Send>>; pub type SharedComputeDriver = Arc + Send + Sync>; +pub type SharedSandboxTemplateReconciler = Arc; use traced_driver::TracedDriver; @@ -76,7 +87,7 @@ mod traced_driver { use tonic::Status; use tracing::Instrument as _; - use super::{DriverWatchStream, SharedComputeDriver}; + use super::{DriverWatchStream, SharedComputeDriver, SharedSandboxTemplateReconciler}; type TracedWatchStream = openshell_otel::TracedGrpcStream; @@ -147,6 +158,30 @@ mod traced_driver { .await } + pub(super) async fn call_reconciler( + &self, + reconciler: SharedSandboxTemplateReconciler, + rpc: openshell_otel::ComputeDriverRpc, + call: impl FnOnce(SharedSandboxTemplateReconciler) -> Fut, + ) -> Result + where + Fut: Future>, + { + let span = self.span(rpc, None); + let future = call(reconciler); + async { + let result = future.await; + let current = tracing::Span::current(); + match &result { + Ok(_) => openshell_otel::record_grpc_status(¤t, tonic::Code::Ok), + Err(status) => openshell_otel::record_grpc_status(¤t, status.code()), + } + result + } + .instrument(span) + .await + } + /// Open a driver watch while keeping the client span alive with the stream. pub(super) async fn watch(&self) -> Result, Status> { let span = self.span(openshell_otel::rpc::WATCH_SANDBOXES, None); @@ -293,6 +328,7 @@ enum BeginDelete { } #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct ComputeDriverInfoSnapshot { /// Gateway-selected driver name used for routing and `driver_config` keys. pub name: String, @@ -312,8 +348,30 @@ pub struct ComputeDriverInfoSnapshot { pub rootfs_tar_staging_dir: String, /// Maximum rootfs tar file size in bytes. pub rootfs_tar_max_bytes: u64, + /// Driver opted into authoritative sandbox-template reconciliation. + pub supports_sandbox_template_reconciliation: bool, + /// Driver may return warm-pending instances from `AuthenticateSandbox`. + pub supports_warm_supervisor_bootstrap: bool, +} + +/// Start a single sandbox whose store record indicates it should be +/// running. Implemented by drivers (currently only Docker) where compute +/// resources do not auto-restart with the gateway. Returns `Ok(true)` if +/// the backend resource was found and started (or was already running), +/// `Ok(false)` if no backend resource exists. +#[tonic::async_trait] +trait LeaseScopedReconciler: Send + Sync { + async fn run(&self, cancel: watch::Receiver); } +pub trait SandboxClaimActivationSpawner: Send + Sync { + fn spawn( + &self, + activator: Arc, + registration_rx: watch::Receiver, + shutdown_rx: watch::Receiver, + ); +} /// Interval between store-vs-backend reconciliation sweeps. const RECONCILE_INTERVAL: Duration = Duration::from_mins(1); @@ -455,6 +513,35 @@ struct RemoteComputeDriver { client: RemoteComputeDriverClient, } +#[derive(Debug, Clone)] +struct RemoteSandboxTemplateReconciler { + client: SandboxTemplateReconcilerClient< + tonic::service::interceptor::InterceptedService, + >, +} + +impl RemoteSandboxTemplateReconciler { + fn new(channel: Channel) -> Self { + Self { + client: SandboxTemplateReconcilerClient::with_interceptor( + channel, + TraceContextInterceptor, + ), + } + } +} + +#[tonic::async_trait] +impl SandboxTemplateReconciler for RemoteSandboxTemplateReconciler { + async fn reconcile_sandbox_templates( + &self, + request: Request, + ) -> Result, Status> { + let mut client = self.client.clone(); + client.reconcile_sandbox_templates(request).await + } +} + type RemoteComputeDriverClient = ComputeDriverClient< tonic::service::interceptor::InterceptedService, >; @@ -607,6 +694,10 @@ pub struct ComputeRuntime { sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, + supervisor_bootstrap_identity: Option>, + sandbox_claim_activation: Option>, + sandbox_template_reconciler: Option, + lease_scoped_reconciler: Option>, sync_lock: Arc>, lifecycle_gates: Arc, gateway_listener_requirements: Vec, @@ -623,6 +714,30 @@ impl fmt::Debug for ComputeRuntime { } } +#[derive(Debug)] +struct ComputeDriverBootstrapIdentityProvider { + compute: ComputeRuntime, +} + +impl ComputeDriverBootstrapIdentityProvider { + fn new(compute: ComputeRuntime) -> Self { + Self { compute } + } +} + +#[tonic::async_trait] +impl SupervisorBootstrapIdentityProvider for ComputeDriverBootstrapIdentityProvider { + async fn authenticate_registration( + &self, + token: &str, + ) -> Result, Status> { + self.compute + .authenticate_sandbox_for_registration(token) + .await + .map(Some) + } +} + impl ComputeRuntime { #[allow(clippy::too_many_arguments)] #[tracing::instrument( @@ -643,6 +758,9 @@ impl ComputeRuntime { sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, + supervisor_bootstrap_identity: Option>, + sandbox_claim_activation: Option>, + sandbox_template_reconciler: Option, ) -> Result { let capabilities = driver .get_capabilities(Request::new(GetCapabilitiesRequest {})) @@ -652,6 +770,27 @@ impl ComputeRuntime { compute_error_from_status(status) })? .into_inner(); + if capabilities.supports_warm_supervisor_bootstrap && sandbox_claim_activation.is_none() { + return Err(ComputeError::Message(format!( + "compute driver '{driver_name}' advertises warm supervisor bootstrap without an activation controller" + ))); + } + let sandbox_claim_activation = if capabilities.supports_warm_supervisor_bootstrap { + sandbox_claim_activation + } else { + None + }; + if capabilities.supports_sandbox_template_reconciliation + && sandbox_template_reconciler.is_none() + { + return Err(ComputeError::Message(format!( + "compute driver '{driver_name}' advertises sandbox-template reconciliation without providing the optional reconciler service" + ))); + } + let sandbox_template_reconciler = capabilities + .supports_sandbox_template_reconciliation + .then_some(sandbox_template_reconciler) + .flatten(); info!( configured_driver = %driver_name, advertised_driver = %capabilities.driver_name, @@ -667,6 +806,9 @@ impl ComputeRuntime { resource_capabilities: capabilities.resource_capabilities, rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, + supports_sandbox_template_reconciliation: capabilities + .supports_sandbox_template_reconciliation, + supports_warm_supervisor_bootstrap: capabilities.supports_warm_supervisor_bootstrap, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -739,6 +881,10 @@ impl ComputeRuntime { sandbox_watch_bus, tracing_log_bus, supervisor_sessions, + supervisor_bootstrap_identity, + sandbox_claim_activation, + sandbox_template_reconciler, + lease_scoped_reconciler: None, sync_lock: Arc::new(Mutex::new(())), lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, @@ -780,6 +926,9 @@ impl ComputeRuntime { tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, ) -> Result { + let sandbox_template_reconciler: SharedSandboxTemplateReconciler = Arc::new( + RemoteSandboxTemplateReconciler::new(endpoint.channel.clone()), + ); let driver: SharedComputeDriver = Arc::new(RemoteComputeDriver::new(endpoint.channel)); Self::from_driver( endpoint.name, @@ -790,6 +939,9 @@ impl ComputeRuntime { sandbox_watch_bus, tracing_log_bus, supervisor_sessions, + None, + None, + Some(sandbox_template_reconciler), ) .await } @@ -818,31 +970,10 @@ impl ComputeRuntime { &self.driver_info.name } - #[must_use] - pub fn supports_sandbox_authentication(&self) -> bool { - self.driver_info.supports_sandbox_authentication - } - - pub(crate) async fn authenticate_sandbox(&self, credential: &str) -> Result { - if !self.supports_sandbox_authentication() { - return Err(Status::unimplemented( - "selected compute driver does not authenticate sandbox credentials", - )); - } - let request = AuthenticateSandboxRequest { - credential: credential.to_string(), - }; - self.driver - .call( - openshell_otel::rpc::AUTHENTICATE_SANDBOX, - None, - |driver| async move { driver.authenticate_sandbox(Request::new(request)).await }, - ) - .await - .map(|response| response.into_inner().sandbox_id) + pub(crate) fn supports_sandbox_template_reconciliation(&self) -> bool { + self.driver_info.supports_sandbox_template_reconciliation } - #[must_use] pub(crate) fn telemetry_compute_driver(&self) -> TelemetryComputeDriver { self.telemetry_compute_driver } @@ -856,6 +987,101 @@ impl ComputeRuntime { self } + #[must_use] + pub fn supervisor_bootstrap_identity_provider( + &self, + ) -> Option> { + self.supervisor_bootstrap_identity.clone().or_else(|| { + if self.driver_info.supports_sandbox_authentication { + let provider: Arc = + Arc::new(ComputeDriverBootstrapIdentityProvider::new(self.clone())); + Some(provider) + } else { + None + } + }) + } + + #[allow(clippy::result_large_err)] + pub(crate) async fn authenticate_sandbox_for_registration( + &self, + credential: &str, + ) -> Result { + if !self.driver_info.supports_sandbox_authentication { + return Err(Status::failed_precondition( + "selected compute driver does not support supervisor bootstrap authentication", + )); + } + let response = self + .driver + .call( + openshell_otel::rpc::AUTHENTICATE_SANDBOX, + None, + |driver| async move { + driver + .authenticate_sandbox(Request::new(AuthenticateSandboxRequest { + credential: credential.to_string(), + })) + .await + }, + ) + .await? + .into_inner(); + + let binding = response.binding.ok_or_else(|| { + Status::permission_denied("compute driver returned no sandbox binding") + })?; + match binding { + authenticate_sandbox_response::Binding::SandboxId(sandbox_id) => { + if sandbox_id.is_empty() { + return Err(Status::permission_denied( + "compute driver returned an empty sandbox identity", + )); + } + Ok(SupervisorBootstrapIdentity { + driver: self.driver_info.name.clone(), + instance_id: String::new(), + binding: SupervisorBootstrapBinding::BoundSandbox { sandbox_id }, + }) + } + authenticate_sandbox_response::Binding::WarmPending(warm_pending) => { + if !self.driver_info.supports_warm_supervisor_bootstrap { + return Err(Status::permission_denied( + "compute driver returned warm supervisor bootstrap without advertising support", + )); + } + if warm_pending.instance_id.is_empty() { + return Err(Status::permission_denied( + "compute driver returned an empty supervisor instance ID", + )); + } + if warm_pending.activation_guard.is_empty() { + return Err(Status::permission_denied( + "compute driver returned an empty activation guard", + )); + } + Ok(SupervisorBootstrapIdentity { + driver: self.driver_info.name.clone(), + instance_id: warm_pending.instance_id, + binding: SupervisorBootstrapBinding::WarmPending { + activation_guard: warm_pending.activation_guard, + }, + }) + } + } + } + + pub(crate) fn spawn_sandbox_claim_activation( + &self, + activator: Arc, + registration_rx: watch::Receiver, + shutdown_rx: watch::Receiver, + ) { + if let Some(controller) = self.sandbox_claim_activation.clone() { + controller.spawn(activator, registration_rx, shutdown_rx); + } + } + #[must_use] pub(crate) fn gateway_listener_requirements(&self) -> &[GatewayListenerRequirement] { &self.gateway_listener_requirements @@ -933,6 +1159,7 @@ impl ComputeRuntime { sandbox: Sandbox, sandbox_token: Option, await_main_process_attachment: bool, + sandbox_template: Option, ) -> Result { let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -1006,6 +1233,7 @@ impl ComputeRuntime { driver .create_sandbox(Request::new(CreateSandboxRequest { sandbox: Some(driver_sandbox), + sandbox_template, })) .await }, @@ -1041,6 +1269,20 @@ impl ComputeRuntime { self.sandbox_index.remove_sandbox(sandbox.object_id()); Err(Status::failed_precondition(status.message().to_string())) } + Err(status) if status.code() == Code::Unavailable => { + // The driver may have committed the backend create. Keep the + // durable Provisioning row so its watcher can reconcile the + // accepted resource instead of allowing a new sandbox ID to + // reuse the same name. If no backend resource appears, the + // store-vs-backend reconciler prunes the stale Provisioning + // row after ORPHAN_GRACE_PERIOD and clears the name index. + warn!( + sandbox_id = %sandbox.object_id(), + error = %status, + "sandbox create outcome is ambiguous; preserving provisioning state" + ); + Err(Status::unavailable(status.message().to_string())) + } Err(err) => { let _ = self .store @@ -2105,6 +2347,7 @@ impl ComputeRuntime { pub fn spawn_watchers(&self, shutdown_rx: watch::Receiver) { let runtime = Arc::new(self.clone()); if self.store.is_single_replica() { + let _lease_scoped_handle = self.spawn_lease_scoped_reconciler(shutdown_rx.clone()); let watch_runtime = runtime.clone(); let watch_shutdown = shutdown_rx.clone(); tokio::spawn(async move { @@ -2120,6 +2363,17 @@ impl ComputeRuntime { } } + fn spawn_lease_scoped_reconciler( + &self, + cancel: watch::Receiver, + ) -> Option> { + self.lease_scoped_reconciler.clone().map(|reconciler| { + tokio::spawn(async move { + reconciler.run(cancel).await; + }) + }) + } + pub async fn cleanup_on_shutdown(&self) -> Result<(), String> { let stop_result = self.stop_persisted_sandboxes_on_shutdown().await; @@ -2239,17 +2493,64 @@ impl ComputeRuntime { } } - /// Reconcile running intent for local compute after a gateway restart. - /// - /// `StartSandbox` is idempotent, so call it for every persisted phase that - /// requires running compute for drivers that request gateway-managed - /// lifecycle. Stable stopped and deleting states are deliberately left - /// alone. Error-phase sandboxes are included only when their Ready - /// condition indicates the runtime went away underneath a running container - /// — a signal-kill from a machine/daemon restart or an explicit runtime - /// stop. If the container still exists it is restarted and the sandbox is - /// moved back to `Provisioning`; otherwise it stays in `Error`. Ordinary - /// application exits and crashes stay terminal and are not relaunched. + pub async fn reconcile_sandbox_templates( + &self, + templates: &[SandboxWorkloadTemplate], + ) -> Result { + if !self.driver_info.supports_sandbox_template_reconciliation { + return Ok(ReconcileSandboxTemplatesResponse::default()); + } + let mut driver_templates = Vec::with_capacity(templates.len()); + for template in templates { + match driver_sandbox_template_resource_from_public(template, &self.driver_info.name) { + Ok(template) => driver_templates.push(template), + Err(err) if err.code() == Code::InvalidArgument => { + warn!( + template_id = template + .metadata + .as_ref() + .map_or("", |metadata| metadata.id.as_str()), + template_name = template + .metadata + .as_ref() + .map_or("", |metadata| metadata.name.as_str()), + workspace = template + .metadata + .as_ref() + .map_or("", |metadata| metadata.workspace.as_str()), + error = %err, + "Skipping invalid sandbox template during reconciliation" + ); + } + Err(err) => return Err(*err), + } + } + let reconciler = self.sandbox_template_reconciler.clone().ok_or_else(|| { + Status::failed_precondition("sandbox-template reconciler unavailable") + })?; + self.driver + .call_reconciler( + reconciler, + openshell_otel::rpc::RECONCILE_SANDBOX_TEMPLATES, + |reconciler| async move { + reconciler + .reconcile_sandbox_templates(Request::new( + ReconcileSandboxTemplatesRequest { + templates: driver_templates, + }, + )) + .await + }, + ) + .await + .map(tonic::Response::into_inner) + } + + /// Start sandboxes whose store records say they should be running. + /// For each sandbox in the store whose phase is not `Deleting` or `Error`, + /// ask the driver to start the underlying resource. If the driver reports + /// that the resource no longer exists or fails to start, move the sandbox + /// to the `Error` phase so the failure surfaces in the UI. /// /// Should be called once at gateway startup, before watchers spawn, /// so the watch loop sees the post-start state on its first poll. @@ -2650,6 +2951,8 @@ impl ComputeRuntime { runtime.reconcile_loop(cancel_rx).await; }); + let lease_scoped_handle = self.spawn_lease_scoped_reconciler(cancel_tx.subscribe()); + loop { tokio::select! { () = tokio::time::sleep(LEASE_RENEWAL_INTERVAL) => { @@ -2676,6 +2979,9 @@ impl ComputeRuntime { let _ = cancel_tx.send(true); let _ = watch_handle.await; let _ = reconcile_handle.await; + if let Some(handle) = lease_scoped_handle { + let _ = handle.await; + } return; } } @@ -2685,6 +2991,9 @@ impl ComputeRuntime { let _ = cancel_tx.send(true); let _ = watch_handle.await; let _ = reconcile_handle.await; + if let Some(handle) = lease_scoped_handle { + let _ = handle.await; + } info!(replica = %lease.replica_id(), "reconciler lease lost — returning to standby"); } @@ -3916,7 +4225,7 @@ fn driver_sandbox_spec_from_public( template: spec .template .as_ref() - .map(|template| driver_sandbox_template_from_public(template, driver_name)) + .map(|template| driver_inline_sandbox_template_from_public(template, driver_name)) .transpose()?, policy: spec.policy.clone(), resource_requirements: spec.resource_requirements.as_ref().map(|requirements| { @@ -3934,7 +4243,75 @@ fn driver_sandbox_spec_from_public( }) } -fn driver_sandbox_template_from_public( +fn driver_sandbox_template_resource_from_public( + template: &SandboxWorkloadTemplate, + driver_name: &str, +) -> Result> { + let metadata = template + .metadata + .as_ref() + .ok_or_else(|| Box::new(Status::invalid_argument("template metadata is required")))?; + let spec = template + .spec + .as_ref() + .ok_or_else(|| Box::new(Status::invalid_argument("template spec is required")))?; + let workload = spec + .workload + .as_ref() + .ok_or_else(|| Box::new(Status::invalid_argument("template workload is required")))?; + + Ok(DriverSandboxTemplateResource { + id: metadata.id.clone(), + name: metadata.name.clone(), + workspace: metadata.workspace.clone(), + resource_version: metadata.resource_version, + labels: metadata.labels.clone(), + annotations: metadata.annotations.clone(), + deletion_timestamp_ms: metadata.deletion_timestamp_ms, + template: Some(driver_sandbox_template_from_workload( + workload, + &spec.driver_config, + driver_name, + )?), + desired_service_level: spec + .desired_service_level + .as_ref() + .map(driver_template_service_level_from_public), + resource_requirements: driver_resource_requirements_from_workload(workload), + }) +} + +fn driver_template_service_level_from_public( + service_level: &SandboxServiceLevel, +) -> DriverSandboxTemplateServiceLevel { + DriverSandboxTemplateServiceLevel { + startup: service_level + .startup + .as_ref() + .map(driver_template_startup_from_public), + } +} + +fn driver_template_startup_from_public(startup: &SandboxStartup) -> DriverSandboxTemplateStartup { + DriverSandboxTemplateStartup { + ready_within: startup.ready_within, + max_burst: startup.max_burst, + } +} + +fn driver_resource_requirements_from_workload( + workload: &SandboxWorkloadConfig, +) -> Option { + let resources = workload.resources.as_ref()?; + resources + .gpu + .as_ref() + .map(|gpu| DriverSandboxResourceRequirements { + gpu: Some(DriverGpuResourceRequirements { count: gpu.count }), + }) +} + +fn driver_inline_sandbox_template_from_public( template: &SandboxTemplate, driver_name: &str, ) -> Result> { @@ -4020,6 +4397,36 @@ fn set_rootfs_tar_path(driver_sandbox: &mut DriverSandbox, path: &Path) { ); } +fn driver_sandbox_template_from_workload( + workload: &SandboxWorkloadConfig, + driver_config: &Option, + driver_name: &str, +) -> Result> { + Ok(DriverSandboxTemplate { + image: workload.image.clone(), + agent_socket_path: String::new(), + labels: HashMap::new(), + environment: workload.environment.clone(), + resources: workload + .resources + .as_ref() + .map(driver_resource_limits_from_workload), + platform_config: None, + driver_config: select_driver_config(driver_config, driver_name)?, + user_namespaces: None, + }) +} + +fn driver_resource_limits_from_workload( + resources: &SandboxResources, +) -> DriverResourceRequirements { + DriverResourceRequirements { + cpu_limit: resources.cpu.clone(), + memory_limit: resources.memory.clone(), + ..Default::default() + } +} + fn select_driver_config( config: &Option, driver_name: &str, @@ -4719,20 +5126,6 @@ impl NoopTestDriver { sandbox_authentication: None, } } - - pub fn authenticating_sandbox(sandbox_id: impl Into) -> Self { - Self { - workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), - sandbox_authentication: Some(Ok(sandbox_id.into())), - } - } - - pub fn failing_sandbox_authentication(code: Code, message: impl Into) -> Self { - Self { - workspace_delete_failures: std::sync::atomic::AtomicUsize::new(0), - sandbox_authentication: Some(Err((code, message.into()))), - } - } } #[cfg(test)] @@ -4758,7 +5151,9 @@ impl ComputeDriver for NoopTestDriver { match &self.sandbox_authentication { Some(Ok(sandbox_id)) => Ok(tonic::Response::new( openshell_core::proto::compute::v1::AuthenticateSandboxResponse { - sandbox_id: sandbox_id.clone(), + binding: Some(authenticate_sandbox_response::Binding::SandboxId( + sandbox_id.clone(), + )), }, )), Some(Err((code, message))) => Err(Status::new(*code, message.clone())), @@ -4786,6 +5181,8 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, }, )) } @@ -4933,6 +5330,8 @@ pub async fn new_test_runtime_with_driver( resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_sandbox_template_reconciliation: false, + supports_warm_supervisor_bootstrap: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -4942,6 +5341,10 @@ pub async fn new_test_runtime_with_driver( sandbox_watch_bus: SandboxWatchBus::new(), tracing_log_bus: TracingLogBus::new(), supervisor_sessions: Arc::new(SupervisorSessionRegistry::new()), + supervisor_bootstrap_identity: None, + sandbox_claim_activation: None, + sandbox_template_reconciler: None, + lease_scoped_reconciler: None, sync_lock: Arc::new(Mutex::new(())), lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), @@ -4965,6 +5368,22 @@ mod tests { use tokio::sync::{Notify, Semaphore, mpsc, oneshot}; use tokio_stream::wrappers::UnboundedReceiverStream; + struct TestLeaseScopedReconciler { + started: Arc, + stopped: Arc, + } + + #[tonic::async_trait] + impl LeaseScopedReconciler for TestLeaseScopedReconciler { + async fn run(&self, mut cancel: watch::Receiver) { + self.started.notify_one(); + if !*cancel.borrow() { + let _ = cancel.changed().await; + } + self.stopped.notify_one(); + } + } + fn string_value(value: &str) -> prost_types::Value { prost_types::Value { kind: Some(prost_types::value::Kind::StringValue(value.to_string())), @@ -5216,6 +5635,9 @@ mod tests { listed_sandboxes: Vec, current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, + create_error: Option, + supports_sandbox_template_reconciliation: bool, + supports_warm_supervisor_bootstrap: bool, } #[tonic::async_trait] @@ -5248,6 +5670,9 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: self.supports_warm_supervisor_bootstrap, + supports_sandbox_template_reconciliation: self + .supports_sandbox_template_reconciliation, })) } @@ -5315,6 +5740,9 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + if let Some(code) = self.create_error { + return Err(Status::new(code, "controlled create error")); + } Ok(tonic::Response::new(CreateSandboxResponse {})) } @@ -5592,6 +6020,8 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, })) } @@ -5805,6 +6235,8 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_sandbox_template_reconciliation: false, + supports_warm_supervisor_bootstrap: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -5814,6 +6246,10 @@ mod tests { sandbox_watch_bus: SandboxWatchBus::new(), tracing_log_bus: TracingLogBus::new(), supervisor_sessions: Arc::new(SupervisorSessionRegistry::new()), + supervisor_bootstrap_identity: None, + sandbox_claim_activation: None, + sandbox_template_reconciler: None, + lease_scoped_reconciler: None, sync_lock: Arc::new(Mutex::new(())), lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), @@ -5831,6 +6267,64 @@ mod tests { runtime } + #[tokio::test] + async fn lease_scoped_reconciler_stops_when_cancelled() { + let mut runtime = test_runtime(Arc::new(TestDriver::default())).await; + let started = Arc::new(Notify::new()); + let stopped = Arc::new(Notify::new()); + runtime.lease_scoped_reconciler = Some(Arc::new(TestLeaseScopedReconciler { + started: started.clone(), + stopped: stopped.clone(), + })); + + let (cancel_tx, cancel_rx) = watch::channel(false); + let handle = runtime + .spawn_lease_scoped_reconciler(cancel_rx) + .expect("configured reconciler should start"); + tokio::time::timeout(Duration::from_secs(1), started.notified()) + .await + .expect("reconciler should start"); + + cancel_tx.send(true).unwrap(); + handle.await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), stopped.notified()) + .await + .expect("reconciler should stop after cancellation"); + } + + #[tokio::test] + async fn lease_holder_runs_and_awaits_lease_scoped_reconciler() { + let driver = ControlledDriver::new(); + let mut runtime = test_runtime(driver).await; + let started = Arc::new(Notify::new()); + let stopped = Arc::new(Notify::new()); + runtime.lease_scoped_reconciler = Some(Arc::new(TestLeaseScopedReconciler { + started: started.clone(), + stopped: stopped.clone(), + })); + + let lease = lease::ReconcilerLease::new( + runtime.store.clone(), + runtime.replica_id.clone(), + lease::LEASE_TTL, + ); + let guard = lease.acquire_or_steal().await.unwrap(); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + let runtime = Arc::new(runtime); + let holder = tokio::spawn(async move { + runtime.run_as_holder(&lease, guard, &mut shutdown_rx).await; + }); + + tokio::time::timeout(Duration::from_secs(1), started.notified()) + .await + .expect("lease holder should start the reconciler"); + shutdown_tx.send(true).unwrap(); + holder.await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), stopped.notified()) + .await + .expect("lease holder should await reconciler shutdown"); + } + fn register_test_supervisor_session(runtime: &ComputeRuntime, sandbox_id: &str) { let (tx, _rx) = mpsc::channel(1); let (shutdown_tx, _shutdown_rx) = oneshot::channel(); @@ -5961,6 +6455,38 @@ mod tests { ); } + #[tokio::test] + async fn ambiguous_create_preserves_provisioning_record() { + let runtime = test_runtime(Arc::new(TestDriver { + create_error: Some(Code::Unavailable), + ..Default::default() + })) + .await; + let sandbox = sandbox_record("sb-ambiguous", "sandbox-a", SandboxPhase::Provisioning); + + let err = runtime + .create_sandbox(sandbox, None, false, None) + .await + .expect_err("ambiguous create should remain unavailable"); + + assert_eq!(err.code(), Code::Unavailable); + let stored = runtime + .store + .get_message::("sb-ambiguous") + .await + .unwrap() + .expect("provisioning record must be retained"); + assert_eq!(stored.phase(), SandboxPhase::Provisioning as i32); + assert_eq!(stored.object_name(), "sandbox-a"); + assert_eq!( + runtime + .sandbox_index + .sandbox_id_for_sandbox_name("default", "sandbox-a") + .as_deref(), + Some("sb-ambiguous") + ); + } + #[tokio::test] async fn missing_sandbox_main_process_exit_is_acknowledged() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; @@ -6210,6 +6736,49 @@ mod tests { sandbox } + #[tokio::test] + async fn ambiguous_create_is_pruned_when_backend_resource_never_appears() { + let runtime = test_runtime(Arc::new(TestDriver { + create_error: Some(Code::Unavailable), + ..Default::default() + })) + .await; + let sandbox = sandbox_record("sb-ambiguous", "sandbox-a", SandboxPhase::Provisioning); + + let err = runtime + .create_sandbox(sandbox, None, false, None) + .await + .expect_err("ambiguous create should remain unavailable"); + assert_eq!(err.code(), Code::Unavailable); + assert_eq!( + runtime + .sandbox_index + .sandbox_id_for_sandbox_name("default", "sandbox-a") + .as_deref(), + Some("sb-ambiguous") + ); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-ambiguous") + .await + .unwrap() + .is_none() + ); + assert!( + runtime + .sandbox_index + .sandbox_id_for_sandbox_name("default", "sandbox-a") + .is_none() + ); + } + fn ssh_session_record(id: &str, sandbox_id: &str) -> SshSession { SshSession { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -6805,7 +7374,7 @@ mod tests { let traced = test_exporter::install_traced(); async { runtime - .create_sandbox(sandbox, None, false) + .create_sandbox(sandbox, None, false, None) .await .expect("create succeeds"); } @@ -7011,7 +7580,7 @@ mod tests { let traced = test_exporter::install_traced(); async { runtime - .create_sandbox(sandbox, None, false) + .create_sandbox(sandbox, None, false, None) .await .expect_err("driver refuses the create"); } @@ -8097,7 +8666,10 @@ mod tests { ..Default::default() }); - runtime.create_sandbox(sandbox, None, false).await.unwrap(); + runtime + .create_sandbox(sandbox, None, false, None) + .await + .unwrap(); runtime .apply_sandbox_update(ready_driver_sandbox("sb-1", "sandbox-a")) .await @@ -9920,6 +10492,7 @@ mod tests { }), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10091,6 +10664,7 @@ mod tests { })), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10869,13 +11443,35 @@ mod tests { user_namespaces: Some(true), ..SandboxTemplate::default() }; - let driver_template = driver_sandbox_template_from_public(&template, "test") + let driver_template = driver_inline_sandbox_template_from_public(&template, "test") .expect("template conversion should succeed"); assert_eq!(driver_template.user_namespaces, Some(true)); assert!(driver_template.platform_config.is_none()); } + #[test] + fn driver_workload_template_preserves_cpu_memory_limits() { + let workload = SandboxWorkloadConfig { + image: "registry.example.com/agent:latest".to_string(), + resources: Some(SandboxResources { + cpu: "500m".to_string(), + memory: "2Gi".to_string(), + ..Default::default() + }), + ..Default::default() + }; + + let driver_template = driver_sandbox_template_from_workload(&workload, &None, "kubernetes") + .expect("template conversion should succeed"); + let resources = driver_template + .resources + .expect("CPU and memory limits should be forwarded to the driver"); + + assert_eq!(resources.cpu_limit, "500m"); + assert_eq!(resources.memory_limit, "2Gi"); + } + #[tokio::test] async fn compute_driver_initialization_records_an_operation_span() { use crate::otel_tracing::test_exporter; @@ -10891,6 +11487,9 @@ mod tests { SandboxWatchBus::new(), TracingLogBus::new(), Arc::new(SupervisorSessionRegistry::new()), + None, + None, + None, ) .await .unwrap(); @@ -10899,6 +11498,211 @@ mod tests { test_exporter::assert_is_root(&initialization); } + #[tokio::test] + async fn template_reconciliation_sends_one_authoritative_snapshot() { + use crate::test_support::{FakeComputeDriver, FakeComputeDriverCall}; + + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let driver = FakeComputeDriver::new().with_sandbox_template_reconciliation(); + let runtime = ComputeRuntime::from_driver( + "kubernetes".to_string(), + Arc::new(driver.clone()), + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + None, + Some(Arc::new(driver.clone())), + ) + .await + .unwrap(); + driver.clear_calls(); + let template = SandboxWorkloadTemplate { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "template-id".to_string(), + name: "python".to_string(), + workspace: "default".to_string(), + resource_version: 7, + ..Default::default() + }), + spec: Some(openshell_core::proto::SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: "registry.example.com/python:latest".to_string(), + ..Default::default() + }), + ..Default::default() + }), + }; + + let result = runtime + .reconcile_sandbox_templates(&[template]) + .await + .unwrap(); + assert_eq!(result.reconciled, 1); + assert_eq!(result.pruned, 0); + assert!(matches!( + driver.calls().as_slice(), + [FakeComputeDriverCall::ReconcileSandboxTemplates { templates }] + if templates.len() == 1 + && templates[0].id == "template-id" + && templates[0].resource_version == 7 + )); + } + + #[tokio::test] + async fn template_reconciliation_skips_invalid_templates_in_authoritative_snapshot() { + use crate::test_support::{FakeComputeDriver, FakeComputeDriverCall}; + + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let driver = FakeComputeDriver::new().with_sandbox_template_reconciliation(); + let runtime = ComputeRuntime::from_driver( + "kubernetes".to_string(), + Arc::new(driver.clone()), + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + None, + Some(Arc::new(driver.clone())), + ) + .await + .unwrap(); + driver.clear_calls(); + + let template = |id: &str| SandboxWorkloadTemplate { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: id.to_string(), + name: id.to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + spec: Some(openshell_core::proto::SandboxWorkloadTemplateSpec { + workload: Some(SandboxWorkloadConfig { + image: "registry.example.com/python:latest".to_string(), + ..Default::default() + }), + ..Default::default() + }), + }; + let valid = template("valid-template"); + let mut invalid = template("invalid-template"); + invalid.spec.as_mut().unwrap().driver_config = Some(prost_types::Struct { + fields: std::iter::once(("kubernetes".to_string(), string_value("not-an-object"))) + .collect(), + }); + + let result = runtime + .reconcile_sandbox_templates(&[invalid, valid]) + .await + .unwrap(); + assert_eq!(result.reconciled, 1); + assert_eq!(result.pruned, 0); + assert!(matches!( + driver.calls().as_slice(), + [FakeComputeDriverCall::ReconcileSandboxTemplates { templates }] + if templates.len() == 1 && templates[0].id == "valid-template" + )); + } + + #[tokio::test] + async fn compute_driver_rejects_warm_capability_without_activation_controller() { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let error = ComputeRuntime::from_driver( + "test-driver".to_string(), + Arc::new(TestDriver { + supports_warm_supervisor_bootstrap: true, + ..TestDriver::default() + }), + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + None, + None, + ) + .await + .expect_err("warm capability without activation controller must fail"); + + assert!( + error + .to_string() + .contains("without an activation controller") + ); + } + + #[tokio::test] + async fn compute_driver_rejects_reconciliation_capability_without_reconciler_service() { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let error = ComputeRuntime::from_driver( + "test-driver".to_string(), + Arc::new(TestDriver { + supports_sandbox_template_reconciliation: true, + ..TestDriver::default() + }), + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + None, + None, + ) + .await + .expect_err("reconciliation capability without a reconciler service must fail"); + + assert!( + error + .to_string() + .contains("without providing the optional reconciler service") + ); + } + + #[tokio::test] + async fn compute_driver_discards_activation_controller_without_warm_capability() { + struct TestActivationSpawner; + + impl SandboxClaimActivationSpawner for TestActivationSpawner { + fn spawn( + &self, + _activator: Arc, + _registration_rx: watch::Receiver, + _shutdown_rx: watch::Receiver, + ) { + panic!("activation controller must not start without warm capability"); + } + } + + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let runtime = ComputeRuntime::from_driver( + "test-driver".to_string(), + Arc::new(TestDriver::default()), + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + Some(Arc::new(TestActivationSpawner)), + None, + ) + .await + .expect("non-warm drivers should ignore activation controllers"); + + assert!(runtime.sandbox_claim_activation.is_none()); + } + #[tokio::test] #[cfg(unix)] async fn remote_compute_driver_interceptor_propagates_every_rpc() { @@ -10912,6 +11716,7 @@ mod tests { let endpoint = connect_remote_compute_driver("external-test", &socket_path) .await .unwrap(); + let reconciler = RemoteSandboxTemplateReconciler::new(endpoint.channel.clone()); let remote = RemoteComputeDriver::new(endpoint.channel); let sandbox = DriverSandbox { id: "sb-trace".to_string(), @@ -10937,9 +11742,16 @@ mod tests { })) .await .unwrap(); + reconciler + .reconcile_sandbox_templates(Request::new(ReconcileSandboxTemplatesRequest { + templates: Vec::new(), + })) + .await + .unwrap(); remote .create_sandbox(Request::new(CreateSandboxRequest { sandbox: Some(sandbox.clone()), + sandbox_template: None, })) .await .unwrap(); @@ -10982,7 +11794,7 @@ mod tests { let traceparents = driver.traceparents(); assert_eq!( traceparents.len(), - 9, + 10, "the client interceptor should cover every RPC" ); assert!( @@ -11077,6 +11889,11 @@ mod tests { ); let mut sandbox = sandbox_record("sb-uds", "uds-sandbox", SandboxPhase::Provisioning); + sandbox.created_from_workload_template = + Some(openshell_core::proto::SandboxWorkloadTemplateProvenance { + name: "template-a".to_string(), + resource_version: "42".to_string(), + }); sandbox.spec = Some(SandboxSpec { log_level: "debug".to_string(), policy: Some(openshell_core::proto::SandboxPolicy { @@ -11105,7 +11922,10 @@ mod tests { }); runtime.validate_sandbox_create(&sandbox).await.unwrap(); - runtime.create_sandbox(sandbox, None, false).await.unwrap(); + runtime + .create_sandbox(sandbox, None, false, None) + .await + .unwrap(); let calls = driver.calls(); assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); let validated = match &calls[2] { @@ -11132,7 +11952,10 @@ mod tests { ); assert!(matches!( &calls[3], - FakeComputeDriverCall::CreateSandbox { sandbox: Some(sandbox) } + FakeComputeDriverCall::CreateSandbox { + sandbox: Some(sandbox), + .. + } if sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()) .is_some_and(|policy| policy.version == 42) )); @@ -11232,7 +12055,10 @@ mod tests { deletion_timestamp_ms: 0, }); - let created = runtime.create_sandbox(sandbox, None, false).await.unwrap(); + let created = runtime + .create_sandbox(sandbox, None, false, None) + .await + .unwrap(); assert_eq!( created.metadata.as_ref().unwrap().resource_version, @@ -11267,7 +12093,10 @@ mod tests { .labels .insert("env".to_string(), "prod".to_string()); - runtime.create_sandbox(sandbox, None, false).await.unwrap(); + runtime + .create_sandbox(sandbox, None, false, None) + .await + .unwrap(); let matching = runtime .store @@ -11292,12 +12121,12 @@ mod tests { let runtime1 = runtime.clone(); let sandbox1 = sandbox.clone(); let handle1 = - tokio::spawn(async move { runtime1.create_sandbox(sandbox1, None, false).await }); + tokio::spawn(async move { runtime1.create_sandbox(sandbox1, None, false, None).await }); let runtime2 = runtime.clone(); let sandbox2 = sandbox.clone(); let handle2 = - tokio::spawn(async move { runtime2.create_sandbox(sandbox2, None, false).await }); + tokio::spawn(async move { runtime2.create_sandbox(sandbox2, None, false, None).await }); // Wait for both to complete let result1 = handle1.await.unwrap(); diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 64ac953624..627646e77c 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -149,7 +149,7 @@ pub struct GatewayFileSection { #[serde(default)] pub enable_user_namespaces: Option, /// Lifetime (seconds) of the projected `ServiceAccount` token kubelet - /// writes for the `IssueSandboxToken` bootstrap exchange. Driver + /// writes for the `RegisterSupervisor` bootstrap stream. Driver /// clamps to `[600, 86400]`. #[serde(default)] pub sa_token_ttl_secs: Option, diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 62547cc290..7445849d67 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -5,7 +5,8 @@ //! //! Hosts authenticated identity RPCs: //! - `GetCurrentUser` — report the gateway-validated caller identity -//! - `IssueSandboxToken` — driver-native bootstrap exchange → gateway JWT +//! - `RegisterSupervisor` — Kubernetes supervisor registration and activation +//! - `IssueSandboxToken` — legacy bootstrap compatibility shim //! - `RefreshSandboxToken` — renew a still-valid gateway JWT //! //! Both end in a fresh gateway-signed JWT minted by @@ -14,19 +15,27 @@ use crate::ServerState; use crate::auth::identity::IdentityProvider; -use crate::auth::principal::{Principal, SandboxIdentitySource}; +use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; +use crate::warm_pod_activation::{load_sandbox, mint_pod_activation}; use openshell_core::proto::{ ExtensionServiceCredential, GetCurrentUserRequest, GetCurrentUserResponse, GetSandboxConfigRequest, IssueSandboxTokenRequest, IssueSandboxTokenResponse, - RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, Sandbox, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RegisterSupervisorRequest, + SupervisorActivationMessage, }; +use openshell_core::supervisor_bootstrap::SupervisorBootstrapIdentity; use openshell_extension_core::{ExtensionAudience, ExtensionCallerKind, MAX_EXTENSION_TOKEN_TTL}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; +use std::{pin::Pin, result::Result as StdResult}; +use tokio_stream::Stream; use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; +pub type RegisterSupervisorStream = + Pin> + Send + 'static>>; + #[allow(clippy::result_large_err, clippy::unused_async)] pub async fn handle_get_current_user( request: Request, @@ -59,49 +68,38 @@ pub async fn handle_issue_sandbox_token( state: &Arc, request: Request, ) -> Result, Status> { - let principal = request - .extensions() - .get::() - .cloned() - .ok_or_else(|| Status::unauthenticated("missing principal"))?; - - let Principal::Sandbox(sandbox) = principal else { - return Err(Status::permission_denied( - "IssueSandboxToken requires a sandbox principal", - )); - }; + // Compatibility shim for older supervisor images. New Kubernetes + // supervisors should use RegisterSupervisor so warm-pool activation can + // later remain pending on the same bootstrap stream. + let sandbox = require_bootstrap_sandbox(request.extensions(), "IssueSandboxToken")?; + let activation = mint_pod_activation(state, &sandbox.sandbox_id, "IssueSandboxToken").await?; + Ok(Response::new(IssueSandboxTokenResponse { + token: activation.token, + expires_at_ms: activation.token_expires_at_ms, + })) +} - // Only a selected compute driver may establish the bootstrap sandbox - // identity. Sandboxes already holding a gateway JWT use refresh instead. - if !matches!(sandbox.source, SandboxIdentitySource::ComputeDriver { .. }) { - debug!( - sandbox_id = %sandbox.sandbox_id, - "IssueSandboxToken rejected: non-bootstrap principal source" +#[allow(clippy::result_large_err, clippy::unused_async)] +pub async fn handle_register_supervisor( + state: &Arc, + request: Request, +) -> Result, Status> { + let identity = require_bootstrap_identity(request.extensions())?; + if let Some(sandbox_id) = identity.bound_sandbox_id() { + let activation = mint_pod_activation(state, sandbox_id, "RegisterSupervisor").await?; + info!( + sandbox_id = %activation.sandbox_id, + driver = %identity.driver, + instance_id = %identity.instance_id, + "activated bound supervisor instance" ); - return Err(Status::permission_denied( - "this principal cannot mint a sandbox token; use RefreshSandboxToken", - )); + return Ok(Response::new(Box::pin(tokio_stream::once(Ok(activation))))); } - let issuer = state.sandbox_jwt_issuer.as_ref().ok_or_else(|| { - warn!( - sandbox_id = %sandbox.sandbox_id, - "IssueSandboxToken called but sandbox JWT issuer is not configured" - ); - Status::unavailable("sandbox JWT minting is not configured on this gateway") - })?; - - ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; - - let minted = issuer.mint(&sandbox.sandbox_id)?; - info!( - sandbox_id = %sandbox.sandbox_id, - "issued gateway sandbox JWT" - ); - Ok(Response::new(IssueSandboxTokenResponse { - token: minted.token, - expires_at_ms: minted.expires_at_ms, - })) + let stream = state + .supervisor_pod_registrations + .register_pending(identity)?; + Ok(Response::new(Box::pin(stream))) } #[allow(clippy::result_large_err, clippy::unused_async)] @@ -122,15 +120,15 @@ pub async fn handle_refresh_sandbox_token( )); }; - // Only callers already holding a gateway-minted JWT may refresh; the - // K8s bootstrap path must use `IssueSandboxToken`. + // Only callers already holding a gateway-minted JWT may refresh; the K8s + // bootstrap path must use RegisterSupervisor. let SandboxIdentitySource::BootstrapJwt { .. } = &sandbox.source else { debug!( sandbox_id = %sandbox.sandbox_id, "RefreshSandboxToken rejected: non-gateway-JWT principal source" ); return Err(Status::permission_denied( - "this principal cannot refresh; use IssueSandboxToken for bootstrap", + "this principal cannot refresh; use RegisterSupervisor for bootstrap", )); }; @@ -142,7 +140,7 @@ pub async fn handle_refresh_sandbox_token( Status::unavailable("sandbox JWT minting is not configured on this gateway") })?; - ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; + load_sandbox(state, &sandbox.sandbox_id).await?; let minted = issuer.mint(&sandbox.sandbox_id)?; let extension_credentials = if requested_extension_services.is_empty() { @@ -263,19 +261,57 @@ fn mint_extension_credentials( .collect() } -async fn ensure_sandbox_exists(state: &Arc, sandbox_id: &str) -> Result<(), Status> { - if sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); +fn require_bootstrap_sandbox( + extensions: &tonic::Extensions, + rpc_name: &'static str, +) -> Result { + let principal = extensions + .get::() + .cloned() + .ok_or_else(|| Status::unauthenticated("missing principal"))?; + + let Principal::Sandbox(sandbox) = principal else { + return Err(Status::permission_denied(format!( + "{rpc_name} requires a sandbox principal" + ))); + }; + + if !matches!( + sandbox.source, + SandboxIdentitySource::SupervisorBootstrap { .. } + ) { + debug!( + sandbox_id = %sandbox.sandbox_id, + rpc = rpc_name, + "bootstrap RPC rejected: non-bootstrap principal source" + ); + return Err(Status::permission_denied( + "this principal cannot mint a sandbox token; use RefreshSandboxToken", + )); + } + + Ok(sandbox) +} + +fn require_bootstrap_identity( + extensions: &tonic::Extensions, +) -> Result { + if let Some(identity) = extensions.get::().cloned() { + return Ok(identity); } - state - .store - .get_message::(sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let principal = extensions + .get::() + .cloned() + .ok_or_else(|| Status::unauthenticated("missing principal"))?; - Ok(()) + let Principal::SupervisorBootstrap(identity) = principal else { + return Err(Status::permission_denied( + "RegisterSupervisor requires a supervisor bootstrap principal", + )); + }; + + Ok(identity) } #[cfg(test)] @@ -295,8 +331,10 @@ mod tests { use openshell_core::Config; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec}; + use openshell_core::supervisor_bootstrap::SupervisorBootstrapBinding; use std::collections::HashMap; use std::time::Duration; + use tokio_stream::StreamExt; async fn state_with_issuer() -> Arc { let mat = generate_jwt_key().expect("jwt key"); @@ -365,6 +403,14 @@ mod tests { }) } + fn bootstrap_identity(binding: SupervisorBootstrapBinding) -> SupervisorBootstrapIdentity { + SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: "uid-a".to_string(), + binding, + } + } + #[tokio::test] async fn current_user_returns_gateway_validated_identity() { let mut req = Request::new(GetCurrentUserRequest {}); @@ -514,8 +560,9 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::ComputeDriver { - driver_name: "kubernetes".to_string(), + source: SandboxIdentitySource::SupervisorBootstrap { + driver: "kubernetes".to_string(), + instance_id: "uid-a".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -527,6 +574,66 @@ mod tests { assert!(resp.expires_at_ms > 0); } + #[tokio::test] + async fn register_supervisor_returns_immediate_activation_for_existing_sandbox() { + let state = state_with_issuer().await; + let mut req = Request::new(RegisterSupervisorRequest {}); + req.extensions_mut() + .insert(Principal::SupervisorBootstrap(bootstrap_identity( + SupervisorBootstrapBinding::BoundSandbox { + sandbox_id: "sandbox-a".to_string(), + }, + ))); + + let mut stream = handle_register_supervisor(&state, req) + .await + .expect("register OK") + .into_inner(); + let activation = stream + .next() + .await + .expect("activation message") + .expect("activation OK"); + assert_eq!(activation.sandbox_id, "sandbox-a"); + assert_eq!(activation.sandbox_name, "sandbox-a"); + assert!(!activation.token.is_empty()); + assert!(activation.token_expires_at_ms > 0); + let main_process = activation + .startup_metadata + .get(openshell_core::sandbox_env::MAIN_PROCESS_SPEC) + .expect("main process activation metadata"); + let decoded = openshell_core::sandbox_env::MainProcessConfig::decode(main_process).unwrap(); + assert!(decoded.command.is_empty()); + assert!(!decoded.await_main_process_attachment); + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn register_supervisor_keeps_unbound_warm_pod_pending() { + let state = state_with_issuer().await; + let mut req = Request::new(RegisterSupervisorRequest {}); + req.extensions_mut() + .insert(Principal::SupervisorBootstrap(bootstrap_identity( + SupervisorBootstrapBinding::WarmPending { + activation_guard: "owner-uid-a".to_string(), + }, + ))); + + let mut stream = handle_register_supervisor(&state, req) + .await + .expect("register OK") + .into_inner(); + assert_eq!(state.supervisor_pod_registrations.pending_count(), 1); + assert!( + tokio::time::timeout(Duration::from_millis(20), stream.next()) + .await + .is_err(), + "unbound warm pod must wait for later activation" + ); + drop(stream); + assert_eq!(state.supervisor_pod_registrations.pending_count(), 0); + } + #[tokio::test] async fn issue_rejects_missing_sandbox() { use crate::auth::principal::SandboxIdentitySource; @@ -536,8 +643,9 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-deleted".to_string(), - source: SandboxIdentitySource::ComputeDriver { - driver_name: "kubernetes".to_string(), + source: SandboxIdentitySource::SupervisorBootstrap { + driver: "kubernetes".to_string(), + instance_id: "uid-a".to_string(), }, trust_domain: Some("openshell".to_string()), })); @@ -570,10 +678,10 @@ mod tests { } #[tokio::test] - async fn refresh_rejects_compute_driver_principal() { - // Driver-bootstrap principals must use IssueSandboxToken, not - // RefreshSandboxToken — the refresh path assumes a still-valid - // gateway-minted JWT exists. + async fn refresh_rejects_bootstrap_principal() { + // Bootstrap principals must use RegisterSupervisor, not + // RefreshSandboxToken. The refresh path assumes a still-valid + // gateway-minted JWT already exists. use crate::auth::principal::SandboxIdentitySource; let state = state_with_issuer().await; let mut req = Request::new(RefreshSandboxTokenRequest { @@ -582,14 +690,15 @@ mod tests { req.extensions_mut() .insert(Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::ComputeDriver { - driver_name: "kubernetes".to_string(), + source: SandboxIdentitySource::SupervisorBootstrap { + driver: "kubernetes".to_string(), + instance_id: "uid-a".to_string(), }, trust_domain: Some("openshell".to_string()), })); let err = handle_refresh_sandbox_token(&state, req) .await - .expect_err("K8s SA principal must not refresh"); + .expect_err("bootstrap principal must not refresh"); assert_eq!(err.code(), tonic::Code::PermissionDenied); } diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index c5a12a15b5..b38275e8c4 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -47,13 +47,14 @@ use openshell_core::proto::{ ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, ListWorkspacesResponse, MemoryResourceCapabilities, ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, - RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, - RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, ReportMainProcessExitRequest, - ReportMainProcessExitResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, - ResourceCapabilities, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, - SandboxTemplateResponse, ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, - StopSandboxRequest, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + RefreshSandboxTokenResponse, RegisterSupervisorRequest, RejectDraftChunkRequest, + RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, + RemoveWorkspaceMemberResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, ResourceCapabilities, + RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, + RotateProviderCredentialResponse, SandboxResponse, SandboxTemplateResponse, + ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, StopSandboxRequest, + SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorActivationMessage, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, @@ -700,6 +701,21 @@ impl OpenShell for OpenShellService { auth_rpc::handle_issue_sandbox_token(&self.state, request).await } + type RegisterSupervisorStream = Pin< + Box< + dyn tokio_stream::Stream> + + Send + + 'static, + >, + >; + + async fn register_supervisor( + &self, + request: Request, + ) -> Result, Status> { + auth_rpc::handle_register_supervisor(&self.state, request).await + } + async fn refresh_sandbox_token( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 054a3c34c6..c8fb8b71d3 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2346,6 +2346,9 @@ async fn resolve_sandbox_by_name_for_principal( Ok(sandbox) } Principal::User(_) => sandbox.ok_or_else(|| Status::not_found("sandbox not found")), + Principal::SupervisorBootstrap(_) => Err(Status::permission_denied( + "sandbox-scoped methods require a sandbox principal", + )), Principal::Anonymous => Err(Status::unauthenticated( "sandbox-scoped methods require an authenticated caller", )), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index e0ff130ebc..26380bf44e 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -16,6 +16,7 @@ use crate::auth::workspace_authz::{ use crate::persistence::{ObjectLabels, ObjectType, WriteCondition, generate_name}; use futures::future; use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::proto::compute::v1::DriverSandboxTemplateRef; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, @@ -342,30 +343,37 @@ async fn handle_create_sandbox_inner( .await? .ensure_active()?; - let (mut spec, created_from_workload_template) = if workload_template_name.is_empty() { - let spec = request - .spec - .ok_or_else(|| Status::invalid_argument("spec is required"))?; - (spec, None) - } else { - let governance_spec = request.spec.unwrap_or_default(); - let template = state - .store - .get_message_by_name::(&workspace, &workload_template_name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox template not found"))?; - let provenance = SandboxWorkloadTemplateProvenance { - name: template.object_name().to_string(), - resource_version: template.get_resource_version().to_string(), + let (mut spec, created_from_workload_template, driver_sandbox_template) = + if workload_template_name.is_empty() { + let spec = request + .spec + .ok_or_else(|| Status::invalid_argument("spec is required"))?; + (spec, None, None) + } else { + let governance_spec = request.spec.unwrap_or_default(); + let template = state + .store + .get_message_by_name::(&workspace, &workload_template_name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox template not found"))?; + let provenance = SandboxWorkloadTemplateProvenance { + name: template.object_name().to_string(), + resource_version: template.get_resource_version().to_string(), + }; + let driver_template_ref = DriverSandboxTemplateRef { + id: template.object_id().to_string(), + name: template.object_name().to_string(), + workspace: template.object_workspace().to_string(), + resource_version: template.get_resource_version(), + }; + let mut resolved = sandbox_spec_from_stored_workload_template(&template)?; + resolved.policy = governance_spec.policy; + resolved.providers = governance_spec.providers; + resolved.command = governance_spec.command; + resolved.tty = governance_spec.tty; + (resolved, Some(provenance), Some(driver_template_ref)) }; - let mut resolved = sandbox_spec_from_stored_workload_template(&template)?; - resolved.policy = governance_spec.policy; - resolved.providers = governance_spec.providers; - resolved.command = governance_spec.command; - resolved.tty = governance_spec.tty; - (resolved, Some(provenance)) - }; // Leave an omitted command empty rather than persisting a concrete shell: // the supervisor resolves the default login shell against the sandbox image @@ -454,6 +462,16 @@ async fn handle_create_sandbox_inner( }; let now_ms = current_time_ms(); + let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_public_spec( + Some(&spec), + await_main_process_attachment, + ) + .map_err(|err| Status::internal(format!("encode main process spec failed: {err}")))?; + let mut annotations = request.annotations.clone(); + annotations.insert( + openshell_core::sandbox_env::MAIN_PROCESS_SPEC_ANNOTATION.to_string(), + main_process_spec, + ); let mut sandbox = Sandbox { metadata: Some(ObjectMeta { @@ -462,7 +480,7 @@ async fn handle_create_sandbox_inner( created_at_ms: now_ms, labels: request.labels.clone(), resource_version: 0, - annotations: request.annotations.clone(), + annotations, workspace, deletion_timestamp_ms: 0, }), @@ -495,8 +513,11 @@ async fn handle_create_sandbox_inner( status })?; - // Mint a gateway JWT whenever the issuer is configured. Compute runtimes - // that bootstrap through another authentication mechanism may ignore it. + // Mint the gateway JWT for singleplayer drivers. K8s sandboxes skip + // this mint and bootstrap via `RegisterSupervisor` at supervisor + // startup; identifying "is this K8s?" lives in the compute layer, so + // we mint unconditionally here when the issuer is configured and let + // the K8s driver simply ignore the field. let sandbox_token = state.sandbox_jwt_issuer.as_ref().map(|issuer| { issuer.mint(&id).map(|minted| { tracing::info!( @@ -514,7 +535,12 @@ async fn handle_create_sandbox_inner( let sandbox = state .compute - .create_sandbox(sandbox, sandbox_token, await_main_process_attachment) + .create_sandbox( + sandbox, + sandbox_token, + await_main_process_attachment, + driver_sandbox_template, + ) .await?; info!( @@ -537,6 +563,15 @@ fn validate_create_sandbox_request_pre_io( crate::grpc::validation::validate_label_value(value)?; } crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + if request + .annotations + .contains_key(openshell_core::sandbox_env::MAIN_PROCESS_SPEC_ANNOTATION) + { + return Err(Status::invalid_argument(format!( + "{} is reserved", + openshell_core::sandbox_env::MAIN_PROCESS_SPEC_ANNOTATION + ))); + } if workload_template_name.is_empty() { let spec = request @@ -800,6 +835,12 @@ pub(super) async fn handle_create_sandbox_template( deletion_timestamp_ms: 0, }); validate_sandbox_workload_template(&resolved)?; + if let Some(metadata) = resolved.metadata.as_mut() { + // New object inserts always start at resource version one. Set it before + // preparing the outbox payload so both records describe the same + // authoritative template version inside the atomic transaction. + metadata.resource_version = 1; + } let labels_map = resolved.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { @@ -842,6 +883,8 @@ pub(super) async fn handle_create_sandbox_template( metadata.resource_version = write.resource_version; } + crate::template_reconciliation::notify_after_create(state); + Ok(Response::new(SandboxTemplateResponse { template: Some(resolved), })) @@ -967,15 +1010,28 @@ pub(super) async fn handle_delete_sandbox_template( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; + let existing = state + .store + .get_message_by_name::(&workspace, &req.name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox template failed: {e}")))?; + let Some(template) = existing else { + return Ok(Response::new(DeleteSandboxTemplateResponse { + deleted: false, + })); + }; let deleted = state .store - .delete_by_name( + .delete_if( SandboxWorkloadTemplate::object_type(), - &workspace, - &req.name, + template.object_id(), + template.get_resource_version(), ) .await - .map_err(|e| Status::internal(format!("delete sandbox template failed: {e}")))?; + .map_err(|error| super::persistence_error_to_status(error, "delete sandbox template"))?; + if deleted { + crate::template_reconciliation::notify_after_delete(state); + } Ok(Response::new(DeleteSandboxTemplateResponse { deleted })) } @@ -4550,6 +4606,35 @@ mod tests { } } + #[tokio::test] + async fn create_sandbox_rejects_reserved_main_process_annotation() { + let state = test_server_state().await; + + let err = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "reserved-main-process".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::from([( + openshell_core::sandbox_env::MAIN_PROCESS_SPEC_ANNOTATION.to_string(), + "user-supplied".to_string(), + )]), + workspace: String::new(), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!( + err.message() + .contains(openshell_core::sandbox_env::MAIN_PROCESS_SPEC_ANNOTATION) + ); + } + #[tokio::test] async fn create_sandbox_persists_long_metadata_annotations() { let state = test_server_state().await; @@ -4804,7 +4889,6 @@ mod tests { assert_eq!(metadata.workspace, "default"); assert!(!metadata.id.is_empty()); assert_ne!(metadata.resource_version, 0); - let fetched = handle_get_sandbox_template( &state, authed_request(GetSandboxTemplateRequest { diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index 30ca756a1b..8b26202506 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -66,6 +66,9 @@ fn membership_filter_subject<'a>( } } Principal::Sandbox(_) => Ok(None), + Principal::SupervisorBootstrap(_) => Err(Status::permission_denied( + "supervisor registration principals cannot list workspaces", + )), Principal::Anonymous => Err(Status::unauthenticated("authentication required")), } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 09bf280c9b..4b5747e353 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -35,8 +35,10 @@ mod sandbox_index; mod sandbox_watch; mod service_routing; mod ssh_sessions; +mod supervisor_pod_registration; pub mod supervisor_session; mod telemetry; +mod template_reconciliation; #[cfg(any(test, feature = "test-support"))] pub mod test_support; mod tls; @@ -44,10 +46,12 @@ mod tls; pub(crate) mod tls_test_utils; pub mod tracing_bus; mod tracing_setup; +pub(crate) mod warm_pod_activation; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::supervisor_bootstrap::SupervisorBootstrapIdentityProvider; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{Config, Error, ObjectLabels, Result}; use openshell_extension_core::{ @@ -303,11 +307,19 @@ pub struct ServerState { /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, + /// Pending supervisor registrations awaiting warm-pool + /// activation. + pub(crate) supervisor_pod_registrations: + Arc, + + /// Wakes durable sandbox-template lifecycle delivery after CRUD commits. + pub(crate) template_reconciliation_notify: Arc, + /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, /// Gateway-minted sandbox JWT issuer. `None` when `config.gateway_jwt` - /// is not configured; in that mode `IssueSandboxToken` returns + /// is not configured; in that mode Kubernetes bootstrap RPCs return /// `Status::unavailable`. Populated at startup from the on-disk key /// material that `certgen` writes. pub sandbox_jwt_issuer: Option>, @@ -317,10 +329,6 @@ pub struct ServerState { /// presenting a freshly minted token are recognized. pub sandbox_jwt_authenticator: Option>, - /// Optional selected-driver authenticator for the `IssueSandboxToken` - /// bootstrap path. - pub compute_driver_authenticator: Option>, - /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, @@ -418,10 +426,13 @@ impl ServerState { gateway_shutting_down: AtomicBool::new(false), extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), + supervisor_pod_registrations: Arc::new( + supervisor_pod_registration::SupervisorPodRegistrationRegistry::new(), + ), + template_reconciliation_notify: template_reconciliation::new_notify(), oidc_cache, sandbox_jwt_issuer: None, sandbox_jwt_authenticator: None, - compute_driver_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, provider_profile_sources: @@ -666,16 +677,6 @@ pub(crate) async fn run_server( spawn_gateway_extension_token_refresh(issuer, gateway_extension_credentials); } - if state.sandbox_jwt_issuer.is_some() && state.compute.supports_sandbox_authentication() { - state.compute_driver_authenticator = Some(Arc::new( - auth::compute_driver::ComputeDriverAuthenticator::new(state.compute.clone()), - )); - info!( - driver = state.compute.configured_driver_name(), - "compute-driver sandbox bootstrap authenticator enabled" - ); - } - let state = Arc::new(state); // Reconcile local-driver running intent before watchers spawn so their @@ -697,11 +698,19 @@ pub(crate) async fn run_server( ) .await?; + template_reconciliation::spawn_worker(state.clone(), shutdown_rx.clone()); + if let Err(err) = state.compute.start_persisted_sandboxes().await { warn!(error = %err, "Failed to start persisted sandboxes during startup"); } state.compute.spawn_watchers(shutdown_rx.clone()); + let supervisor_registration_rx = state.supervisor_pod_registrations.subscribe(); + state.compute.spawn_sandbox_claim_activation( + Arc::new(warm_pod_activation::GatewaySupervisorBootstrapActivator::new(state.clone())), + supervisor_registration_rx, + shutdown_rx.clone(), + ); ssh_sessions::spawn_session_reaper(store.clone(), Duration::from_hours(1)); supervisor_session::spawn_relay_reaper(state.clone(), Duration::from_secs(30)); provider_refresh::spawn_refresh_worker(state.clone(), Duration::from_mins(1)); @@ -1043,13 +1052,21 @@ async fn terminate_signal() { } pub use compute::{ - AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, + SandboxClaimActivationSpawner, SharedComputeDriver, SharedSandboxTemplateReconciler, }; /// Driver instance returned by a compiled compute-driver factory. pub enum ComputeDriverInstance { /// A driver hosted in the gateway process. InProcess(SharedComputeDriver), + /// A driver hosted in the gateway process with supervisor bootstrap hooks. + InProcessWithSupervisorBootstrap { + driver: SharedComputeDriver, + supervisor_bootstrap_identity: Option>, + sandbox_claim_activation: Option>, + sandbox_template_reconciler: Option, + }, /// A driver process launched and owned by the gateway. ManagedRemote(AcquiredRemoteDriverEndpoint), } @@ -1409,6 +1426,31 @@ async fn build_compute_runtime( sandbox_watch_bus, tracing_log_bus, supervisor_sessions, + None, + None, + None, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?, + ComputeDriverInstance::InProcessWithSupervisorBootstrap { + driver, + supervisor_bootstrap_identity, + sandbox_claim_activation, + sandbox_template_reconciler, + } => ComputeRuntime::from_driver( + registration.name, + driver, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + supervisor_bootstrap_identity, + sandbox_claim_activation, + sandbox_template_reconciler, ) .await .map_err(|error| { diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 21315f9830..8f1f09f429 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -739,7 +739,7 @@ fn gateway_principal_fields(principal: &Principal) -> BTreeMap { match &sandbox.source { SandboxIdentitySource::BootstrapJwt { .. } => "bootstrap_jwt", SandboxIdentitySource::BootstrapCert { .. } => "bootstrap_cert", - SandboxIdentitySource::ComputeDriver { .. } => "compute_driver", + SandboxIdentitySource::SupervisorBootstrap { .. } => "supervisor_bootstrap", } .to_string(), ); @@ -747,6 +747,11 @@ fn gateway_principal_fields(principal: &Principal) -> BTreeMap { fields.insert("trust_domain".to_string(), trust_domain.clone()); } } + Principal::SupervisorBootstrap(identity) => { + fields.insert("kind".to_string(), "supervisor_bootstrap".to_string()); + fields.insert("driver".to_string(), identity.driver.clone()); + fields.insert("instance_id".to_string(), identity.instance_id.clone()); + } Principal::Anonymous => { fields.insert("kind".to_string(), "anonymous".to_string()); } @@ -917,9 +922,12 @@ where /// Assemble the authenticator chain for the gateway. /// /// Chain order (first-match-wins): -/// 1. `ComputeDriverAuthenticator` (path-scoped to `IssueSandboxToken`) -/// — delegates a driver-native credential and receives a sandbox identity -/// so the handler can mint a gateway JWT. No-op on every other path. +/// 1. `SupervisorBootstrapAuthenticator` (path-scoped to supervisor bootstrap +/// RPCs) — delegates driver-native bootstrap token validation to the active +/// compute driver and resolves it to either a legacy `Principal::Sandbox` +/// for `IssueSandboxToken` or a registration-scoped principal for +/// `RegisterSupervisor`. No-op on every other path; only present when +/// the active driver exposes a bootstrap identity provider. /// 2. `SandboxJwtAuthenticator` — validates gateway-minted JWTs. Recognized /// via a distinctive `kid` so non-matching Bearer tokens fall through. /// 3. `OidcAuthenticator` — validates user Bearer tokens against the @@ -937,8 +945,10 @@ where /// to pass-through unless mTLS or local unauthenticated users are enabled. fn build_authenticator_chain(state: &ServerState) -> Option { let mut authenticators: Vec> = Vec::new(); - if let Some(driver) = state.compute_driver_authenticator.clone() { - authenticators.push(driver); + if let Some(provider) = state.compute.supervisor_bootstrap_identity_provider() { + authenticators.push(Arc::new( + crate::auth::k8s_sa::SupervisorBootstrapAuthenticator::new(provider), + )); } if let Some(jwt) = state.sandbox_jwt_authenticator.clone() { authenticators.push(jwt); @@ -965,6 +975,8 @@ fn build_authenticator_chain(state: &ServerState) -> Option /// `Principal::User` is gated by the RBAC `AuthzPolicy`. /// `Principal::Sandbox` is gated by a supervisor-method allowlist, then /// handlers enforce same-sandbox scope on request bodies. +/// `Principal::SupervisorBootstrap` is gated by the supervisor-registration method +/// only. #[derive(Clone)] pub struct AuthGrpcRouter { inner: S, @@ -1104,6 +1116,14 @@ where ))); } } + Principal::SupervisorBootstrap(ref identity) => { + if !crate::auth::method_authz::is_supervisor_registration_callable(&path) { + return Ok(status_response(tonic::Status::permission_denied( + "supervisor bootstrap principals may only register supervisors", + ))); + } + req.extensions_mut().insert(identity.clone()); + } Principal::Anonymous => { return Ok(status_response(tonic::Status::unauthenticated( "anonymous callers may not call authenticated methods", @@ -2564,6 +2584,9 @@ mod tests { Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; use http_body_util::Full; + use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapBinding, SupervisorBootstrapIdentity, + }; use std::sync::Arc; use std::sync::Mutex; use tower::Service; @@ -2663,6 +2686,16 @@ mod tests { }) } + fn bootstrap_registration_principal() -> Principal { + Principal::SupervisorBootstrap(SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: "pod-uid-a".to_string(), + binding: SupervisorBootstrapBinding::WarmPending { + activation_guard: "owner-uid-a".to_string(), + }, + }) + } + #[tokio::test] async fn mtls_peer_identity_fills_missing_principal_when_enabled() { let mock = Arc::new(MockAuthenticator::returning(Ok(None))); @@ -2830,6 +2863,40 @@ mod tests { )); } + #[tokio::test] + async fn bootstrap_registration_principal_can_only_register_supervisor() { + let mock = Arc::new(MockAuthenticator::returning(Ok(Some( + bootstrap_registration_principal(), + )))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + + let res = router + .call(empty_request("/openshell.v1.OpenShell/RegisterSupervisor")) + .await + .unwrap(); + + assert_eq!(res.status(), 200); + assert!(matches!( + seen.lock().unwrap().as_ref(), + Some(Principal::SupervisorBootstrap(_)) + )); + + let mock = Arc::new(MockAuthenticator::returning(Ok(Some( + bootstrap_registration_principal(), + )))); + let chain = AuthenticatorChain::new(vec![mock]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + let res = router + .call(empty_request("/openshell.v1.OpenShell/GetSandboxConfig")) + .await + .unwrap(); + + assert!(seen.lock().unwrap().is_none()); + assert_eq!(grpc_status(&res).as_deref(), Some("7")); + } /// A user principal — even one carrying `openshell:all` and the /// admin role — must not reach a `sandbox`-annotated method. The /// router enforces this from the per-handler auth-mode declarations @@ -2919,6 +2986,7 @@ mod tests { "/openshell.v1.OpenShell/DeleteSandbox", "/openshell.v1.OpenShell/CreateProvider", "/openshell.v1.OpenShell/ApproveDraftChunk", + "/openshell.v1.OpenShell/RegisterSupervisor", ] { let mock = Arc::new(MockAuthenticator::returning(Ok(Some(sandbox_principal())))); let chain = AuthenticatorChain::new(vec![mock]); diff --git a/crates/openshell-server/src/supervisor_pod_registration.rs b/crates/openshell-server/src/supervisor_pod_registration.rs new file mode 100644 index 0000000000..8e1356c69d --- /dev/null +++ b/crates/openshell-server/src/supervisor_pod_registration.rs @@ -0,0 +1,564 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pending supervisor supervisor registrations for warm-pool activation. +//! +//! Cold pods already bound to a sandbox are activated immediately by the +//! `RegisterSupervisor` handler. Warm pods can register before claim +//! assignment; the future claim controller will activate the stored stream once +//! it binds the exact pod UID to a sandbox. + +use openshell_core::proto::SupervisorActivationMessage; +use openshell_core::supervisor_bootstrap::SupervisorBootstrapIdentity; +use std::collections::HashMap; +use std::pin::Pin; +use std::result::Result as StdResult; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, watch}; +use tokio_stream::Stream; +use tokio_stream::wrappers::ReceiverStream; +use tonic::Status; +use tracing::{debug, info, warn}; + +const ACTIVATED_TOMBSTONE_TTL: Duration = Duration::from_hours(1); + +#[derive(Debug)] +pub struct SupervisorPodRegistrationRegistry { + inner: Mutex, + next_session_id: AtomicU64, + registration_generation: watch::Sender, +} + +impl Default for SupervisorPodRegistrationRegistry { + fn default() -> Self { + let (registration_generation, _) = watch::channel(0); + Self { + inner: Mutex::new(Inner::default()), + next_session_id: AtomicU64::new(0), + registration_generation, + } + } +} + +#[derive(Debug, Default)] +struct Inner { + pending_by_instance_id: HashMap, + activated_instance_id: HashMap, +} + +#[derive(Debug)] +struct PendingRegistration { + identity: SupervisorBootstrapIdentity, + sender: mpsc::Sender>, + session_id: u64, + registered_at: Instant, +} + +#[derive(Debug, Clone)] +pub struct PendingRegistrationSnapshot { + pub identity: SupervisorBootstrapIdentity, + pub session_id: u64, +} + +impl SupervisorPodRegistrationRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.registration_generation.subscribe() + } + + #[allow(clippy::result_large_err)] + pub fn register_pending( + self: &Arc, + identity: SupervisorBootstrapIdentity, + ) -> Result { + if identity.instance_id.is_empty() { + return Err(Status::permission_denied( + "registered supervisor instance ID is required", + )); + } + + let (sender, receiver) = mpsc::channel(1); + let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed); + let instance_id = identity.instance_id.clone(); + let driver = identity.driver.clone(); + let now = Instant::now(); + + let replaced = { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + prune_activated_tombstones(&mut inner, now); + inner.activated_instance_id.remove(&instance_id); + inner + .pending_by_instance_id + .insert( + instance_id.clone(), + PendingRegistration { + identity, + sender, + session_id, + registered_at: now, + }, + ) + .is_some() + }; + self.registration_generation + .send_modify(|generation| *generation = generation.wrapping_add(1)); + + if replaced { + info!( + driver = %driver, + instance_id = %instance_id, + "replaced duplicate pending supervisor registration" + ); + } else { + info!( + driver = %driver, + instance_id = %instance_id, + "registered warm supervisor instance pending activation" + ); + } + + Ok(PendingRegistrationStream { + registry: self.clone(), + instance_id, + session_id, + inner: ReceiverStream::new(receiver), + }) + } + + #[allow(clippy::result_large_err)] + pub(crate) fn pending_identity( + &self, + instance_id: &str, + ) -> Result { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + prune_activated_tombstones(&mut inner, Instant::now()); + if inner.activated_instance_id.contains_key(instance_id) { + return Err(Status::already_exists( + "supervisor instance has already been activated", + )); + } + inner + .pending_by_instance_id + .get(instance_id) + .map(|pending| PendingRegistrationSnapshot { + identity: pending.identity.clone(), + session_id: pending.session_id, + }) + .ok_or_else(|| Status::not_found("pending supervisor registration not found")) + } + + #[allow(clippy::result_large_err)] + pub(crate) fn activate_if_session( + &self, + instance_id: &str, + session_id: u64, + activation: SupervisorActivationMessage, + ) -> Result<(), Status> { + let now = Instant::now(); + let pending = { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + prune_activated_tombstones(&mut inner, now); + if inner.activated_instance_id.contains_key(instance_id) { + return Err(Status::already_exists( + "supervisor instance has already been activated", + )); + } + let Some(current) = inner.pending_by_instance_id.get(instance_id) else { + debug!( + instance_id = %instance_id, + "no pending supervisor registration to activate" + ); + return Err(Status::not_found( + "pending supervisor registration not found", + )); + }; + if current.session_id != session_id { + return Err(Status::aborted( + "pending supervisor registration was replaced", + )); + } + let pending = inner + .pending_by_instance_id + .remove(instance_id) + .expect("pending registration checked above"); + inner + .activated_instance_id + .insert(instance_id.to_string(), now); + pending + }; + + if pending.sender.try_send(Ok(activation)).is_err() { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + inner.activated_instance_id.remove(instance_id); + warn!( + instance_id = %instance_id, + "pending supervisor registration stream closed before activation" + ); + return Err(Status::unavailable( + "registered supervisor stream closed before activation", + )); + } + + info!( + instance_id = %instance_id, + pending_ms = pending.registered_at.elapsed().as_millis(), + "activated pending supervisor registration" + ); + Ok(()) + } + + #[allow(clippy::result_large_err)] + pub(crate) fn fail_if_session( + &self, + instance_id: &str, + session_id: u64, + status: Status, + ) -> Result<(), Status> { + let pending = { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + prune_activated_tombstones(&mut inner, Instant::now()); + if inner.activated_instance_id.contains_key(instance_id) { + return Err(Status::already_exists( + "supervisor instance has already been activated", + )); + } + let Some(current) = inner.pending_by_instance_id.get(instance_id) else { + return Err(Status::not_found( + "pending supervisor registration not found", + )); + }; + if current.session_id != session_id { + return Err(Status::aborted( + "pending supervisor registration was replaced", + )); + } + inner.pending_by_instance_id.remove(instance_id) + }; + + let Some(pending) = pending else { + debug!(instance_id = %instance_id, "no pending supervisor registration to fail"); + return Err(Status::not_found( + "pending supervisor registration not found", + )); + }; + + pending.sender.try_send(Err(status)).map_err(|_| { + warn!( + instance_id = %instance_id, + "pending supervisor registration stream closed before failure notification" + ); + Status::unavailable("registered supervisor stream closed before failure notification") + })?; + info!( + instance_id = %instance_id, + pending_ms = pending.registered_at.elapsed().as_millis(), + "failed pending supervisor registration" + ); + Ok(()) + } + + #[must_use] + #[allow(dead_code)] + pub fn pending_count(&self) -> usize { + self.inner + .lock() + .expect("pending pod registry poisoned") + .pending_by_instance_id + .len() + } + + #[must_use] + #[allow(dead_code)] + pub fn activated_count(&self) -> usize { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + prune_activated_tombstones(&mut inner, Instant::now()); + inner.activated_instance_id.len() + } + + fn remove_if_session(&self, instance_id: &str, session_id: u64) { + let removed = { + let mut inner = self.inner.lock().expect("pending pod registry poisoned"); + if inner + .pending_by_instance_id + .get(instance_id) + .is_some_and(|pending| pending.session_id == session_id) + { + inner.pending_by_instance_id.remove(instance_id) + } else { + None + } + }; + + if let Some(pending) = removed { + debug!( + driver = %pending.identity.driver, + instance_id = %instance_id, + pending_ms = pending.registered_at.elapsed().as_millis(), + "removed pending supervisor registration" + ); + } + } +} + +fn prune_activated_tombstones(inner: &mut Inner, now: Instant) { + inner.activated_instance_id.retain(|_, activated_at| { + now.saturating_duration_since(*activated_at) < ACTIVATED_TOMBSTONE_TTL + }); +} + +pub struct PendingRegistrationStream { + registry: Arc, + instance_id: String, + session_id: u64, + inner: ReceiverStream>, +} + +impl Stream for PendingRegistrationStream { + type Item = StdResult; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut this.inner).poll_next(cx) + } +} + +impl Drop for PendingRegistrationStream { + fn drop(&mut self) { + self.registry + .remove_if_session(&self.instance_id, self.session_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tokio_stream::StreamExt; + + fn bootstrap_identity(instance_id: &str) -> SupervisorBootstrapIdentity { + SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: instance_id.to_string(), + binding: + openshell_core::supervisor_bootstrap::SupervisorBootstrapBinding::WarmPending { + activation_guard: "owner-uid-a".to_string(), + }, + } + } + + fn activation() -> SupervisorActivationMessage { + SupervisorActivationMessage { + sandbox_id: "sandbox-a".to_string(), + sandbox_name: "sandbox-a".to_string(), + token: "token-a".to_string(), + token_expires_at_ms: 123, + startup_metadata: HashMap::default(), + } + } + + #[test] + fn dropping_stream_removes_pending_registration() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register pending"); + assert_eq!(registry.pending_count(), 1); + drop(stream); + assert_eq!(registry.pending_count(), 0); + } + + #[test] + fn duplicate_registration_replaces_prior_stream() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let old = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register old"); + let new = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register new"); + + assert_eq!(registry.pending_count(), 1); + drop(old); + assert_eq!( + registry.pending_count(), + 1, + "old stream drop must not remove replacement" + ); + drop(new); + assert_eq!(registry.pending_count(), 0); + } + + #[tokio::test] + async fn activation_sends_message_and_removes_pending_registration() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let mut stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register pending"); + + let pending = registry.pending_identity("pod-uid-a").unwrap(); + registry + .activate_if_session("pod-uid-a", pending.session_id, activation()) + .expect("activate"); + assert_eq!(registry.pending_count(), 0); + assert_eq!(registry.activated_count(), 1); + + let received = stream + .next() + .await + .expect("activation message") + .expect("activation OK"); + assert_eq!(received.sandbox_id, "sandbox-a"); + assert!(stream.next().await.is_none()); + } + + #[test] + fn activation_for_unknown_pod_uid_returns_not_found() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let err = registry + .activate_if_session("pod-uid-a", 0, activation()) + .expect_err("unknown pod UID must fail"); + + assert_eq!(err.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn activation_tombstone_rejects_duplicate_activation_until_reregistration() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let mut stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register pending"); + + let pending = registry.pending_identity("pod-uid-a").unwrap(); + registry + .activate_if_session("pod-uid-a", pending.session_id, activation()) + .expect("activate"); + let _ = stream.next().await.expect("activation message"); + + let err = registry + .activate_if_session("pod-uid-a", pending.session_id, activation()) + .expect_err("duplicate activation must fail"); + assert_eq!(err.code(), tonic::Code::AlreadyExists); + + let replacement = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("new registration supersedes tombstone"); + assert_eq!(registry.activated_count(), 0); + drop(replacement); + } + + #[test] + fn expired_activation_tombstones_are_pruned() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + { + let mut inner = registry.inner.lock().expect("registry mutex poisoned"); + inner.activated_instance_id.insert( + "expired-pod-uid".to_string(), + Instant::now() + .checked_sub(ACTIVATED_TOMBSTONE_TTL + Duration::from_secs(1)) + .expect("test tombstone timestamp"), + ); + inner + .activated_instance_id + .insert("fresh-pod-uid".to_string(), Instant::now()); + } + + let stream = registry + .register_pending(bootstrap_identity("expired-pod-uid")) + .expect("expired tombstone must not reject registration"); + assert_eq!(registry.activated_count(), 1); + drop(stream); + } + + #[test] + fn closed_pending_stream_cannot_be_activated() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register pending"); + drop(stream); + + let err = registry + .activate_if_session("pod-uid-a", 0, activation()) + .expect_err("closed stream should remove pending registration"); + + assert_eq!(err.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn stale_session_cannot_activate_replacement_stream() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let old_stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register old"); + let old = registry.pending_identity("pod-uid-a").unwrap(); + let mut replacement_stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register replacement"); + let replacement = registry.pending_identity("pod-uid-a").unwrap(); + + let err = registry + .activate_if_session("pod-uid-a", old.session_id, activation()) + .expect_err("stale session must not activate replacement"); + assert_eq!(err.code(), tonic::Code::Aborted); + assert_eq!(registry.pending_count(), 1); + + registry + .activate_if_session("pod-uid-a", replacement.session_id, activation()) + .expect("activate replacement"); + assert!(replacement_stream.next().await.unwrap().is_ok()); + drop(old_stream); + } + + #[tokio::test] + async fn stale_session_cannot_fail_replacement_stream() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let old_stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register old"); + let old = registry.pending_identity("pod-uid-a").unwrap(); + let mut replacement_stream = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register replacement"); + let replacement = registry.pending_identity("pod-uid-a").unwrap(); + + let err = registry + .fail_if_session( + "pod-uid-a", + old.session_id, + Status::permission_denied("stale failure"), + ) + .expect_err("stale session must not fail replacement"); + assert_eq!(err.code(), tonic::Code::Aborted); + + registry + .activate_if_session("pod-uid-a", replacement.session_id, activation()) + .expect("activate replacement"); + assert!(replacement_stream.next().await.unwrap().is_ok()); + drop(old_stream); + } + + #[tokio::test] + async fn registrations_increment_coalescing_generation() { + let registry = Arc::new(SupervisorPodRegistrationRegistry::new()); + let mut generation = registry.subscribe(); + + let first = registry + .register_pending(bootstrap_identity("pod-uid-a")) + .expect("register first"); + let second = registry + .register_pending(bootstrap_identity("pod-uid-b")) + .expect("register second"); + + generation.changed().await.expect("generation change"); + assert_eq!(*generation.borrow_and_update(), 2); + drop((first, second)); + } +} diff --git a/crates/openshell-server/src/template_reconciliation.rs b/crates/openshell-server/src/template_reconciliation.rs new file mode 100644 index 0000000000..6eac1be4f1 --- /dev/null +++ b/crates/openshell-server/src/template_reconciliation.rs @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Desired-state reconciliation of sandbox templates with the compute driver. + +use crate::ServerState; +use crate::persistence::{ObjectCursor, ObjectType, Store}; +use openshell_core::SetResourceVersion; +use openshell_core::proto::SandboxWorkloadTemplate; +use prost::Message; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Notify, watch}; +use tracing::{debug, info, warn}; + +const PAGE_SIZE: u32 = 100; +const SWEEP_INTERVAL: Duration = Duration::from_mins(1); + +async fn list_desired_templates(store: &Store) -> Result, String> { + let mut templates = Vec::new(); + let mut cursor = None; + loop { + let records = store + .list_by_type_after( + SandboxWorkloadTemplate::object_type(), + cursor.as_ref(), + PAGE_SIZE, + ) + .await + .map_err(|error| format!("list sandbox templates failed: {error}"))?; + if records.is_empty() { + break; + } + for record in &records { + let mut template = + SandboxWorkloadTemplate::decode(record.payload.as_slice()).map_err(|error| { + format!("decode sandbox template {} failed: {error}", record.id) + })?; + template.set_resource_version(record.resource_version); + templates.push(template); + } + cursor = records.last().map(ObjectCursor::from); + } + Ok(templates) +} + +async fn reconcile_templates(state: &ServerState) -> Result { + if !state.compute.supports_sandbox_template_reconciliation() { + debug!( + driver = %state.compute.configured_driver_name(), + "Compute driver does not support sandbox template reconciliation" + ); + return Ok(0); + } + + // Build the complete snapshot before crossing the driver boundary. A store + // read or decode failure must not turn a partial snapshot into destructive + // backend pruning. + let templates = list_desired_templates(state.store.as_ref()).await?; + let requested = templates.len(); + let result = state + .compute + .reconcile_sandbox_templates(&templates) + .await + .map_err(|status| status.to_string())?; + info!( + driver = %state.compute.configured_driver_name(), + requested, + reconciled = result.reconciled, + pruned = result.pruned, + "Reconciled sandbox template desired state" + ); + Ok(result.reconciled as usize) +} + +pub fn spawn_worker(state: Arc, mut shutdown_rx: watch::Receiver) { + tokio::spawn(async move { + loop { + if *shutdown_rx.borrow() { + return; + } + if let Err(error) = reconcile_templates(&state).await { + warn!(error = %error, "Sandbox template reconciliation sweep failed"); + } + tokio::select! { + () = state.template_reconciliation_notify.notified() => {} + () = tokio::time::sleep(SWEEP_INTERVAL) => {} + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + return; + } + } + } + } + }); +} + +pub fn notify_after_create(state: &ServerState) { + state.template_reconciliation_notify.notify_one(); +} + +pub fn notify_after_delete(state: &ServerState) { + state.template_reconciliation_notify.notify_one(); +} + +pub fn new_notify() -> Arc { + Arc::new(Notify::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::persistence::WriteCondition; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; + + fn template(id: &str) -> SandboxWorkloadTemplate { + SandboxWorkloadTemplate { + metadata: Some(ObjectMeta { + id: id.to_string(), + name: format!("template-{id}"), + workspace: "default".to_string(), + ..ObjectMeta::default() + }), + spec: Some(openshell_core::proto::SandboxWorkloadTemplateSpec { + workload: Some(openshell_core::proto::SandboxWorkloadConfig { + image: "registry.example.com/agent:latest".to_string(), + ..openshell_core::proto::SandboxWorkloadConfig::default() + }), + ..openshell_core::proto::SandboxWorkloadTemplateSpec::default() + }), + } + } + + #[tokio::test] + async fn desired_snapshot_pages_through_all_templates() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + for index in 0..105 { + let template = template(&index.to_string()); + store + .put_if( + SandboxWorkloadTemplate::object_type(), + template.object_id(), + template.object_name(), + template.object_workspace(), + &template.encode_to_vec(), + None, + WriteCondition::MustCreate, + ) + .await + .unwrap(); + } + + let templates = list_desired_templates(&store).await.unwrap(); + assert_eq!(templates.len(), 105); + assert!(templates.iter().all(|template| { + template + .metadata + .as_ref() + .is_some_and(|metadata| metadata.resource_version == 1) + })); + } + + #[tokio::test] + async fn invalid_template_aborts_snapshot() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + store + .put_if( + SandboxWorkloadTemplate::object_type(), + "broken", + "broken", + "default", + b"not protobuf", + None, + WriteCondition::MustCreate, + ) + .await + .unwrap(); + + assert!(list_desired_templates(&store).await.is_err()); + } +} diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 3bd5430ef1..f8116aa5e7 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -4,18 +4,23 @@ //! Test fixtures for exercising gateway integration points. use futures::{Stream, stream}; -#[cfg(unix)] -use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverSandbox, EnsureWorkspaceRequest, - EnsureWorkspaceResponse, GatewayListenerRequirement, GetCapabilitiesRequest, - GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest, - GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse, - ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest, StartSandboxResponse, - StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverSandbox, DriverSandboxTemplateRef, + DriverSandboxTemplateResource, EnsureWorkspaceRequest, EnsureWorkspaceResponse, + GatewayListenerRequirement, GetCapabilitiesRequest, GetCapabilitiesResponse, + GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, + GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, + ReconcileSandboxTemplatesRequest, ReconcileSandboxTemplatesResponse, StartSandboxRequest, + StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, WatchSandboxesRequest, compute_driver_server::ComputeDriver, gateway_listener_requirement::Selector, + sandbox_template_reconciler_server::SandboxTemplateReconciler, +}; +#[cfg(unix)] +use openshell_core::proto::compute::v1::{ + compute_driver_server::ComputeDriverServer, + sandbox_template_reconciler_server::SandboxTemplateReconcilerServer, }; use std::collections::HashMap; #[cfg(unix)] @@ -41,6 +46,9 @@ pub enum FakeComputeDriverCall { ValidateSandboxCreate { sandbox: Option, }, + ReconcileSandboxTemplates { + templates: Vec, + }, GetSandbox { sandbox_id: String, sandbox_name: String, @@ -48,6 +56,7 @@ pub enum FakeComputeDriverCall { ListSandboxes, CreateSandbox { sandbox: Option, + sandbox_template: Option, }, StopSandbox { sandbox_id: String, @@ -100,6 +109,8 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_warm_supervisor_bootstrap: false, + supports_sandbox_template_reconciliation: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, @@ -134,6 +145,14 @@ impl FakeComputeDriver { self } + #[must_use] + pub fn with_sandbox_template_reconciliation(self) -> Self { + self.with_state(|state| { + state.capabilities.supports_sandbox_template_reconciliation = true; + }); + self + } + #[must_use] pub fn with_gateway_listener_requirement( self, @@ -179,9 +198,11 @@ impl FakeComputeDriver { let socket_path = socket_path.as_ref().to_path_buf(); let listener = UnixListener::bind(&socket_path)?; let driver = self.clone(); + let reconciler = self.clone(); let task = tokio::spawn(async move { tonic::transport::Server::builder() .add_service(ComputeDriverServer::new(driver)) + .add_service(SandboxTemplateReconcilerServer::new(reconciler)) .serve_with_incoming(UnixIncoming { listener }) .await }); @@ -345,13 +366,15 @@ impl ComputeDriver for FakeComputeDriver { self.record_traceparent(request.metadata()); let request = request.into_inner(); let sandbox = request.sandbox; + let sandbox_template = request.sandbox_template; self.with_state(|state| { if let Some(sandbox) = sandbox.as_ref() { state.sandboxes.insert(sandbox.id.clone(), sandbox.clone()); } - state - .calls - .push(FakeComputeDriverCall::CreateSandbox { sandbox }); + state.calls.push(FakeComputeDriverCall::CreateSandbox { + sandbox, + sandbox_template, + }); }); Ok(Response::new(CreateSandboxResponse {})) } @@ -437,3 +460,26 @@ impl ComputeDriver for FakeComputeDriver { Ok(Response::new(DeleteWorkspaceResponse {})) } } + +#[tonic::async_trait] +impl SandboxTemplateReconciler for FakeComputeDriver { + async fn reconcile_sandbox_templates( + &self, + request: Request, + ) -> Result, Status> { + self.record_traceparent(request.metadata()); + let request = request.into_inner(); + let reconciled = u32::try_from(request.templates.len()).unwrap_or(u32::MAX); + self.with_state(|state| { + state + .calls + .push(FakeComputeDriverCall::ReconcileSandboxTemplates { + templates: request.templates, + }); + }); + Ok(Response::new(ReconcileSandboxTemplatesResponse { + reconciled, + pruned: 0, + })) + } +} diff --git a/crates/openshell-server/src/warm_pod_activation.rs b/crates/openshell-server/src/warm_pod_activation.rs new file mode 100644 index 0000000000..00a344611f --- /dev/null +++ b/crates/openshell-server/src/warm_pod_activation.rs @@ -0,0 +1,612 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway-side warm supervisor pod activation. +//! +//! The Kubernetes claim controller will call this module after it observes and +//! revalidates a claim that binds a warm supervisor instance to an `OpenShell` +//! sandbox. + +use crate::ServerState; +use async_trait::async_trait; +use openshell_core::proto::{Sandbox, SupervisorActivationMessage}; +use openshell_core::sandbox_env::{MAIN_PROCESS_SPEC, MAIN_PROCESS_SPEC_ANNOTATION}; +use openshell_core::supervisor_bootstrap::{ + SupervisorBootstrapActivationRequest, SupervisorBootstrapActivator, SupervisorBootstrapBinding, + SupervisorBootstrapIdentity, +}; +use std::sync::Arc; +use tonic::Status; +use tracing::{info, warn}; + +const ACTIVATION_SESSION_RETRY_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct WarmPodActivationTarget { + pub driver: String, + pub instance_id: String, + pub sandbox_id: String, + pub activation_guard: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct ValidatedWarmPodActivation { + pub driver: String, + pub instance_id: String, + pub activation_guard: String, + pub sandbox_id: String, +} + +#[async_trait] +#[allow(dead_code)] +pub trait WarmPodActivationValidator: Send + Sync { + async fn validate( + &self, + target: &WarmPodActivationTarget, + _pending: &SupervisorBootstrapIdentity, + ) -> Result; +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct GatewaySupervisorBootstrapActivator { + state: Arc, +} + +impl GatewaySupervisorBootstrapActivator { + #[must_use] + #[allow(dead_code)] + pub fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait] +impl SupervisorBootstrapActivator for GatewaySupervisorBootstrapActivator { + async fn activate_registered_supervisor( + &self, + request: SupervisorBootstrapActivationRequest, + ) -> Result<(), Status> { + let target = WarmPodActivationTarget { + driver: request.driver, + instance_id: request.instance_id, + sandbox_id: request.sandbox_id, + activation_guard: request.activation_guard, + }; + activate_warm_pod(&self.state, &TrustDriverActivationRequest, target).await + } +} + +struct TrustDriverActivationRequest; + +#[async_trait] +impl WarmPodActivationValidator for TrustDriverActivationRequest { + async fn validate( + &self, + target: &WarmPodActivationTarget, + _pending: &SupervisorBootstrapIdentity, + ) -> Result { + Ok(ValidatedWarmPodActivation { + driver: target.driver.clone(), + instance_id: target.instance_id.clone(), + activation_guard: target.activation_guard.clone(), + sandbox_id: target.sandbox_id.clone(), + }) + } +} + +#[allow(clippy::result_large_err)] +#[allow(dead_code)] +pub async fn activate_warm_pod( + state: &Arc, + validator: &V, + target: WarmPodActivationTarget, +) -> Result<(), Status> +where + V: WarmPodActivationValidator + ?Sized, +{ + for _ in 0..ACTIVATION_SESSION_RETRY_ATTEMPTS { + let pending = state + .supervisor_pod_registrations + .pending_identity(&target.instance_id)?; + let activation_result = async { + let validated = validator.validate(&target, &pending.identity).await?; + ensure_validated_activation_matches_pending(&target, &pending.identity, &validated)?; + mint_pod_activation(state, &validated.sandbox_id, "WarmPodActivation").await + } + .await; + let activation = match activation_result { + Ok(activation) => activation, + Err(status) => { + let stream_status = clone_status(&status); + match state.supervisor_pod_registrations.fail_if_session( + &target.instance_id, + pending.session_id, + stream_status, + ) { + Ok(()) => return Err(status), + Err(replaced) if replaced.code() == tonic::Code::Aborted => continue, + Err(delivery) => return Err(delivery), + } + } + }; + + match state.supervisor_pod_registrations.activate_if_session( + &target.instance_id, + pending.session_id, + activation, + ) { + Ok(()) => return Ok(()), + Err(replaced) if replaced.code() == tonic::Code::Aborted => {} + Err(status) => return Err(status), + } + } + + warn!( + driver = %target.driver, + instance_id = %target.instance_id, + sandbox_id = %target.sandbox_id, + attempts = ACTIVATION_SESSION_RETRY_ATTEMPTS, + "supervisor registration changed repeatedly during warm pod activation" + ); + Err(Status::aborted( + "supervisor registration changed repeatedly during activation", + )) +} + +fn clone_status(status: &Status) -> Status { + Status::new(status.code(), status.message().to_string()) +} + +#[allow(clippy::result_large_err)] +fn ensure_validated_activation_matches_pending( + target: &WarmPodActivationTarget, + pending: &SupervisorBootstrapIdentity, + validated: &ValidatedWarmPodActivation, +) -> Result<(), Status> { + if validated.driver != target.driver || validated.driver != pending.driver { + return Err(Status::permission_denied( + "validated driver does not match pending registration", + )); + } + if validated.instance_id != target.instance_id || validated.instance_id != pending.instance_id { + return Err(Status::permission_denied( + "validated instance ID does not match pending registration", + )); + } + let SupervisorBootstrapBinding::WarmPending { activation_guard } = &pending.binding else { + return Err(Status::permission_denied( + "pending registration is already bound to a sandbox", + )); + }; + if validated.activation_guard != target.activation_guard + || validated.activation_guard != *activation_guard + { + return Err(Status::permission_denied( + "validated activation guard does not match pending registration", + )); + } + if validated.sandbox_id != target.sandbox_id { + return Err(Status::permission_denied( + "validated sandbox ID does not match activation target", + )); + } + Ok(()) +} + +#[allow(clippy::result_large_err)] +pub async fn mint_pod_activation( + state: &Arc, + sandbox_id: &str, + reason: &'static str, +) -> Result { + let issuer = state.sandbox_jwt_issuer.as_ref().ok_or_else(|| { + warn!( + sandbox_id = %sandbox_id, + reason, + "supervisor activation requested but sandbox JWT issuer is not configured" + ); + Status::unavailable("sandbox JWT minting is not configured on this gateway") + })?; + + let record = load_sandbox(state, sandbox_id).await?; + let minted = issuer.mint(sandbox_id)?; + let sandbox_name = record + .metadata + .as_ref() + .map_or_else(String::new, |m| m.name.clone()); + let mut startup_metadata = std::collections::HashMap::default(); + let main_process_spec = record + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(MAIN_PROCESS_SPEC_ANNOTATION)) + .cloned() + .or_else(|| { + openshell_core::sandbox_env::MainProcessConfig::encode_public_spec( + record.spec.as_ref(), + false, + ) + .ok() + }); + if let Some(main_process_spec) = main_process_spec { + startup_metadata.insert(MAIN_PROCESS_SPEC.to_string(), main_process_spec); + } + info!( + sandbox_id = %sandbox_id, + reason, + "issued gateway sandbox JWT for supervisor activation" + ); + + Ok(SupervisorActivationMessage { + sandbox_id: sandbox_id.to_string(), + sandbox_name, + token: minted.token, + token_expires_at_ms: minted.expires_at_ms, + startup_metadata, + }) +} + +pub async fn load_sandbox(state: &Arc, sandbox_id: &str) -> Result { + if sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + + state + .store + .get_message::(sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::sandbox_jwt::SandboxJwtIssuer; + use crate::compute::new_test_runtime; + use crate::persistence::Store; + use crate::sandbox_index::SandboxIndex; + use crate::sandbox_watch::SandboxWatchBus; + use crate::supervisor_session::SupervisorSessionRegistry; + use crate::tracing_bus::TracingLogBus; + use openshell_bootstrap::jwt::generate_jwt_key; + use openshell_core::Config; + use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec}; + use std::collections::HashMap; + use std::sync::Mutex; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + use tokio_stream::StreamExt; + + struct StaticValidator { + validated: ValidatedWarmPodActivation, + } + + #[async_trait] + impl WarmPodActivationValidator for StaticValidator { + async fn validate( + &self, + _target: &WarmPodActivationTarget, + _pending: &SupervisorBootstrapIdentity, + ) -> Result { + Ok(self.validated.clone()) + } + } + + struct ReplaceRegistrationOnceValidator { + state: Arc, + replaced: AtomicBool, + replacement_stream: + Mutex>, + validated: ValidatedWarmPodActivation, + } + + #[async_trait] + impl WarmPodActivationValidator for ReplaceRegistrationOnceValidator { + async fn validate( + &self, + _target: &WarmPodActivationTarget, + _pending: &SupervisorBootstrapIdentity, + ) -> Result { + if !self.replaced.swap(true, Ordering::SeqCst) { + let stream = self + .state + .supervisor_pod_registrations + .register_pending(pending_identity())?; + *self.replacement_stream.lock().unwrap() = Some(stream); + } + Ok(self.validated.clone()) + } + } + + async fn state_with_issuer() -> Arc { + let mat = generate_jwt_key().expect("jwt key"); + let store = Arc::new( + Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(), + ); + let compute = new_test_runtime(store.clone()).await; + let mut state = ServerState::new( + Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_credential_drivers(["test-static"]), + store, + compute, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + ); + let issuer = SandboxJwtIssuer::from_pem( + mat.signing_key_pem.as_bytes(), + mat.kid, + "test-gateway", + Duration::from_hours(1), + ) + .unwrap(); + state.sandbox_jwt_issuer = Some(Arc::new(issuer)); + let state = Arc::new(state); + insert_sandbox(&state, "sandbox-a").await; + state + } + + async fn insert_sandbox(state: &Arc, sandbox_id: &str) { + let mut sandbox = Sandbox { + metadata: Some(ObjectMeta { + id: sandbox_id.to_string(), + name: sandbox_id.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::default(), + annotations: HashMap::default(), + resource_version: 0, + workspace: "default".to_string(), + ..Default::default() + }), + spec: Some(SandboxSpec { + policy: None, + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sandbox).await.unwrap(); + } + + fn pending_identity() -> SupervisorBootstrapIdentity { + SupervisorBootstrapIdentity { + driver: "kubernetes".to_string(), + instance_id: "pod-uid-a".to_string(), + binding: SupervisorBootstrapBinding::WarmPending { + activation_guard: "owner-uid-a".to_string(), + }, + } + } + + fn activation_target(sandbox_id: &str) -> WarmPodActivationTarget { + WarmPodActivationTarget { + driver: "kubernetes".to_string(), + instance_id: "pod-uid-a".to_string(), + sandbox_id: sandbox_id.to_string(), + activation_guard: "owner-uid-a".to_string(), + } + } + + fn validated_activation(sandbox_id: &str) -> ValidatedWarmPodActivation { + ValidatedWarmPodActivation { + driver: "kubernetes".to_string(), + instance_id: "pod-uid-a".to_string(), + activation_guard: "owner-uid-a".to_string(), + sandbox_id: sandbox_id.to_string(), + } + } + + #[tokio::test] + async fn pending_warm_pod_receives_activation_token() { + let state = state_with_issuer().await; + let mut stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("register pending"); + let validator = StaticValidator { + validated: validated_activation("sandbox-a"), + }; + + activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect("activate"); + + let received = stream + .next() + .await + .expect("activation message") + .expect("activation OK"); + assert_eq!(received.sandbox_id, "sandbox-a"); + assert_eq!(received.sandbox_name, "sandbox-a"); + assert!(!received.token.is_empty()); + let main_process = received + .startup_metadata + .get(MAIN_PROCESS_SPEC) + .expect("main process activation metadata"); + let decoded = openshell_core::sandbox_env::MainProcessConfig::decode(main_process).unwrap(); + assert!(decoded.command.is_empty()); + assert_eq!(state.supervisor_pod_registrations.pending_count(), 0); + assert_eq!(state.supervisor_pod_registrations.activated_count(), 1); + } + + #[tokio::test] + async fn activation_for_unknown_instance_id_fails_before_validation() { + let state = state_with_issuer().await; + let validator = StaticValidator { + validated: validated_activation("sandbox-a"), + }; + + let err = activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect_err("unknown instance ID must fail"); + + assert_eq!(err.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn missing_target_sandbox_fails_without_token_emission() { + let state = state_with_issuer().await; + let mut stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("register pending"); + let validator = StaticValidator { + validated: validated_activation("sandbox-deleted"), + }; + + let err = activate_warm_pod(&state, &validator, activation_target("sandbox-deleted")) + .await + .expect_err("missing sandbox must fail"); + + assert_eq!(err.code(), tonic::Code::NotFound); + assert_eq!(state.supervisor_pod_registrations.pending_count(), 0); + let stream_err = stream + .next() + .await + .expect("failure message") + .expect_err("missing sandbox should fail stream"); + assert_eq!(stream_err.code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn duplicate_activation_for_same_instance_id_is_rejected() { + let state = state_with_issuer().await; + let mut stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("register pending"); + let validator = StaticValidator { + validated: validated_activation("sandbox-a"), + }; + + activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect("first activation"); + let _ = stream.next().await.expect("activation"); + let err = activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect_err("duplicate activation must fail"); + + assert_eq!(err.code(), tonic::Code::AlreadyExists); + } + + #[tokio::test] + async fn same_pod_uid_can_reregister_after_activation() { + let state = state_with_issuer().await; + let mut first_stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("register first process"); + let validator = StaticValidator { + validated: validated_activation("sandbox-a"), + }; + + activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect("activate first process"); + let first = first_stream.next().await.unwrap().unwrap(); + + let mut restarted_stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("same pod UID must reregister"); + activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect("activate restarted process"); + let restarted = restarted_stream.next().await.unwrap().unwrap(); + + assert_eq!(first.sandbox_id, restarted.sandbox_id); + assert!(!restarted.token.is_empty()); + } + + #[tokio::test] + async fn replacement_pod_uid_can_activate_existing_sandbox() { + let state = state_with_issuer().await; + let mut replacement_identity = pending_identity(); + replacement_identity.instance_id = "pod-uid-b".to_string(); + let mut stream = state + .supervisor_pod_registrations + .register_pending(replacement_identity) + .expect("register replacement pod"); + let validator = StaticValidator { + validated: ValidatedWarmPodActivation { + instance_id: "pod-uid-b".to_string(), + ..validated_activation("sandbox-a") + }, + }; + let target = WarmPodActivationTarget { + instance_id: "pod-uid-b".to_string(), + ..activation_target("sandbox-a") + }; + + activate_warm_pod(&state, &validator, target) + .await + .expect("activate replacement pod"); + let activation = stream.next().await.unwrap().unwrap(); + + assert_eq!(activation.sandbox_id, "sandbox-a"); + assert!(!activation.token.is_empty()); + } + + #[tokio::test] + async fn activation_retries_when_registration_changes_during_validation() { + let state = state_with_issuer().await; + let old_stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("register old process"); + let validator = ReplaceRegistrationOnceValidator { + state: state.clone(), + replaced: AtomicBool::new(false), + replacement_stream: Mutex::new(None), + validated: validated_activation("sandbox-a"), + }; + + activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect("replacement session should activate"); + let mut replacement_stream = validator + .replacement_stream + .lock() + .unwrap() + .take() + .expect("replacement stream"); + let activation = replacement_stream.next().await.unwrap().unwrap(); + + assert_eq!(activation.sandbox_id, "sandbox-a"); + drop(old_stream); + } + + #[tokio::test] + async fn revalidation_metadata_must_match_pending_registration() { + let state = state_with_issuer().await; + let mut stream = state + .supervisor_pod_registrations + .register_pending(pending_identity()) + .expect("register pending"); + let mut validated = validated_activation("sandbox-a"); + validated.activation_guard = "other-owner".to_string(); + let validator = StaticValidator { validated }; + + let err = activate_warm_pod(&state, &validator, activation_target("sandbox-a")) + .await + .expect_err("mismatched revalidation metadata must fail"); + + assert_eq!(err.code(), tonic::Code::PermissionDenied); + let stream_err = stream + .next() + .await + .expect("failure message") + .expect_err("validation mismatch should fail stream"); + assert_eq!(stream_err.code(), tonic::Code::PermissionDenied); + } +} diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 695d8d8f2a..ecc21ea5ad 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -23,9 +23,10 @@ use openshell_core::proto::{ GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, - RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RelayFrame, RevokeSshSessionRequest, - RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, - SupervisorMessage, TcpForwardFrame, UpdateProviderRequest, WatchSandboxRequest, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RegisterSupervisorRequest, RelayFrame, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, SupervisorActivationMessage, SupervisorMessage, TcpForwardFrame, + UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -526,6 +527,15 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type RegisterSupervisorStream = ReceiverStream>; + + async fn register_supervisor( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn refresh_sandbox_token( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 448ae2cc7b..d2b5c972da 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -23,7 +23,8 @@ use hyper_util::{ server::conn::auto::Builder, }; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, SupervisorMessage, TcpForwardFrame, + GatewayMessage, RelayFrame, RelayInit, SupervisorActivationMessage, SupervisorMessage, + TcpForwardFrame, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -487,6 +488,13 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + type RegisterSupervisorStream = ReceiverStream>; + async fn register_supervisor( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn refresh_sandbox_token( &self, _: tonic::Request, diff --git a/crates/openshell-supervisor-process/src/log_push.rs b/crates/openshell-supervisor-process/src/log_push.rs index 256fbff4a4..8e4c31a6d8 100644 --- a/crates/openshell-supervisor-process/src/log_push.rs +++ b/crates/openshell-supervisor-process/src/log_push.rs @@ -9,7 +9,7 @@ use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::{PushSandboxLogsRequest, SandboxLogLine}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use tracing::{Event, Subscriber}; use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; @@ -20,13 +20,21 @@ use tracing_subscriber::layer::Context; /// event is dropped. Logging must never block the sandbox. #[derive(Clone)] pub struct LogPushLayer { - sandbox_id: String, + sandbox_id: watch::Receiver>, tx: mpsc::Sender, max_level: tracing::Level, } impl LogPushLayer { pub fn new(sandbox_id: String, tx: mpsc::Sender) -> Self { + let (_identity_tx, identity_rx) = watch::channel(Some(sandbox_id)); + Self::with_identity(identity_rx, tx) + } + + fn with_identity( + sandbox_id: watch::Receiver>, + tx: mpsc::Sender, + ) -> Self { let max_level = parse_max_level(std::env::var("OPENSHELL_LOG_PUSH_LEVEL").ok().as_deref()); Self { sandbox_id, @@ -74,7 +82,7 @@ impl Layer for LogPushLayer { let is_ocsf = meta.target() == openshell_ocsf::OCSF_TARGET; let log = SandboxLogLine { - sandbox_id: self.sandbox_id.clone(), + sandbox_id: self.sandbox_id.borrow().clone().unwrap_or_default(), timestamp_ms: ts, level: if is_ocsf { "OCSF".to_string() @@ -92,6 +100,35 @@ impl Layer for LogPushLayer { } } +/// Completes a pending log stream once warm-pod registration assigns the +/// authoritative sandbox identity. +#[derive(Clone)] +pub struct LogPushActivation { + sandbox_id: watch::Sender>, +} + +impl LogPushActivation { + pub fn activate(&self, sandbox_id: String) { + self.sandbox_id.send_replace(Some(sandbox_id)); + } +} + +/// Build a log layer and push task that may start before a warm sandbox has an +/// identity. The task does not connect to the gateway until activation. +pub fn spawn_log_push( + endpoint: String, + sandbox_id: Option, +) -> (LogPushLayer, LogPushActivation, tokio::task::JoinHandle<()>) { + let (identity_tx, identity_rx) = watch::channel(sandbox_id); + let (tx, rx) = mpsc::channel::(1024); + let layer = LogPushLayer::with_identity(identity_rx.clone(), tx); + let activation = LogPushActivation { + sandbox_id: identity_tx, + }; + let handle = tokio::spawn(run_push_loop(endpoint, identity_rx, rx)); + (layer, activation, handle) +} + /// Spawn a background task that batches and pushes log lines to the server. /// /// Returns the sender half of the channel (for the [`LogPushLayer`]) and the @@ -102,8 +139,8 @@ pub fn spawn_log_push_task( sandbox_id: String, ) -> (mpsc::Sender, tokio::task::JoinHandle<()>) { let (tx, rx) = mpsc::channel::(1024); - - let handle = tokio::spawn(run_push_loop(endpoint, sandbox_id, rx)); + let (_identity_tx, identity_rx) = watch::channel(Some(sandbox_id)); + let handle = tokio::spawn(run_push_loop(endpoint, identity_rx, rx)); (tx, handle) } @@ -115,9 +152,18 @@ const INITIAL_BACKOFF: tokio::time::Duration = tokio::time::Duration::from_secs( async fn run_push_loop( endpoint: String, - sandbox_id: String, + mut sandbox_id_rx: watch::Receiver>, mut rx: mpsc::Receiver, ) { + let sandbox_id = loop { + let current_sandbox_id = sandbox_id_rx.borrow().clone(); + if let Some(sandbox_id) = current_sandbox_id { + break sandbox_id; + } + if sandbox_id_rx.changed().await.is_err() { + return; + } + }; let mut batch = Vec::with_capacity(50); let mut backoff = INITIAL_BACKOFF; let mut attempt: u64 = 0; @@ -169,7 +215,7 @@ async fn run_push_loop( // --- Flush any lines buffered during reconnect --- if !batch.is_empty() { - let lines = std::mem::take(&mut batch); + let lines = take_batch_for_sandbox(&mut batch, &sandbox_id); if push_tx .send(PushSandboxLogsRequest { sandbox_id: sandbox_id.clone(), @@ -197,7 +243,7 @@ async fn run_push_loop( // Tracing layer dropped — sandbox is shutting down. // Flush remaining and exit entirely. if !batch.is_empty() { - let lines = std::mem::take(&mut batch); + let lines = take_batch_for_sandbox(&mut batch, &sandbox_id); let _ = push_tx.send(PushSandboxLogsRequest { sandbox_id: sandbox_id.clone(), logs: lines, @@ -207,7 +253,7 @@ async fn run_push_loop( }; batch.push(line); if batch.len() >= 50 { - let lines = std::mem::take(&mut batch); + let lines = take_batch_for_sandbox(&mut batch, &sandbox_id); if push_tx.send(PushSandboxLogsRequest { sandbox_id: sandbox_id.clone(), logs: lines, @@ -218,7 +264,7 @@ async fn run_push_loop( } _ = timer.tick() => { if !batch.is_empty() { - let lines = std::mem::take(&mut batch); + let lines = take_batch_for_sandbox(&mut batch, &sandbox_id); if push_tx.send(PushSandboxLogsRequest { sandbox_id: sandbox_id.clone(), logs: lines, @@ -248,6 +294,17 @@ async fn run_push_loop( } } +fn take_batch_for_sandbox( + batch: &mut Vec, + sandbox_id: &str, +) -> Vec { + let mut lines = std::mem::take(batch); + for line in &mut lines { + line.sandbox_id = sandbox_id.to_string(); + } + lines +} + /// Drain incoming log lines during a backoff delay so the tracing layer's /// `try_send` doesn't fill up. Lines received during backoff are kept in `batch` /// (up to a limit) so they can be sent after reconnecting. @@ -338,7 +395,7 @@ mod tests { fn capture(capacity: usize, f: impl FnOnce()) -> Vec { let (tx, mut rx) = mpsc::channel::(capacity); let layer = LogPushLayer { - sandbox_id: "sb-test".to_string(), + sandbox_id: watch::channel(Some("sb-test".to_string())).1, tx, max_level: tracing::Level::INFO, }; @@ -352,6 +409,39 @@ mod tests { out } + #[test] + fn pending_layer_switches_to_activated_identity() { + let (tx, mut rx) = mpsc::channel::(4); + let (identity_tx, identity_rx) = watch::channel(None); + let activation = LogPushActivation { + sandbox_id: identity_tx, + }; + let layer = LogPushLayer::with_identity(identity_rx, tx); + let subscriber = tracing_subscriber::registry().with(layer); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!(target: "test_target", "before activation"); + activation.activate("sb-warm".to_string()); + tracing::info!(target: "test_target", "after activation"); + }); + + assert_eq!(rx.try_recv().unwrap().sandbox_id, ""); + assert_eq!(rx.try_recv().unwrap().sandbox_id, "sb-warm"); + } + + #[test] + fn queued_lines_are_rebound_to_authoritative_identity() { + let mut batch = vec![SandboxLogLine { + sandbox_id: String::new(), + ..SandboxLogLine::default() + }]; + + let lines = take_batch_for_sandbox(&mut batch, "sb-warm"); + + assert!(batch.is_empty()); + assert_eq!(lines[0].sandbox_id, "sb-warm"); + } + #[test] fn ocsf_events_push_shorthand_with_ocsf_level_and_no_fields() { let event = NetworkActivityBuilder::new(&ocsf_ctx()) diff --git a/deploy/helm/openshell-workspace/templates/role.yaml b/deploy/helm/openshell-workspace/templates/role.yaml index 45b3beb831..d7b79cf687 100644 --- a/deploy/helm/openshell-workspace/templates/role.yaml +++ b/deploy/helm/openshell-workspace/templates/role.yaml @@ -37,3 +37,4 @@ rules: - pods verbs: - get + - list diff --git a/deploy/helm/openshell-workspace/tests/workspace_test.yaml b/deploy/helm/openshell-workspace/tests/workspace_test.yaml index 2f71920eaf..49e0bbda01 100644 --- a/deploy/helm/openshell-workspace/tests/workspace_test.yaml +++ b/deploy/helm/openshell-workspace/tests/workspace_test.yaml @@ -20,6 +20,12 @@ tests: - equal: path: metadata.namespace value: app-a + - contains: + path: rules + content: + apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] - it: binds the shared gateway service account template: templates/rolebinding.yaml diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index fb3161a604..3c2f4e95c9 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -32,6 +32,12 @@ supported Sandbox API (`agents.x-k8s.io/v1beta1` or `agentSandbox.preflight.enabled=false` for offline `helm template` rendering, where Helm cannot discover cluster APIs. +The chart defaults Kubernetes warm pooling on. Install the Agent Sandbox extension APIs too, or set `server.warmPooling.enabled=false`: + +```shell +kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/extensions.yaml +``` + ## Install on Kubernetes ```shell @@ -279,7 +285,7 @@ discovery endpoint or its TLS CA. | server.sandboxImagePullPolicy | string | `""` | Kubernetes imagePullPolicy for sandbox pods. Empty = Kubernetes default (Always for :latest, IfNotPresent otherwise). Set to "Always" for dev clusters so new images are picked up without manual eviction. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | | server.sandboxJwt.gatewayId | string | `""` | Stable gateway identity embedded in iss/aud of every minted token. Defaults to the release name so HA replicas share identity. | -| server.sandboxJwt.k8sSaTokenTtlSecs | int | `3600` | Lifetime (seconds) of the projected ServiceAccount token kubelet writes into each sandbox pod for the IssueSandboxToken bootstrap exchange. Kubelet enforces a minimum of 600s; the driver clamps values outside [600, 86400]. Default 3600 — generous, since the supervisor consumes the token within seconds of pod start. | +| server.sandboxJwt.k8sSaTokenTtlSecs | int | `3600` | Lifetime (seconds) of the projected ServiceAccount token kubelet writes into each sandbox pod for RegisterSupervisor bootstrap. Kubelet enforces a minimum of 600s; the driver clamps values outside [600, 86400]. Default 3600 — generous, since the supervisor consumes the token within seconds of pod start. | | server.sandboxJwt.secretDefaultMode | string | `""` | File mode for the mounted JWT signing key Secret. Default 0400 (owner-read only). Override to 0440 or 0444 if the container UID does not match the volume file owner. | | server.sandboxJwt.signingSecretName | string | `""` | Name of the Opaque Secret holding the signing key material. Empty falls back to the chart fullname with "-jwt-keys" appended. | | server.sandboxJwt.ttlSecs | int | `3600` | Token TTL in seconds. Defaults to 3600 (1h). | @@ -288,6 +294,10 @@ discovery endpoint or its TLS CA. | server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. | | server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). Do not set to null; omit the key to use the default secret name above. | | server.tls.clientTlsSecretName | string | `"openshell-client-tls"` | K8s secret mounted into sandbox pods for mTLS to the server. | +| server.warmPooling | object | `{"enabled":true,"templates":{"maxReplicas":20,"readyWithinThresholdSecs":5}}` | Enable transparent Kubernetes warm-pool allocation through Agent Sandbox v1beta1 SandboxClaim resources backed by compatible OpenShell-generated SandboxWarmPool resources in the target namespace. | +| server.warmPooling.templates | object | `{"maxReplicas":20,"readyWithinThresholdSecs":5}` | Reconcile OpenShell SandboxTemplate lifecycle notifications into generated SandboxTemplate and SandboxWarmPool resources when the template asks for startup faster than readyWithinThresholdSecs. | +| server.warmPooling.templates.maxReplicas | int | `20` | Maximum generated warm-pool replicas per template. | +| server.warmPooling.templates.readyWithinThresholdSecs | int | `5` | Strict startup threshold in seconds. Templates with desired_service_level.startup.ready_within below this value get a warm pool. | | server.workspaceDefaultStorageSize | string | `""` | Default storage size for the workspace PVC in sandbox pods. Uses Kubernetes quantity syntax (e.g. "2Gi", "10Gi", "500Mi"). Empty = built-in default (2Gi). | | server.workspaceStorageClass | string | `""` | Kubernetes StorageClass for the workspace PVC in sandbox pods. Empty (default) = omit storageClassName, using the cluster's default StorageClass. Set this on clusters with no default StorageClass, otherwise the workspace PVC stays Pending and the sandbox never starts. | | service.healthPort | int | `8081` | Gateway health service port. | diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index cf8677741e..a60606e65f 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -32,6 +32,12 @@ supported Sandbox API (`agents.x-k8s.io/v1beta1` or `agentSandbox.preflight.enabled=false` for offline `helm template` rendering, where Helm cannot discover cluster APIs. +The chart defaults Kubernetes warm pooling on. Install the Agent Sandbox extension APIs too, or set `server.warmPooling.enabled=false`: + +```shell +kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/extensions.yaml +``` + ## Install on Kubernetes ```shell diff --git a/deploy/helm/openshell/templates/clusterrole.yaml b/deploy/helm/openshell/templates/clusterrole.yaml index eb1ed8e1d0..5c57ebba1f 100644 --- a/deploy/helm/openshell/templates/clusterrole.yaml +++ b/deploy/helm/openshell/templates/clusterrole.yaml @@ -9,8 +9,8 @@ metadata: labels: {{- include "openshell.labels" . | nindent 4 }} rules: - # Validate projected sandbox ServiceAccount tokens during the - # IssueSandboxToken bootstrap exchange. + # Validate projected sandbox ServiceAccount tokens during supervisor + # registration and legacy token bootstrap. - apiGroups: - authentication.k8s.io resources: @@ -72,6 +72,53 @@ rules: - pods verbs: - get + - list + {{- end }} + {{- if ne $workspaceMode "shared" }} + # Claim inventory, activation, and cleanup remain available across managed or + # operator-owned workspace namespaces when new warm allocation is disabled. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxclaims + verbs: + - get + - list + - watch + - delete + {{- if .Values.server.warmPooling.enabled }} + # Creating new claims is required only for warm-pool allocation. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxclaims + verbs: + - create + {{- end }} + # Keep generated-resource cleanup available when warm allocation is disabled + # so the driver can prune resources created by an earlier configuration. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxtemplates + - sandboxwarmpools + verbs: + - list + - delete + {{- if .Values.server.warmPooling.enabled }} + # Reconcile and watch generated resources while warm allocation is enabled. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxtemplates + - sandboxwarmpools + verbs: + - get + - watch + - create + - patch + - update + {{- end }} {{- end }} {{- $copiedSecretNames := list }} {{- if and (ne $workspaceMode "shared") (not .Values.server.disableTls) }} diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 083748aee3..509ba5ce9a 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -215,6 +215,13 @@ data: gateway_namespace = {{ .Release.Namespace | quote }} gateway_pod_selector = { "app.kubernetes.io/name" = {{ include "openshell.name" . | quote }}, "app.kubernetes.io/instance" = {{ .Release.Name | quote }} } + [openshell.drivers.kubernetes.warm_pooling] + enabled = {{ .Values.server.warmPooling.enabled }} + + [openshell.drivers.kubernetes.warm_pooling.templates] + ready_within_threshold_secs = {{ .Values.server.warmPooling.templates.readyWithinThresholdSecs | default 5 }} + max_replicas = {{ .Values.server.warmPooling.templates.maxReplicas | default 20 }} + [openshell.drivers.kubernetes.sidecar] proxy_uid = {{ .Values.supervisor.sidecar.proxyUid | default 1337 }} process_binary_aware_network_policy = {{ .Values.supervisor.sidecar.processBinaryAwareNetworkPolicy }} diff --git a/deploy/helm/openshell/templates/role.yaml b/deploy/helm/openshell/templates/role.yaml index dfd6423615..b36c9e6280 100644 --- a/deploy/helm/openshell/templates/role.yaml +++ b/deploy/helm/openshell/templates/role.yaml @@ -34,13 +34,59 @@ rules: # Per-sandbox identity: TokenReview authenticates the projected token from # the configured sandbox service account, then the gateway resolves the # returned pod name and UID to the pod's `openshell.ai/sandbox-id` - # annotation. patch is intentionally NOT granted — the annotation is set - # once at pod create and must remain immutable for the lifetime of the - # sandbox. + # annotation. list supports the owner-validated status-selector fallback for + # claim-backed Sandboxes without the pod-name annotation. patch is + # intentionally NOT granted — the annotation is set once at pod create and + # must remain immutable for the lifetime of the sandbox. - apiGroups: - "" resources: - pods verbs: - get + - list + # Claim inventory, activation, and cleanup remain available when new warm + # allocation is disabled. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxclaims + verbs: + - get + - list + - watch + - delete + {{- if .Values.server.warmPooling.enabled }} + # Creating new claims is required only for warm-pool allocation. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxclaims + verbs: + - create + {{- end }} + # Keep generated-resource cleanup available when warm allocation is disabled + # so the driver can prune resources created by an earlier configuration. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxtemplates + - sandboxwarmpools + verbs: + - list + - delete + {{- if .Values.server.warmPooling.enabled }} + # Reconcile and watch generated resources while warm allocation is enabled. + - apiGroups: + - extensions.agents.x-k8s.io + resources: + - sandboxtemplates + - sandboxwarmpools + verbs: + - get + - watch + - create + - patch + - update + {{- end }} {{- end }} diff --git a/deploy/helm/openshell/tests/clusterrole_test.yaml b/deploy/helm/openshell/tests/clusterrole_test.yaml index afecada9b6..67c187bbd7 100644 --- a/deploy/helm/openshell/tests/clusterrole_test.yaml +++ b/deploy/helm/openshell/tests/clusterrole_test.yaml @@ -9,6 +9,17 @@ release: namespace: my-namespace tests: + - it: grants pod lookup and selector fallback in multi-namespace mode + set: + server.drivers.kubernetes.workspaceMode: operator + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - it: grants managed namespace NetworkPolicy apply permissions set: server.drivers.kubernetes.workspaceMode: managed diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 1380cb18c5..57c6800df4 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -257,6 +257,28 @@ tests: path: data["gateway.toml"] pattern: '(?ms)\[openshell\.drivers\.kubernetes\.managed_ssh_ingress\].*?enabled\s*=\s*true.*?gateway_namespace\s*=\s*"my-namespace".*?gateway_pod_selector\s*=.*?app\.kubernetes\.io/name.*?openshell' + - it: renders warm pooling config under [openshell.drivers.kubernetes.warm_pooling] + template: templates/gateway-config.yaml + set: + server.warmPooling.enabled: false + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\.warm_pooling\].*?enabled\s*=\s*false' + + - it: renders warm pool template config under [openshell.drivers.kubernetes.warm_pooling.templates] + template: templates/gateway-config.yaml + set: + server.warmPooling.templates.readyWithinThresholdSecs: 3 + server.warmPooling.templates.maxReplicas: 7 + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\.warm_pooling\.templates\].*?ready_within_threshold_secs\s*=\s*3' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\.warm_pooling\.templates\].*?max_replicas\s*=\s*7' + - it: renders sandbox image pull secrets under [openshell.drivers.kubernetes] template: templates/gateway-config.yaml set: diff --git a/deploy/helm/openshell/tests/sandbox_namespace_test.yaml b/deploy/helm/openshell/tests/sandbox_namespace_test.yaml index 864e3a8512..aa5562e5e6 100644 --- a/deploy/helm/openshell/tests/sandbox_namespace_test.yaml +++ b/deploy/helm/openshell/tests/sandbox_namespace_test.yaml @@ -57,6 +57,16 @@ tests: path: metadata.namespace value: other-ns + - it: grants pod lookup and selector fallback in shared mode + template: templates/role.yaml + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - it: uses explicit sandboxNamespace for sandbox RoleBinding template: templates/rolebinding.yaml set: diff --git a/deploy/helm/openshell/tests/warm_pool_rbac_test.yaml b/deploy/helm/openshell/tests/warm_pool_rbac_test.yaml new file mode 100644 index 0000000000..d8a733127d --- /dev/null +++ b/deploy/helm/openshell/tests/warm_pool_rbac_test.yaml @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +suite: Warm-pool RBAC +templates: + - templates/clusterrole.yaml + - templates/role.yaml +release: + name: openshell + namespace: my-namespace + +tests: + - it: keeps shared claim and generated-resource cleanup permissions when warm pooling is disabled + set: + server.warmPooling.enabled: false + asserts: + - notContains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + any: true + template: templates/clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims"] + verbs: ["get", "list", "watch", "delete"] + template: templates/role.yaml + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxtemplates", "sandboxwarmpools"] + verbs: ["list", "delete"] + template: templates/role.yaml + + - it: keeps multi-namespace claim and generated-resource cleanup permissions when warm pooling is disabled + set: + server.drivers.kubernetes.workspaceMode: operator + server.warmPooling.enabled: false + asserts: + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims"] + verbs: ["get", "list", "watch", "delete"] + template: templates/clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxtemplates", "sandboxwarmpools"] + verbs: ["list", "delete"] + template: templates/clusterrole.yaml + + - it: grants shared warm-pool permissions only in the namespaced role + set: + server.drivers.kubernetes.workspaceMode: shared + server.warmPooling.enabled: true + asserts: + - notContains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + any: true + template: templates/clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims"] + verbs: ["get", "list", "watch", "delete"] + template: templates/role.yaml + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims"] + verbs: ["create"] + template: templates/role.yaml + + - it: grants generated-resource mutation in multi-namespace mode + set: + server.drivers.kubernetes.workspaceMode: operator + server.warmPooling.enabled: true + asserts: + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxtemplates", "sandboxwarmpools"] + verbs: ["list", "delete"] + template: templates/clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxtemplates", "sandboxwarmpools"] + verbs: ["get", "watch", "create", "patch", "update"] + template: templates/clusterrole.yaml diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 453654f790..d802e24358 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -246,6 +246,21 @@ server: # Set to a RuntimeClass name (e.g. "kata-containers", "nvidia") to apply it # to all sandboxes that don't explicitly override it. defaultRuntimeClassName: "" + # -- Enable transparent Kubernetes warm-pool allocation through Agent Sandbox + # v1beta1 SandboxClaim resources backed by compatible OpenShell-generated + # SandboxWarmPool resources in the target namespace. + warmPooling: + enabled: true + # -- Reconcile OpenShell SandboxTemplate lifecycle notifications into + # generated SandboxTemplate and SandboxWarmPool resources when the template + # asks for startup faster than readyWithinThresholdSecs. + templates: + # -- Strict startup threshold in seconds. Templates with + # desired_service_level.startup.ready_within below this value get a warm + # pool. + readyWithinThresholdSecs: 5 + # -- Maximum generated warm-pool replicas per template. + maxReplicas: 20 # -- gRPC endpoint sandboxes call back into the gateway. Leave empty to derive # it from the chart fullname, release namespace, service port, and # disableTls flag, for example https://openshell.openshell.svc.cluster.local:8080. @@ -386,8 +401,8 @@ server: # -- Token TTL in seconds. Defaults to 3600 (1h). ttlSecs: 3600 # -- Lifetime (seconds) of the projected ServiceAccount token kubelet - # writes into each sandbox pod for the IssueSandboxToken bootstrap - # exchange. Kubelet enforces a minimum of 600s; the driver clamps + # writes into each sandbox pod for RegisterSupervisor bootstrap. + # Kubelet enforces a minimum of 600s; the driver clamps # values outside [600, 86400]. Default 3600 — generous, since the # supervisor consumes the token within seconds of pod start. k8sSaTokenTtlSecs: 3600 diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 3fd7a66ecb..1393aded17 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -21,9 +21,9 @@ For how the CLI resolves gateways and stores credentials, refer to [Gateway Auth ## Sandbox Supervisor Identity -Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the Kubernetes compute driver validates each projected ServiceAccount token and returns the authenticated sandbox ID to the gateway. The gateway verifies the sandbox still exists and mints its own sandbox JWT. +Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. Each supervisor calls `RegisterSupervisor` with its projected ServiceAccount token. The Kubernetes compute driver validates that pod-bound token, verifies the live pod and controlling Agent Sandbox objects, and returns either a bound sandbox identity or a warm-pending supervisor identity. The gateway verifies a bound or later-activated sandbox still exists before streaming a gateway-minted sandbox JWT back to the supervisor. -Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. +Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping supervisor registration through `RegisterSupervisor` and gateway-minted sandbox JWTs. Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.ai/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index fb7881af2d..f0f2d26388 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -22,7 +22,7 @@ Make sure the following are in place before you install. |---|---|---| | Kubernetes 1.29+ with RBAC enabled | Yes | No additional notes. | | Helm 3.x | Yes | No additional notes. | -| Agent Sandbox controller and CRDs | Yes | Install before the OpenShell chart. Refer to [Install Agent Sandbox](#install-agent-sandbox). | +| Agent Sandbox controller and CRDs | Yes | Install before the OpenShell chart. Refer to [Install Agent Sandbox](#install-agent-sandbox). Install `extensions.yaml` too when warm pooling is enabled. | | cert-manager | No | Refer to [Managing Certificates](/kubernetes/managing-certificates). Use cert-manager only if you prefer it over the built-in PKI job. | | Kubernetes Gateway API | No | Refer to [Ingress](/kubernetes/ingress). Use it only for external access without port-forwarding. | @@ -51,6 +51,14 @@ helm template openshell oci://ghcr.io/nvidia/openshell/helm-chart \ The chart does not install or upgrade the cluster-scoped Agent Sandbox CRDs or controller. +Install the Agent Sandbox extension APIs when Kubernetes warm pooling is enabled. The OpenShell chart defaults warm pooling on, but it does not install the `SandboxClaim`, `SandboxTemplate`, or `SandboxWarmPool` CRDs: + +```shell +kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/extensions.yaml +``` + +If you do not install `extensions.yaml`, set `server.warmPooling.enabled=false`. Otherwise OpenShell disables warm pooling and template reconciliation at startup and falls back to direct `Sandbox` creation. + **Air-gapped clusters:** mirror the manifest above and the `registry.k8s.io/agent-sandbox/agent-sandbox-controller` image referenced inside it to your internal registry, then point the manifest's image reference at your mirror before applying. You will also need to mirror the OpenShell gateway and sandbox images — see the chart's `image.repository` value for the gateway and `server.sandboxImage` / `server.supervisorImage` for the sandbox runtime. @@ -290,14 +298,24 @@ The namespaced Role covers sandbox lifecycle and identity: |---|---|---| | `agents.x-k8s.io` | `sandboxes`, `sandboxes/status` | create, delete, get, list, patch, update, watch | | `""` | `events` | get, list, watch | -| `""` | `pods` | get | +| `""` | `pods` | get, list | -The ClusterRole grants node inspection and token validation: +The ClusterRole grants node inspection, token validation, and namespace reads. +Claim inventory, activation, and cleanup permissions remain when +`server.warmPooling.enabled=false`, as do the generated-resource permissions +needed to prune resources created by an earlier configuration. Permissions that +create claims or reconcile active pools are granted only when warm pooling is +enabled. In shared mode these permissions are granted by the namespaced Role; +managed and operator modes use the ClusterRole because they span workspace +namespaces: | API Group | Resource | Verbs | |---|---|---| | `authentication.k8s.io` | `tokenreviews` | create | | `""` | `nodes` | get, list, watch | +| `""` | `namespaces` | get | +| `extensions.agents.x-k8s.io` | `sandboxclaims` | get, list, watch, delete; create when warm pooling is enabled | +| `extensions.agents.x-k8s.io` | `sandboxtemplates`, `sandboxwarmpools` | list, delete; get, watch, create, patch, update when warm pooling is enabled | To use an existing ServiceAccount instead of creating one, set `serviceAccount.create=false` and supply its name: diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index ac56e0fcf8..6d498c3d73 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -210,7 +210,7 @@ Common identity providers such as Keycloak (RS256), Microsoft Entra ID (RSA), an If `OPENSHELL_OIDC_SCOPES_CLAIM` is set, the gateway also enforces scopes. It accepts space-delimited scope strings such as `scope: "openid sandbox:read"` and JSON arrays such as `scp: ["sandbox:read"]`. Standard OIDC scopes such as `openid`, `profile`, `email`, and `offline_access` are ignored for authorization. `openshell:all` grants access to all scoped methods. -Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the Kubernetes compute driver validates the projected ServiceAccount token with TokenReview, verifies the live pod UID and controlling `Sandbox` ownerReference, and returns the authenticated sandbox ID to the gateway. The gateway verifies that sandbox still exists before minting its JWT. Log upload, policy status, provider environment lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. Provider environment responses expose only the credentials and configuration attached to that sandbox, subject to endpoint binding and credential expiry checks. +Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, supervisors first call `RegisterSupervisor` with a projected ServiceAccount token. The gateway sends that token to the selected compute driver's `AuthenticateSandbox` RPC. The Kubernetes driver validates the token with TokenReview, verifies the live pod UID and controlling Agent Sandbox objects, and returns either a bound sandbox identity or a warm-pending supervisor instance. The gateway verifies that the sandbox still exists before streaming its JWT activation. Log upload, policy status, provider environment lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. Provider environment responses expose only the credentials and configuration attached to that sandbox, subject to endpoint binding and credential expiry checks. Re-authenticate an OIDC gateway with: diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 14cdb05ece..dcbb0c5ae5 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -260,7 +260,7 @@ gateway under the worktree-specific k3d cluster name; select it with `openshell gateway select `. The local Podman, Docker, and VM gateway tasks export to the forwarded receiver automatically. -In-process compute drivers read their backend-specific settings from `[openshell.drivers.]`. An external driver's gateway table supplies only its `socket_path`; configure the external driver process itself through that binary's flags or environment variables. A driver that advertises `supports_sandbox_authentication` may authenticate an opaque bootstrap credential through the compute-driver protocol. The gateway trusts the returned sandbox ID only for `IssueSandboxToken`, verifies that the sandbox still exists, and then mints its own JWT. The in-process Kubernetes driver reads `service_account_name`, `workspace_mode`, and namespace discovery from `[openshell.drivers.kubernetes]`; an external Kubernetes driver receives the equivalent values through its own CLI or environment contract. +In-process compute drivers read their backend-specific settings from `[openshell.drivers.]`. An external driver's gateway table supplies only its `socket_path`; configure the external driver process itself through that binary's flags or environment variables. A driver may advertise `supports_sandbox_authentication` so the gateway can authenticate an opaque supervisor bootstrap credential through the driver's `AuthenticateSandbox` RPC. The gateway trusts the returned identity only on `RegisterSupervisor` or the legacy `IssueSandboxToken` compatibility path, verifies that the bound sandbox still exists before minting a gateway JWT, and keeps warm-pending registrations scoped to activation only. Warm-pooling drivers also advertise `supports_warm_supervisor_bootstrap` before returning warm-pending instances, and gateway startup rejects that capability unless an activation controller is installed. The in-process Kubernetes driver reads `service_account_name`, `workspace_mode`, and namespace discovery from `[openshell.drivers.kubernetes]`; an external Kubernetes driver receives the equivalent values through its own CLI or environment contract and remains cold-only because the remote driver protocol has no warm activation callback. ### Tuning @@ -561,6 +561,24 @@ enabled = true gateway_namespace = "openshell" gateway_pod_selector = { "app.kubernetes.io/name" = "openshell", "app.kubernetes.io/instance" = "openshell" } +[openshell.drivers.kubernetes.warm_pooling] +# When enabled, OpenShell generates compatible warm pools from sandbox templates, +# and matching creates may use v1beta1 SandboxClaim resources. +# When disabled, OpenShell still removes generated resources owned by this +# gateway if the SandboxTemplate and SandboxWarmPool APIs are reachable. +# Requires the Agent Sandbox extensions.yaml APIs. OpenShell discovers these +# APIs dynamically, caches successful results for 30 seconds, and uses direct +# Sandbox creation while they are absent. Discovery errors are retried and RBAC +# failures are reported as configuration errors. +enabled = true + +[openshell.drivers.kubernetes.warm_pooling.templates] +# Templates with desired_service_level.startup.ready_within strictly below +# this threshold get a generated warm pool. +ready_within_threshold_secs = 5 +# Cap desired_service_level.startup.max_burst for generated warm pools. +max_replicas = 20 + [openshell.drivers.kubernetes.sidecar] # UID used by relaxed long-running network sidecars. Strict process/binary-aware # sidecars run as UID 0 so Kubernetes grants the required /proc inspection diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index e46ebf8a23..56d590ba2a 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -451,7 +451,7 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA | `app_armor_profile` | `server.appArmorProfile` | Set the sandbox agent container's AppArmor profile. Helm defaults this to `Unconfined` so AppArmor-enabled nodes do not block supervisor network namespace setup. Set the Helm value to an empty string to omit the field, or use `RuntimeDefault` or `Localhost/` for operator-managed profiles. | | `workspace_default_storage_size` | `server.workspaceDefaultStorageSize` | Set the default workspace PVC size for new sandboxes. | | `workspace_storage_class` | `server.workspaceStorageClass` | Set the `StorageClass` for the workspace PVC. Empty (default) omits `storageClassName` and uses the cluster's default `StorageClass`. Set this on clusters with no default `StorageClass`, otherwise the workspace PVC stays `Pending` and the sandbox never starts. | -| `sa_token_ttl_secs` | `server.sandboxJwt.k8sSaTokenTtlSecs` | Set the projected ServiceAccount token TTL used for the bootstrap token exchange. | +| `sa_token_ttl_secs` | `server.sandboxJwt.k8sSaTokenTtlSecs` | Set the projected ServiceAccount token TTL used for `RegisterSupervisor` bootstrap. | Managed-mode Secret copying requires the gateway ServiceAccount to create Secrets. Kubernetes RBAC cannot restrict Secret `create` by resource name, so @@ -494,6 +494,67 @@ workspace PVC keep their identity across both operations. Stop returns only after the controller reports suspension and deletes the old pod, so an immediate start cannot race the prior pod's termination. +When Kubernetes warm pooling is enabled, the driver can satisfy compatible +create requests by creating `extensions.agents.x-k8s.io/v1beta1` +`SandboxClaim` resources instead of direct `Sandbox` resources. OpenShell sets +each claim's `spec.lifecycle.shutdownPolicy` to `Delete`. The driver generates +`SandboxWarmPool` resources from eligible OpenShell templates, +discovers those pools across namespaces it can observe, resolves each pool's +generated `SandboxTemplate`, and matches only against pools in the Kubernetes +namespace selected for the OpenShell workspace. Shared mode selects +`sandbox_namespace`, managed mode uses the derived gateway-owned workspace +namespace, and operator mode uses the allowlisted workspace namespace. Set +`openshell.drivers.kubernetes.warm_pooling.enabled = false` to force direct +`Sandbox` creation. Treat warm-pooling enablement as an installation-time +choice and do not change it on a live system. When the extension APIs remain +reachable, the next reconciliation removes existing gateway-owned generated +warm-pool resources. Kubernetes exposes this behavior through the optional +`SandboxTemplateReconciler` driver service. Drivers that do not advertise +template reconciliation implement only the core `ComputeDriver` service. + +The driver watches `SandboxClaim` lifecycle changes in shared, managed, and +operator modes. In managed and operator modes, it watches claims across +workspace namespaces and scopes them to the current gateway identity, so claim +selection and readiness update the corresponding OpenShell sandbox record. +Operator-mode template reconciliation waits for the first authoritative +namespace allowlist snapshot before applying or pruning generated resources. +An unavailable label watch or invalid initial namespace file therefore defers +reconciliation instead of interpreting an unsynchronized empty allowlist as an +instruction to delete warm pools. + +Warm pooling requires the Agent Sandbox extension APIs from `extensions.yaml`. +The OpenShell chart grants RBAC for those APIs, but it does not install the +`SandboxClaim`, `SandboxTemplate`, or `SandboxWarmPool` CRDs. The driver uses +Kubernetes discovery to detect each resource and caches successful discovery +results for 30 seconds. When resources are absent, OpenShell avoids calls to +them and continues with direct `Sandbox` creation. Installing the CRDs takes +effect without a gateway restart. Discovery failures are not cached, and RBAC +failures against discovered resources are logged as configuration errors. +Claim inventory, activation, and cleanup remain active independently of new +warm allocation so transient failures cannot be mistaken for an empty claim +backend. + +Warm allocation requires the in-process Kubernetes driver because activation +is completed by a gateway-side claim controller. The standalone external +Kubernetes driver advertises warm allocation as unavailable and creates direct +`Sandbox` resources even when extension APIs are installed. It can still +reconcile template desired state to remove existing gateway-owned pools. + +The gateway sends the complete authoritative OpenShell `SandboxTemplate` set at +startup, after template mutations, and periodically. Failed reconciliations are +retried from the gateway store; no per-template delivery rows or delete +tombstones are persisted. Generated resources carry the gateway identity, and +the driver prunes owned resources whose source template is absent. +The driver creates generated Agent Sandbox `SandboxTemplate` and +`SandboxWarmPool` resources when +`desired_service_level.startup.ready_within` is strictly less than +`ready_within_threshold_secs` (default `5`). The generated warm-pool replica +count comes from `desired_service_level.startup.max_burst`, capped by +`max_replicas` (default `20`). + +Templates that do not request startup below the threshold, or templates deleted +from OpenShell, cause the driver to delete their generated warm-pool resources. + If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the controller and CRD rollout completes so the gateway can detect the served API versions again. diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 4f34caa25c..17293d5682 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -158,6 +158,11 @@ name = "host_gateway_alias" path = "tests/host_gateway_alias.rs" required-features = ["e2e-host-gateway"] +[[test]] +name = "kubernetes_warm_pool" +path = "tests/kubernetes_warm_pool.rs" +required-features = ["e2e-kubernetes"] + [[test]] name = "forward_proxy_l7_bypass" path = "tests/forward_proxy_l7_bypass.rs" diff --git a/e2e/rust/src/harness/kubernetes.rs b/e2e/rust/src/harness/kubernetes.rs new file mode 100644 index 0000000000..5be3a6dbae --- /dev/null +++ b/e2e/rust/src/harness/kubernetes.rs @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lightweight Kubernetes helpers for e2e tests. +//! +//! These helpers intentionally shell out to `kubectl` so the e2e crate does not +//! need a Kubernetes client dependency. They inherit `KUBECONFIG` and, when +//! present, `OPENSHELL_E2E_KUBE_CONTEXT` from the wrapper. + +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tokio::process::Command; +use tokio::time::sleep; + +pub async fn kubectl(args: &[&str]) -> Result { + let mut cmd = Command::new("kubectl"); + if let Ok(context) = std::env::var("OPENSHELL_E2E_KUBE_CONTEXT") + && !context.trim().is_empty() + { + cmd.arg("--context").arg(context); + } + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|err| format!("failed to run kubectl {args:?}: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + if !output.status.success() { + return Err(format!( + "kubectl {args:?} failed (exit {:?}):\n{combined}", + output.status.code() + )); + } + + Ok(stdout) +} + +pub async fn kubectl_json(args: &[&str]) -> Result { + let output = kubectl(args).await?; + serde_json::from_str(&output) + .map_err(|err| format!("kubectl {args:?} did not return valid JSON: {err}\n{output}")) +} + +pub async fn has_crd(plural: &str, group: &str) -> bool { + let name = format!("{plural}.{group}"); + kubectl(&["get", "crd", &name]).await.is_ok() +} + +pub async fn wait_for_jsonpath( + namespace: &str, + kind: &str, + name: &str, + jsonpath: &str, + expected: &str, + timeout: Duration, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut last_output: Option; + let output_arg = format!("jsonpath={jsonpath}"); + + loop { + match kubectl(&["get", kind, name, "-n", namespace, "-o", &output_arg]).await { + Ok(output) => { + let trimmed = output.trim().to_string(); + if trimmed == expected { + return Ok(()); + } + last_output = Some(trimmed); + } + Err(err) => last_output = Some(err), + } + + if Instant::now() >= deadline { + let last_output = last_output.unwrap_or_else(|| "".to_string()); + return Err(format!( + "timed out after {}s waiting for {kind}/{name} jsonpath {jsonpath} to equal {expected:?}. Last output:\n{last_output}", + timeout.as_secs() + )); + } + sleep(Duration::from_secs(2)).await; + } +} + +pub async fn wait_for_resource_by_label( + namespace: &str, + kind: &str, + selector: &str, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + let mut last_output: Option; + + loop { + match kubectl_json(&["get", kind, "-n", namespace, "-l", selector, "-o", "json"]).await { + Ok(value) if !items(&value).is_empty() => return Ok(value), + Ok(value) => last_output = Some(value.to_string()), + Err(err) => last_output = Some(err), + } + + if Instant::now() >= deadline { + let last_output = last_output.unwrap_or_else(|| "".to_string()); + return Err(format!( + "timed out after {}s waiting for {kind} in namespace {namespace} with selector {selector}. Last output:\n{last_output}", + timeout.as_secs() + )); + } + sleep(Duration::from_secs(2)).await; + } +} + +pub async fn wait_for_resource_absent_by_label( + namespace: &str, + kind: &str, + selector: &str, + timeout: Duration, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut last_output: Option; + + loop { + match kubectl_json(&["get", kind, "-n", namespace, "-l", selector, "-o", "json"]).await { + Ok(value) if items(&value).is_empty() => return Ok(()), + Ok(value) => last_output = Some(value.to_string()), + Err(err) if err.contains("the server doesn't have a resource type") => return Ok(()), + Err(err) => last_output = Some(err), + } + + if Instant::now() >= deadline { + let last_output = last_output.unwrap_or_else(|| "".to_string()); + return Err(format!( + "timed out after {}s waiting for {kind} in namespace {namespace} with selector {selector} to be absent. Last output:\n{last_output}", + timeout.as_secs() + )); + } + sleep(Duration::from_secs(2)).await; + } +} + +pub async fn delete_resource(namespace: &str, kind: &str, name: &str) -> Result { + kubectl(&[ + "delete", + kind, + name, + "-n", + namespace, + "--ignore-not-found=true", + ]) + .await +} + +pub async fn dump_namespace_diagnostics(namespace: &str) { + eprintln!("=== Kubernetes diagnostics for namespace {namespace} ==="); + for args in [ + vec![ + "get", + "sandboxclaims.extensions.agents.x-k8s.io", + "-n", + namespace, + "-o", + "wide", + ], + vec![ + "get", + "sandboxwarmpools.extensions.agents.x-k8s.io", + "-n", + namespace, + "-o", + "wide", + ], + vec![ + "get", + "sandboxtemplates.extensions.agents.x-k8s.io", + "-n", + namespace, + "-o", + "wide", + ], + vec![ + "get", + "sandboxes.agents.x-k8s.io", + "-n", + namespace, + "-o", + "wide", + ], + vec!["get", "pods", "-n", namespace, "-o", "wide"], + vec!["get", "events", "-n", namespace, "--sort-by=.lastTimestamp"], + vec![ + "logs", + "-n", + namespace, + "-l", + "app.kubernetes.io/instance=openshell", + "--tail=200", + "--all-containers", + "--prefix", + ], + ] { + eprintln!("--- kubectl {args:?} ---"); + match kubectl(&args).await { + Ok(output) => eprintln!("{output}"), + Err(err) => eprintln!("{err}"), + } + } + eprintln!("=== end Kubernetes diagnostics ==="); +} + +pub fn items(value: &Value) -> &[Value] { + value + .get("items") + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) +} diff --git a/e2e/rust/src/harness/mod.rs b/e2e/rust/src/harness/mod.rs index 86bf314a19..daac700d9e 100644 --- a/e2e/rust/src/harness/mod.rs +++ b/e2e/rust/src/harness/mod.rs @@ -8,6 +8,7 @@ pub mod cli; pub mod container; pub mod gateway; pub mod host_process; +pub mod kubernetes; pub mod output; pub mod port; pub mod sandbox; diff --git a/e2e/rust/tests/kubernetes_warm_pool.rs b/e2e/rust/tests/kubernetes_warm_pool.rs new file mode 100644 index 0000000000..a7f52be262 --- /dev/null +++ b/e2e/rust/tests/kubernetes_warm_pool.rs @@ -0,0 +1,701 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-kubernetes")] + +use std::time::Instant; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use openshell_e2e::harness::cli::{ + run_cli, sandbox_names, wait_for_sandbox_exec_contains, wait_for_sandbox_phase, +}; +use openshell_e2e::harness::kubernetes::{ + dump_namespace_diagnostics, has_crd, items, kubectl_json, wait_for_resource_absent_by_label, + wait_for_resource_by_label, +}; +use serde_json::Value; + +const EXTENSIONS_GROUP: &str = "extensions.agents.x-k8s.io"; +const AGENTS_GROUP: &str = "agents.x-k8s.io"; +const CLAIM_KIND: &str = "sandboxclaims.extensions.agents.x-k8s.io"; +const WARM_POOL_KIND: &str = "sandboxwarmpools.extensions.agents.x-k8s.io"; +const TEMPLATE_KIND: &str = "sandboxtemplates.extensions.agents.x-k8s.io"; +const SANDBOX_KIND: &str = "sandboxes.agents.x-k8s.io"; + +const LABEL_ENABLED: &str = "openshell.ai/enabled"; +const LABEL_MANAGED_BY: &str = "openshell.ai/managed-by"; +const LABEL_TEMPLATE_ID: &str = "openshell.ai/warm-pool-template-id"; +const LABEL_ALLOCATION: &str = "openshell.ai/allocation"; +const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name"; +const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; +const MAX_ROUTABLE_NAME_LEN: usize = 19; + +#[derive(Clone, Copy)] +enum TemplateCase { + Default, + Env, + Cpu, +} + +struct GeneratedTemplate { + source_name: String, + selector: String, + template_name: String, + warm_pool_name: String, +} + +struct TestContext { + namespace: String, + templates: Vec, + sandboxes: Vec, +} + +impl TestContext { + fn new(namespace: String) -> Self { + Self { + namespace, + templates: Vec::new(), + sandboxes: Vec::new(), + } + } + + async fn cleanup(&mut self) { + for sandbox in self.sandboxes.iter().rev() { + let _ = run_cli(&["sandbox", "delete", sandbox]).await; + } + for template in self.templates.iter().rev() { + let _ = run_cli(&["sandbox", "template", "delete", template]).await; + } + } +} + +#[tokio::test] +async fn kubernetes_warm_pool_templates_claim_and_fallback() { + if !env_flag("OPENSHELL_E2E_KUBE_WARM_POOL") { + eprintln!( + "Skipping Kubernetes warm-pool e2e test: set OPENSHELL_E2E_KUBE_WARM_POOL=1 to enable" + ); + return; + } + + let namespace = + std::env::var("OPENSHELL_E2E_SANDBOX_NAMESPACE").unwrap_or_else(|_| "openshell".into()); + let strict = env_flag("OPENSHELL_E2E_KUBE_WARM_POOL_STRICT"); + if let Err(err) = ensure_required_crds(strict).await { + if strict { + panic!("{err}"); + } + eprintln!("Skipping Kubernetes warm-pool e2e test: {err}"); + return; + } + + let mut ctx = TestContext::new(namespace); + let result = run_warm_pool_e2e(&mut ctx).await; + if let Err(err) = result { + dump_namespace_diagnostics(&ctx.namespace).await; + ctx.cleanup().await; + panic!("{err}"); + } + ctx.cleanup().await; +} + +async fn run_warm_pool_e2e(ctx: &mut TestContext) -> Result<(), String> { + let template_suffix = unique_template_suffix(); + let sandbox_suffix = unique_sandbox_suffix(); + + let default_template = create_and_assert_template( + ctx, + &template_name("default", &template_suffix), + TemplateCase::Default, + ) + .await?; + let env_template = create_and_assert_template( + ctx, + &template_name("env", &template_suffix), + TemplateCase::Env, + ) + .await?; + let cpu_template = create_and_assert_template( + ctx, + &template_name("cpu", &template_suffix), + TemplateCase::Cpu, + ) + .await?; + + create_and_assert_claimed_default_command( + ctx, + &format!("wps-{sandbox_suffix}"), + &default_template, + ) + .await?; + + create_and_assert_claimed( + ctx, + &format!("wpd-{sandbox_suffix}"), + &[], + "warm-pool-ok", + &default_template, + ) + .await?; + create_and_assert_claimed( + ctx, + &format!("wpe-{sandbox_suffix}"), + &[], + "env-ok", + &env_template, + ) + .await?; + create_and_assert_claimed( + ctx, + &format!("wpc-{sandbox_suffix}"), + &[], + "cpu-ok", + &cpu_template, + ) + .await?; + + create_and_assert_direct_fallback(ctx, &format!("wpf-{sandbox_suffix}")).await?; + delete_template_and_assert_gc(ctx, &env_template, &[&default_template, &cpu_template]).await?; + + Ok(()) +} + +async fn create_and_assert_claimed_default_command( + ctx: &mut TestContext, + sandbox_name: &str, + template: &GeneratedTemplate, +) -> Result<(), String> { + ctx.sandboxes.push(sandbox_name.to_string()); + let args = [ + "sandbox", + "create", + "--name", + sandbox_name, + "--no-tty", + "--detach", + "--template", + &template.source_name, + ]; + let (output, code) = run_cli(&args).await; + if code != 0 { + return Err(format!( + "detached scratch sandbox create for {sandbox_name} failed with exit {code}; output:\n{output}" + )); + } + + wait_for_sandbox_exec_contains( + sandbox_name, + &["echo", "scratch-ready"], + "scratch-ready", + Duration::from_secs(120), + ) + .await?; + let claim = wait_for_claim(ctx, sandbox_name).await?; + if claim["spec"]["warmPoolRef"]["name"].as_str() != Some(&template.warm_pool_name) { + return Err(format!( + "scratch sandbox {sandbox_name} did not use warm pool {}: {claim}", + template.warm_pool_name + )); + } + assert_claim_delete_shutdown_policy(sandbox_name, &claim)?; + Ok(()) +} + +async fn ensure_required_crds(strict: bool) -> Result<(), String> { + let required = [ + ("sandboxes", AGENTS_GROUP), + ("sandboxclaims", EXTENSIONS_GROUP), + ("sandboxtemplates", EXTENSIONS_GROUP), + ("sandboxwarmpools", EXTENSIONS_GROUP), + ]; + let mut missing = Vec::new(); + for (plural, group) in required { + if !has_crd(plural, group).await { + missing.push(format!("{plural}.{group}")); + } + } + if missing.is_empty() { + Ok(()) + } else if strict { + Err(format!( + "required Agent Sandbox CRDs are missing in strict mode: {}", + missing.join(", ") + )) + } else { + Err(format!( + "required Agent Sandbox CRDs are missing: {}", + missing.join(", ") + )) + } +} + +async fn create_and_assert_template( + ctx: &mut TestContext, + name: &str, + case: TemplateCase, +) -> Result { + ctx.templates.push(name.to_string()); + let mut args = vec![ + "sandbox", + "template", + "create", + name, + "--ready-within", + "1s", + "--max-burst", + "1", + "--output", + "json", + ]; + match case { + TemplateCase::Default => {} + TemplateCase::Env => args.extend_from_slice(&["--env", "FOO=bar"]), + TemplateCase::Cpu => args.extend_from_slice(&["--cpu", "0.2"]), + } + let (output, code) = run_cli(&args).await; + if code != 0 { + return Err(format!( + "sandbox template create for {name} failed with exit {code}; output:\n{output}" + )); + } + let source: Value = serde_json::from_str(&output) + .map_err(|err| format!("parse sandbox template create JSON for {name}: {err}\n{output}"))?; + let source_id = source["id"] + .as_str() + .or_else(|| source["metadata"]["id"].as_str()) + .ok_or_else(|| format!("sandbox template {name} JSON is missing metadata.id: {source}"))? + .to_string(); + + let selector = + format!("{LABEL_MANAGED_BY}=openshell-kubernetes-driver,{LABEL_TEMPLATE_ID}={source_id}"); + let template = single_labelled_resource(&ctx.namespace, TEMPLATE_KIND, &selector).await?; + let warm_pool = single_labelled_resource(&ctx.namespace, WARM_POOL_KIND, &selector).await?; + + let template_name = object_name(&template)?; + let warm_pool_name = object_name(&warm_pool)?; + if template_name != warm_pool_name { + return Err(format!( + "generated resource names should match for source {name}; template={template_name}, warm_pool={warm_pool_name}" + )); + } + + assert_generated_labels(&template, name)?; + assert_generated_labels(&warm_pool, name)?; + if warm_pool["spec"]["sandboxTemplateRef"]["name"].as_str() != Some(&template_name) { + return Err(format!( + "SandboxWarmPool/{warm_pool_name} does not reference SandboxTemplate/{template_name}: {warm_pool}" + )); + } + assert_template_invariants(&template, case)?; + wait_for_warm_pool_ready(&ctx.namespace, &warm_pool_name, Duration::from_secs(120)).await?; + + Ok(GeneratedTemplate { + source_name: name.to_string(), + selector, + template_name, + warm_pool_name, + }) +} + +async fn single_labelled_resource( + namespace: &str, + kind: &str, + selector: &str, +) -> Result { + let list = + wait_for_resource_by_label(namespace, kind, selector, Duration::from_secs(120)).await?; + let list_items = items(&list); + if list_items.len() != 1 { + return Err(format!( + "expected exactly one {kind} with selector {selector}, got {}: {list}", + list_items.len() + )); + } + Ok(list_items[0].clone()) +} + +async fn wait_for_warm_pool_ready( + namespace: &str, + name: &str, + timeout: Duration, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut last_observation: Option; + + loop { + match kubectl_json(&["get", WARM_POOL_KIND, name, "-n", namespace, "-o", "json"]).await { + Ok(value) if warm_pool_ready(&value) => return Ok(()), + Ok(value) => last_observation = Some(value), + Err(err) => last_observation = Some(serde_json::json!({ "error": err })), + } + + if Instant::now() >= deadline { + let last_observation = + last_observation.unwrap_or_else(|| serde_json::json!("")); + return Err(format!( + "SandboxWarmPool/{name} did not become ready within {}s. Last observation:\n{}", + timeout.as_secs(), + serde_json::to_string_pretty(&last_observation) + .unwrap_or_else(|_| last_observation.to_string()) + )); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +fn warm_pool_ready(value: &Value) -> bool { + let desired = value["spec"]["replicas"].as_u64().unwrap_or(1); + if desired == 0 { + return true; + } + + if status_condition_is_true(value, "Ready") || status_condition_is_true(value, "Available") { + return true; + } + + for path in [ + "/status/readyReplicas", + "/status/availableReplicas", + "/status/currentReadyReplicas", + ] { + if value + .pointer(path) + .and_then(Value::as_u64) + .is_some_and(|ready| ready >= desired) + { + return true; + } + } + + false +} + +fn status_condition_is_true(value: &Value, condition_type: &str) -> bool { + value["status"]["conditions"] + .as_array() + .into_iter() + .flatten() + .any(|condition| { + condition["type"].as_str() == Some(condition_type) + && condition["status"] + .as_str() + .is_some_and(|status| status.eq_ignore_ascii_case("true")) + }) +} + +async fn create_and_assert_claimed( + ctx: &mut TestContext, + sandbox_name: &str, + extra_create_args: &[&str], + marker: &str, + template: &GeneratedTemplate, +) -> Result<(), String> { + ctx.sandboxes.push(sandbox_name.to_string()); + let mut args = vec![ + "sandbox", + "create", + "--name", + sandbox_name, + "--no-tty", + "--template", + &template.source_name, + ]; + args.extend_from_slice(extra_create_args); + args.extend_from_slice(&["--", "echo", marker]); + let (output, code) = run_cli(&args).await; + if code != 0 || !output.contains(marker) { + return Err(format!( + "sandbox create for {sandbox_name} did not succeed through warm pool (exit {code}); expected marker {marker:?}. Output:\n{output}" + )); + } + + let claim = wait_for_claim(ctx, sandbox_name).await?; + if claim["metadata"]["labels"][LABEL_ALLOCATION].as_str() != Some("sandbox-claim") { + return Err(format!( + "SandboxClaim for {sandbox_name} is missing allocation label: {claim}" + )); + } + if claim["metadata"]["labels"][LABEL_SANDBOX_WORKSPACE].as_str() != Some("default") { + return Err(format!( + "SandboxClaim for {sandbox_name} is not in default workspace: {claim}" + )); + } + if claim["spec"]["warmPoolRef"]["name"].as_str() != Some(&template.warm_pool_name) { + return Err(format!( + "SandboxClaim for {sandbox_name} used wrong warm pool; expected {}, claim: {claim}", + template.warm_pool_name + )); + } + assert_claim_delete_shutdown_policy(sandbox_name, &claim)?; + + let names = sandbox_names().await?; + if !names.iter().any(|name| name == sandbox_name) { + return Err(format!( + "sandbox list --names did not include claimed sandbox {sandbox_name}; names={names:?}" + )); + } + wait_for_sandbox_phase(sandbox_name, "Completed", Duration::from_secs(120)).await?; + + Ok(()) +} + +fn assert_claim_delete_shutdown_policy(sandbox_name: &str, claim: &Value) -> Result<(), String> { + if claim["spec"]["lifecycle"]["shutdownPolicy"].as_str() != Some("Delete") { + return Err(format!( + "SandboxClaim for {sandbox_name} does not use Delete shutdown policy: {claim}" + )); + } + Ok(()) +} + +async fn wait_for_claim(ctx: &TestContext, sandbox_name: &str) -> Result { + let selector = format!("{LABEL_ALLOCATION}=sandbox-claim,{LABEL_SANDBOX_NAME}={sandbox_name}"); + single_labelled_resource(&ctx.namespace, CLAIM_KIND, &selector).await +} + +async fn create_and_assert_direct_fallback( + ctx: &mut TestContext, + sandbox_name: &str, +) -> Result<(), String> { + ctx.sandboxes.push(sandbox_name.to_string()); + let args = [ + "sandbox", + "create", + "--name", + sandbox_name, + "--no-tty", + "--env", + "FOO=baz", + "--", + "echo", + "fallback-ok", + ]; + let (output, code) = run_cli(&args).await; + if code != 0 || !output.contains("fallback-ok") { + return Err(format!( + "fallback sandbox create failed (exit {code}); output:\n{output}" + )); + } + + let claim_selector = + format!("{LABEL_ALLOCATION}=sandbox-claim,{LABEL_SANDBOX_NAME}={sandbox_name}"); + let claims = kubectl_json(&[ + "get", + CLAIM_KIND, + "-n", + &ctx.namespace, + "-l", + &claim_selector, + "-o", + "json", + ]) + .await?; + if !items(&claims).is_empty() { + return Err(format!( + "fallback sandbox {sandbox_name} unexpectedly allocated through SandboxClaim: {claims}" + )); + } + + let sandbox_selector = + format!("{LABEL_MANAGED_BY}=openshell,{LABEL_SANDBOX_NAME}={sandbox_name}"); + let sandbox = single_labelled_resource(&ctx.namespace, SANDBOX_KIND, &sandbox_selector).await?; + if object_name(&sandbox)? != format!("default--{sandbox_name}") { + return Err(format!( + "fallback sandbox used unexpected direct Sandbox name: {sandbox}" + )); + } + + Ok(()) +} + +async fn delete_template_and_assert_gc( + ctx: &mut TestContext, + deleted: &GeneratedTemplate, + remaining: &[&GeneratedTemplate], +) -> Result<(), String> { + let (output, code) = run_cli(&["sandbox", "template", "delete", &deleted.source_name]).await; + if code != 0 { + return Err(format!( + "sandbox template delete for {} failed with exit {code}; output:\n{output}", + deleted.source_name + )); + } + wait_for_resource_absent_by_label( + &ctx.namespace, + WARM_POOL_KIND, + &deleted.selector, + Duration::from_secs(120), + ) + .await?; + wait_for_resource_absent_by_label( + &ctx.namespace, + TEMPLATE_KIND, + &deleted.selector, + Duration::from_secs(120), + ) + .await?; + + for expected in remaining { + let template = + single_labelled_resource(&ctx.namespace, TEMPLATE_KIND, &expected.selector).await?; + let warm_pool = + single_labelled_resource(&ctx.namespace, WARM_POOL_KIND, &expected.selector).await?; + if object_name(&template)? != expected.template_name + || object_name(&warm_pool)? != expected.warm_pool_name + { + return Err(format!( + "generated resources for template {} changed while deleting {}", + expected.source_name, deleted.source_name + )); + } + } + + Ok(()) +} + +fn assert_generated_labels(obj: &Value, source_name: &str) -> Result<(), String> { + let labels = obj["metadata"]["labels"] + .as_object() + .ok_or_else(|| format!("generated object is missing metadata.labels: {obj}"))?; + if labels.get(LABEL_ENABLED).and_then(Value::as_str) != Some("true") { + return Err(format!( + "generated object is missing {LABEL_ENABLED}=true: {obj}" + )); + } + if labels.get(LABEL_MANAGED_BY).and_then(Value::as_str) != Some("openshell-kubernetes-driver") { + return Err(format!( + "generated object is missing driver manager label: {obj}" + )); + } + if labels + .get(LABEL_TEMPLATE_ID) + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + return Err(format!( + "generated object for source {source_name} is missing template id label: {obj}" + )); + } + Ok(()) +} + +fn assert_template_invariants(template: &Value, case: TemplateCase) -> Result<(), String> { + if template["spec"]["podTemplate"]["metadata"]["labels"][LABEL_MANAGED_BY].as_str() + != Some("openshell") + { + return Err(format!( + "generated SandboxTemplate must label warm pods with {LABEL_MANAGED_BY}=openshell: {template}" + )); + } + if template["spec"]["podTemplate"]["spec"]["dnsPolicy"].as_str() != Some("ClusterFirst") { + return Err(format!( + "generated SandboxTemplate must set pod dnsPolicy=ClusterFirst: {template}" + )); + } + + let agent = agent_container(template)?; + let image = agent["image"] + .as_str() + .ok_or_else(|| format!("agent container is missing image: {template}"))?; + if image.is_empty() { + return Err(format!( + "agent container image must not be empty: {template}" + )); + } + + let env = agent["env"].as_array().map_or(&[][..], Vec::as_slice); + for reserved in ["OPENSHELL_SANDBOX_ID", "OPENSHELL_SANDBOX"] { + if env + .iter() + .any(|entry| entry["name"].as_str() == Some(reserved)) + { + return Err(format!( + "warm-pool template must not include reserved env {reserved}: {template}" + )); + } + } + + match case { + TemplateCase::Default => {} + TemplateCase::Env => { + let foo = env + .iter() + .find(|entry| entry["name"].as_str() == Some("FOO")) + .and_then(|entry| entry["value"].as_str()); + if foo != Some("bar") { + return Err(format!( + "env warm-pool template did not include FOO=bar: {template}" + )); + } + } + TemplateCase::Cpu => { + let resources = &agent["resources"]; + if resources["limits"]["cpu"].as_str() != Some("0.2") + || resources["requests"]["cpu"].as_str() != Some("0.2") + { + return Err(format!( + "CPU warm-pool template did not render cpu requests/limits=0.2: {template}" + )); + } + } + } + + Ok(()) +} + +fn agent_container(template: &Value) -> Result<&Value, String> { + let containers = template["spec"]["podTemplate"]["spec"]["containers"] + .as_array() + .ok_or_else(|| format!("SandboxTemplate is missing pod containers: {template}"))?; + containers + .iter() + .find(|container| container["name"].as_str() == Some("agent")) + .ok_or_else(|| format!("SandboxTemplate is missing agent container: {template}")) +} + +fn object_name(obj: &Value) -> Result { + obj["metadata"]["name"] + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| format!("object is missing metadata.name: {obj}")) +} + +fn unique_template_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let value = (nanos ^ u128::from(std::process::id())) & 0xffff_ffff; + format!("{value:08x}") +} + +fn template_name(case: &str, suffix: &str) -> String { + let name = format!("wp-{case}-{suffix}"); + assert!( + name.len() <= MAX_ROUTABLE_NAME_LEN, + "generated template name {name:?} exceeds {MAX_ROUTABLE_NAME_LEN} characters" + ); + name +} + +#[test] +fn generated_template_names_fit_routable_limit() { + for case in ["default", "env", "cpu"] { + assert!(template_name(case, "12345678").len() <= MAX_ROUTABLE_NAME_LEN); + } +} + +fn unique_sandbox_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let value = (nanos ^ u128::from(std::process::id())) & 0x00ff_ffff; + format!("{value:06x}") +} + +fn env_flag(name: &str) -> bool { + std::env::var(name).is_ok_and(|value| { + value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") + }) +} diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index 8a9eaf3df2..c0da0bd01b 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -865,6 +865,11 @@ fi # every gateway K8s call 404s and CreateSandbox never produces a Pod. AGENT_SANDBOX_VERSION="${AGENT_SANDBOX_VERSION}" \ bash "${ROOT}/e2e/support/install-agent-sandbox.sh" --context "${KUBE_CONTEXT}" +if [[ "${AGENT_SANDBOX_VERSION}" != v0.4.* ]]; then + echo "Installing agent-sandbox extension CRDs and controllers (${AGENT_SANDBOX_VERSION})..." + _agent_sandbox_base="https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}" + kctl apply -f "${_agent_sandbox_base}/extensions.yaml" +fi ACTIVE_CREDENTIAL_DRIVER="${OPENSHELL_E2E_CREDENTIAL_DRIVER:-kubernetes-secrets}" if [ "${OPENSHELL_E2E_CREDENTIAL_DRIVERS:-0}" = "1" ] \ @@ -875,6 +880,9 @@ fi helm_extra_args=() helm_post_renderer_args=() helm_extra_args+=(--set "server.telemetryEnabled=${OPENSHELL_TELEMETRY_ENABLED}") +if [[ "${AGENT_SANDBOX_VERSION}" == v0.4.* ]]; then + helm_extra_args+=(--set "server.warmPooling.enabled=false") +fi if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then if [ "${OPENSHELL_E2E_KUBE_BUILD_IMAGES}" != "1" ]; then echo "ERROR: external Kubernetes driver e2e requires OPENSHELL_E2E_KUBE_BUILD_IMAGES=1." >&2 diff --git a/examples/kubernetes-warm-pool-config/README.md b/examples/kubernetes-warm-pool-config/README.md new file mode 100644 index 0000000000..121d3034ad --- /dev/null +++ b/examples/kubernetes-warm-pool-config/README.md @@ -0,0 +1,28 @@ +# Kubernetes warm-pool templates + +Create OpenShell sandbox templates with a startup service level that is below +the Kubernetes driver's configured warm-pool threshold. With the default +threshold of 5 seconds, these examples generate Agent Sandbox `SandboxTemplate` +and `SandboxWarmPool` resources: + +```shell +openshell sandbox template create openshell-warm-pool-default \ + --ready-within 1s \ + --max-burst 1 + +openshell sandbox template create openshell-warm-pool-env-foo \ + --env FOO=bar \ + --ready-within 1s \ + --max-burst 1 + +openshell sandbox template create openshell-warm-pool-cpu-0-2 \ + --cpu 0.2 \ + --ready-within 1s \ + --max-burst 1 +``` + +Create a sandbox from a warmed template: + +```shell +openshell sandbox create --template openshell-warm-pool-env-foo -- echo ready +``` diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f9a19589f9..13dbbcef77 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.compute.v1; import "google/protobuf/struct.proto"; +import "google/protobuf/duration.proto"; import "options.proto"; import "sandbox.proto"; @@ -70,6 +71,18 @@ service ComputeDriver { rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse); } +// Optional compute-driver capability for reusable sandbox-template desired +// state. Drivers advertise and serve this API only when they support template +// reconciliation. +service SandboxTemplateReconciler { + // Reconcile the driver to the complete authoritative set of reusable sandbox + // templates currently stored by the gateway. The driver must apply the + // supplied templates idempotently and prune resources that it owns for + // templates absent from the request. + rpc ReconcileSandboxTemplates(ReconcileSandboxTemplatesRequest) + returns (ReconcileSandboxTemplatesResponse); +} + message GetCapabilitiesRequest {} message GetCapabilitiesResponse { @@ -100,6 +113,10 @@ message GetCapabilitiesResponse { // Maximum rootfs tar file size in bytes accepted by the driver. Zero means // the driver does not support rootfs tar sources. uint64 rootfs_tar_max_bytes = 11; + // Whether the SandboxTemplateReconciler service is implemented. + bool supports_sandbox_template_reconciliation = 12; + // Whether AuthenticateSandbox may return a warm-pending supervisor instance. + bool supports_warm_supervisor_bootstrap = 13; } message AuthenticateSandboxRequest { @@ -108,8 +125,21 @@ message AuthenticateSandboxRequest { } message AuthenticateSandboxResponse { - // Stable gateway-assigned sandbox ID authenticated by the driver. - string sandbox_id = 1; + oneof binding { + // Stable gateway-assigned sandbox ID authenticated by the driver. + string sandbox_id = 1; + // Driver-authenticated supervisor instance that is valid but not yet bound + // to a sandbox. The gateway keeps the registration stream pending until + // activation presents the same instance_id and activation_guard. + WarmPendingInstance warm_pending = 2; + } +} + +message WarmPendingInstance { + // Driver-native stable instance ID, for example Kubernetes pod UID. + string instance_id = 1; + // Opaque driver-owned activation correlation value. + string activation_guard = 2 [(openshell.options.v1.secret) = true]; } // Static portable resource request forms supported by a compute driver. @@ -263,6 +293,43 @@ message DriverSandboxTemplate { optional bool user_namespaces = 13; } +// Gateway-owned reusable template translated into the driver-native compute +// contract. Drivers opt in to receiving this resource via capabilities. +message DriverSandboxTemplateResource { + string id = 1; + string name = 2; + string workspace = 3; + uint64 resource_version = 4; + map labels = 5; + map annotations = 6; + int64 deletion_timestamp_ms = 7; + DriverSandboxTemplate template = 8; + DriverSandboxTemplateServiceLevel desired_service_level = 9; + ResourceRequirements resource_requirements = 10; +} + +message DriverSandboxTemplateServiceLevel { + DriverSandboxTemplateStartup startup = 1; +} + +message DriverSandboxTemplateStartup { + google.protobuf.Duration ready_within = 1; + uint32 max_burst = 2; +} + +message ReconcileSandboxTemplatesRequest { + // Complete desired set. An empty list means the driver must remove every + // sandbox-template resource owned by this gateway. + repeated DriverSandboxTemplateResource templates = 1; +} + +message ReconcileSandboxTemplatesResponse { + // Number of desired templates successfully evaluated by the driver. + uint32 reconciled = 1; + // Number of stale driver-owned template resources pruned from the backend. + uint32 pruned = 2; +} + // Typed compute-resource requirements. // // Values use Kubernetes-style quantity strings (e.g. "500m", "2", "4Gi") @@ -361,10 +428,21 @@ message ListSandboxesResponse { message CreateSandboxRequest { // Sandbox configuration to provision on the compute platform. DriverSandbox sandbox = 1; + // Template provenance set only when the public CreateSandbox request used a + // named SandboxTemplate. + DriverSandboxTemplateRef sandbox_template = 2; } message CreateSandboxResponse {} +// Stable source template identity for a template-created sandbox. +message DriverSandboxTemplateRef { + string id = 1; + string name = 2; + string workspace = 3; + uint64 resource_version = 4; +} + message StopSandboxRequest { // Stable sandbox ID stored by the gateway. string sandbox_id = 1; diff --git a/proto/openshell.proto b/proto/openshell.proto index c2051f94b8..0b87588bfd 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -666,6 +666,18 @@ service OpenShell { }; } + // Register a supervisor instance and wait for gateway activation. + // + // The initial trial activates already-bound supervisor instances immediately. Later + // warm-pool stages keep this stream pending until a SandboxClaim adopts the + // registered instance. + rpc RegisterSupervisor(RegisterSupervisorRequest) + returns (stream SupervisorActivationMessage) { + option (openshell.options.v1.authorization) = { + auth_mode: "supervisor_registration" + }; + } + // Renew the calling sandbox's gateway JWT. Older tokens remain valid // until their own expiry; deployments should keep token TTLs short to // bound replay exposure. The supervisor calls this from a background @@ -763,6 +775,28 @@ message IssueSandboxTokenResponse { int64 expires_at_ms = 2; } +// RegisterSupervisor request. Empty body; identity is established by the +// authentication credentials carried in the request headers (a projected +// Kubernetes ServiceAccount JWT in the K8s driver path). +message RegisterSupervisorRequest {} + +// Activation sent by the gateway once a registered supervisor instance is bound to +// an OpenShell sandbox identity. +message SupervisorActivationMessage { + // OpenShell sandbox UUID the supervisor should use for ConnectSupervisor. + string sandbox_id = 1; + // Human-readable sandbox name, if known. + string sandbox_name = 2; + // Gateway-minted JWT bound to the sandbox UUID. + string token = 3 [(openshell.options.v1.secret) = true]; + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 token_expires_at_ms = 4; + // Reserved for future activation-time metadata. The phase-1 cold path leaves + // this empty. + map startup_metadata = 5; +} + // RefreshSandboxToken request. The calling principal must already be a // sandbox principal (i.e. the request carries a still-valid gateway-minted // JWT in its Authorization header). Extension service names are resolved diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 16929f672c..8d576b7317 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -108,6 +108,20 @@ func TestConverterCoversAllProtoFields_SandboxStartup(t *testing.T) { assertAllFieldsCovered(t, (&pb.SandboxStartup{}).ProtoReflect().Descriptor(), handled, nil) } +func TestConverterCoversAllProtoFields_SandboxWorkloadTemplateProvenance(t *testing.T) { + handled := fieldSet{ + "name": true, + "resource_version": true, + } + + assertAllFieldsCovered( + t, + (&pb.SandboxWorkloadTemplateProvenance{}).ProtoReflect().Descriptor(), + handled, + nil, + ) +} + func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { handled := fieldSet{ "sandbox_name": true, diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index bf15f11059..fd58b6cf8d 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -32,7 +32,6 @@ func SandboxFromProto(s *pb.Sandbox) *types.Sandbox { result.Workspace = m.GetWorkspace() result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) } - if provenance := s.GetCreatedFromWorkloadTemplate(); provenance != nil { result.CreatedFromWorkloadTemplate = &types.SandboxWorkloadTemplateProvenance{ Name: provenance.GetName(), diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 3c977cbb76..fe2a027710 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -602,6 +602,130 @@ func (x *IssueSandboxTokenResponse) GetExpiresAtMs() int64 { return 0 } +// RegisterSupervisor request. Empty body; identity is established by the +// authentication credentials carried in the request headers (a projected +// Kubernetes ServiceAccount JWT in the K8s driver path). +type RegisterSupervisorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterSupervisorRequest) Reset() { + *x = RegisterSupervisorRequest{} + mi := &file_openshell_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterSupervisorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterSupervisorRequest) ProtoMessage() {} + +func (x *RegisterSupervisorRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterSupervisorRequest.ProtoReflect.Descriptor instead. +func (*RegisterSupervisorRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{2} +} + +// Activation sent by the gateway once a registered supervisor instance is bound to +// an OpenShell sandbox identity. +type SupervisorActivationMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // OpenShell sandbox UUID the supervisor should use for ConnectSupervisor. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Human-readable sandbox name, if known. + SandboxName string `protobuf:"bytes,2,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Gateway-minted JWT bound to the sandbox UUID. + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + TokenExpiresAtMs int64 `protobuf:"varint,4,opt,name=token_expires_at_ms,json=tokenExpiresAtMs,proto3" json:"token_expires_at_ms,omitempty"` + // Reserved for future activation-time metadata. The phase-1 cold path leaves + // this empty. + StartupMetadata map[string]string `protobuf:"bytes,5,rep,name=startup_metadata,json=startupMetadata,proto3" json:"startup_metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorActivationMessage) Reset() { + *x = SupervisorActivationMessage{} + mi := &file_openshell_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorActivationMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorActivationMessage) ProtoMessage() {} + +func (x *SupervisorActivationMessage) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SupervisorActivationMessage.ProtoReflect.Descriptor instead. +func (*SupervisorActivationMessage) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{3} +} + +func (x *SupervisorActivationMessage) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SupervisorActivationMessage) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *SupervisorActivationMessage) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *SupervisorActivationMessage) GetTokenExpiresAtMs() int64 { + if x != nil { + return x.TokenExpiresAtMs + } + return 0 +} + +func (x *SupervisorActivationMessage) GetStartupMetadata() map[string]string { + if x != nil { + return x.StartupMetadata + } + return nil +} + // RefreshSandboxToken request. The calling principal must already be a // sandbox principal (i.e. the request carries a still-valid gateway-minted // JWT in its Authorization header). Extension service names are resolved @@ -618,7 +742,7 @@ type RefreshSandboxTokenRequest struct { func (x *RefreshSandboxTokenRequest) Reset() { *x = RefreshSandboxTokenRequest{} - mi := &file_openshell_proto_msgTypes[2] + mi := &file_openshell_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -630,7 +754,7 @@ func (x *RefreshSandboxTokenRequest) String() string { func (*RefreshSandboxTokenRequest) ProtoMessage() {} func (x *RefreshSandboxTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[2] + mi := &file_openshell_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -643,7 +767,7 @@ func (x *RefreshSandboxTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshSandboxTokenRequest.ProtoReflect.Descriptor instead. func (*RefreshSandboxTokenRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{2} + return file_openshell_proto_rawDescGZIP(), []int{4} } func (x *RefreshSandboxTokenRequest) GetExtensionServiceNames() []string { @@ -671,7 +795,7 @@ type RefreshSandboxTokenResponse struct { func (x *RefreshSandboxTokenResponse) Reset() { *x = RefreshSandboxTokenResponse{} - mi := &file_openshell_proto_msgTypes[3] + mi := &file_openshell_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -683,7 +807,7 @@ func (x *RefreshSandboxTokenResponse) String() string { func (*RefreshSandboxTokenResponse) ProtoMessage() {} func (x *RefreshSandboxTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[3] + mi := &file_openshell_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -696,7 +820,7 @@ func (x *RefreshSandboxTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshSandboxTokenResponse.ProtoReflect.Descriptor instead. func (*RefreshSandboxTokenResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{3} + return file_openshell_proto_rawDescGZIP(), []int{5} } func (x *RefreshSandboxTokenResponse) GetToken() string { @@ -729,7 +853,7 @@ type HealthRequest struct { func (x *HealthRequest) Reset() { *x = HealthRequest{} - mi := &file_openshell_proto_msgTypes[4] + mi := &file_openshell_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -741,7 +865,7 @@ func (x *HealthRequest) String() string { func (*HealthRequest) ProtoMessage() {} func (x *HealthRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[4] + mi := &file_openshell_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -754,7 +878,7 @@ func (x *HealthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. func (*HealthRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{4} + return file_openshell_proto_rawDescGZIP(), []int{6} } // Health check response. @@ -770,7 +894,7 @@ type HealthResponse struct { func (x *HealthResponse) Reset() { *x = HealthResponse{} - mi := &file_openshell_proto_msgTypes[5] + mi := &file_openshell_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -782,7 +906,7 @@ func (x *HealthResponse) String() string { func (*HealthResponse) ProtoMessage() {} func (x *HealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[5] + mi := &file_openshell_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -795,7 +919,7 @@ func (x *HealthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. func (*HealthResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{5} + return file_openshell_proto_rawDescGZIP(), []int{7} } func (x *HealthResponse) GetStatus() ServiceStatus { @@ -821,7 +945,7 @@ type GetCurrentUserRequest struct { func (x *GetCurrentUserRequest) Reset() { *x = GetCurrentUserRequest{} - mi := &file_openshell_proto_msgTypes[6] + mi := &file_openshell_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -833,7 +957,7 @@ func (x *GetCurrentUserRequest) String() string { func (*GetCurrentUserRequest) ProtoMessage() {} func (x *GetCurrentUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[6] + mi := &file_openshell_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -846,7 +970,7 @@ func (x *GetCurrentUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCurrentUserRequest.ProtoReflect.Descriptor instead. func (*GetCurrentUserRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{6} + return file_openshell_proto_rawDescGZIP(), []int{8} } // Authenticated user identity as validated by the gateway. @@ -868,7 +992,7 @@ type GetCurrentUserResponse struct { func (x *GetCurrentUserResponse) Reset() { *x = GetCurrentUserResponse{} - mi := &file_openshell_proto_msgTypes[7] + mi := &file_openshell_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -880,7 +1004,7 @@ func (x *GetCurrentUserResponse) String() string { func (*GetCurrentUserResponse) ProtoMessage() {} func (x *GetCurrentUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[7] + mi := &file_openshell_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -893,7 +1017,7 @@ func (x *GetCurrentUserResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCurrentUserResponse.ProtoReflect.Descriptor instead. func (*GetCurrentUserResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{7} + return file_openshell_proto_rawDescGZIP(), []int{9} } func (x *GetCurrentUserResponse) GetSubject() string { @@ -940,7 +1064,7 @@ type GetGatewayInfoRequest struct { func (x *GetGatewayInfoRequest) Reset() { *x = GetGatewayInfoRequest{} - mi := &file_openshell_proto_msgTypes[8] + mi := &file_openshell_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -952,7 +1076,7 @@ func (x *GetGatewayInfoRequest) String() string { func (*GetGatewayInfoRequest) ProtoMessage() {} func (x *GetGatewayInfoRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[8] + mi := &file_openshell_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -965,7 +1089,7 @@ func (x *GetGatewayInfoRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayInfoRequest.ProtoReflect.Descriptor instead. func (*GetGatewayInfoRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{8} + return file_openshell_proto_rawDescGZIP(), []int{10} } // Gateway info response. @@ -984,7 +1108,7 @@ type GetGatewayInfoResponse struct { func (x *GetGatewayInfoResponse) Reset() { *x = GetGatewayInfoResponse{} - mi := &file_openshell_proto_msgTypes[9] + mi := &file_openshell_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -996,7 +1120,7 @@ func (x *GetGatewayInfoResponse) String() string { func (*GetGatewayInfoResponse) ProtoMessage() {} func (x *GetGatewayInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[9] + mi := &file_openshell_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1009,7 +1133,7 @@ func (x *GetGatewayInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayInfoResponse.ProtoReflect.Descriptor instead. func (*GetGatewayInfoResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{9} + return file_openshell_proto_rawDescGZIP(), []int{11} } func (x *GetGatewayInfoResponse) GetStatus() ServiceStatus { @@ -1046,7 +1170,7 @@ type ComputeDriverInfo struct { func (x *ComputeDriverInfo) Reset() { *x = ComputeDriverInfo{} - mi := &file_openshell_proto_msgTypes[10] + mi := &file_openshell_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1058,7 +1182,7 @@ func (x *ComputeDriverInfo) String() string { func (*ComputeDriverInfo) ProtoMessage() {} func (x *ComputeDriverInfo) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[10] + mi := &file_openshell_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1071,7 +1195,7 @@ func (x *ComputeDriverInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ComputeDriverInfo.ProtoReflect.Descriptor instead. func (*ComputeDriverInfo) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{10} + return file_openshell_proto_rawDescGZIP(), []int{12} } func (x *ComputeDriverInfo) GetName() string { @@ -1103,7 +1227,7 @@ type ComputeDriverCapabilities struct { func (x *ComputeDriverCapabilities) Reset() { *x = ComputeDriverCapabilities{} - mi := &file_openshell_proto_msgTypes[11] + mi := &file_openshell_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1115,7 +1239,7 @@ func (x *ComputeDriverCapabilities) String() string { func (*ComputeDriverCapabilities) ProtoMessage() {} func (x *ComputeDriverCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[11] + mi := &file_openshell_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1128,7 +1252,7 @@ func (x *ComputeDriverCapabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use ComputeDriverCapabilities.ProtoReflect.Descriptor instead. func (*ComputeDriverCapabilities) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{11} + return file_openshell_proto_rawDescGZIP(), []int{13} } func (x *ComputeDriverCapabilities) GetDriverName() string { @@ -1165,7 +1289,7 @@ type ResourceCapabilities struct { func (x *ResourceCapabilities) Reset() { *x = ResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[12] + mi := &file_openshell_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1177,7 +1301,7 @@ func (x *ResourceCapabilities) String() string { func (*ResourceCapabilities) ProtoMessage() {} func (x *ResourceCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[12] + mi := &file_openshell_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1190,7 +1314,7 @@ func (x *ResourceCapabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceCapabilities.ProtoReflect.Descriptor instead. func (*ResourceCapabilities) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{12} + return file_openshell_proto_rawDescGZIP(), []int{14} } func (x *ResourceCapabilities) GetCpu() *CpuResourceCapabilities { @@ -1224,7 +1348,7 @@ type CpuResourceCapabilities struct { func (x *CpuResourceCapabilities) Reset() { *x = CpuResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[13] + mi := &file_openshell_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1236,7 +1360,7 @@ func (x *CpuResourceCapabilities) String() string { func (*CpuResourceCapabilities) ProtoMessage() {} func (x *CpuResourceCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[13] + mi := &file_openshell_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1249,7 +1373,7 @@ func (x *CpuResourceCapabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use CpuResourceCapabilities.ProtoReflect.Descriptor instead. func (*CpuResourceCapabilities) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{13} + return file_openshell_proto_rawDescGZIP(), []int{15} } func (x *CpuResourceCapabilities) GetLimitSupported() bool { @@ -1269,7 +1393,7 @@ type MemoryResourceCapabilities struct { func (x *MemoryResourceCapabilities) Reset() { *x = MemoryResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[14] + mi := &file_openshell_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1281,7 +1405,7 @@ func (x *MemoryResourceCapabilities) String() string { func (*MemoryResourceCapabilities) ProtoMessage() {} func (x *MemoryResourceCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[14] + mi := &file_openshell_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1294,7 +1418,7 @@ func (x *MemoryResourceCapabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryResourceCapabilities.ProtoReflect.Descriptor instead. func (*MemoryResourceCapabilities) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{14} + return file_openshell_proto_rawDescGZIP(), []int{16} } func (x *MemoryResourceCapabilities) GetLimitSupported() bool { @@ -1316,7 +1440,7 @@ type GpuResourceCapabilities struct { func (x *GpuResourceCapabilities) Reset() { *x = GpuResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[15] + mi := &file_openshell_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1328,7 +1452,7 @@ func (x *GpuResourceCapabilities) String() string { func (*GpuResourceCapabilities) ProtoMessage() {} func (x *GpuResourceCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[15] + mi := &file_openshell_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1341,7 +1465,7 @@ func (x *GpuResourceCapabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use GpuResourceCapabilities.ProtoReflect.Descriptor instead. func (*GpuResourceCapabilities) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{15} + return file_openshell_proto_rawDescGZIP(), []int{17} } func (x *GpuResourceCapabilities) GetDefaultSelectionSupported() bool { @@ -1382,7 +1506,7 @@ type Sandbox struct { func (x *Sandbox) Reset() { *x = Sandbox{} - mi := &file_openshell_proto_msgTypes[16] + mi := &file_openshell_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1394,7 +1518,7 @@ func (x *Sandbox) String() string { func (*Sandbox) ProtoMessage() {} func (x *Sandbox) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[16] + mi := &file_openshell_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1407,7 +1531,7 @@ func (x *Sandbox) ProtoReflect() protoreflect.Message { // Deprecated: Use Sandbox.ProtoReflect.Descriptor instead. func (*Sandbox) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{16} + return file_openshell_proto_rawDescGZIP(), []int{18} } func (x *Sandbox) GetMetadata() *datamodelv1.ObjectMeta { @@ -1466,7 +1590,7 @@ type SandboxSpec struct { func (x *SandboxSpec) Reset() { *x = SandboxSpec{} - mi := &file_openshell_proto_msgTypes[17] + mi := &file_openshell_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1478,7 +1602,7 @@ func (x *SandboxSpec) String() string { func (*SandboxSpec) ProtoMessage() {} func (x *SandboxSpec) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[17] + mi := &file_openshell_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1491,7 +1615,7 @@ func (x *SandboxSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxSpec.ProtoReflect.Descriptor instead. func (*SandboxSpec) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{17} + return file_openshell_proto_rawDescGZIP(), []int{19} } func (x *SandboxSpec) GetLogLevel() string { @@ -1560,7 +1684,7 @@ type ResourceRequirements struct { func (x *ResourceRequirements) Reset() { *x = ResourceRequirements{} - mi := &file_openshell_proto_msgTypes[18] + mi := &file_openshell_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1572,7 +1696,7 @@ func (x *ResourceRequirements) String() string { func (*ResourceRequirements) ProtoMessage() {} func (x *ResourceRequirements) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[18] + mi := &file_openshell_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1585,7 +1709,7 @@ func (x *ResourceRequirements) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceRequirements.ProtoReflect.Descriptor instead. func (*ResourceRequirements) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{18} + return file_openshell_proto_rawDescGZIP(), []int{20} } func (x *ResourceRequirements) GetGpu() *GpuResourceRequirements { @@ -1607,7 +1731,7 @@ type GpuResourceRequirements struct { func (x *GpuResourceRequirements) Reset() { *x = GpuResourceRequirements{} - mi := &file_openshell_proto_msgTypes[19] + mi := &file_openshell_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1619,7 +1743,7 @@ func (x *GpuResourceRequirements) String() string { func (*GpuResourceRequirements) ProtoMessage() {} func (x *GpuResourceRequirements) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[19] + mi := &file_openshell_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1632,7 +1756,7 @@ func (x *GpuResourceRequirements) ProtoReflect() protoreflect.Message { // Deprecated: Use GpuResourceRequirements.ProtoReflect.Descriptor instead. func (*GpuResourceRequirements) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{19} + return file_openshell_proto_rawDescGZIP(), []int{21} } func (x *GpuResourceRequirements) GetCount() uint32 { @@ -1681,7 +1805,7 @@ type SandboxTemplate struct { func (x *SandboxTemplate) Reset() { *x = SandboxTemplate{} - mi := &file_openshell_proto_msgTypes[20] + mi := &file_openshell_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1693,7 +1817,7 @@ func (x *SandboxTemplate) String() string { func (*SandboxTemplate) ProtoMessage() {} func (x *SandboxTemplate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[20] + mi := &file_openshell_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1706,7 +1830,7 @@ func (x *SandboxTemplate) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxTemplate.ProtoReflect.Descriptor instead. func (*SandboxTemplate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{20} + return file_openshell_proto_rawDescGZIP(), []int{22} } func (x *SandboxTemplate) GetImage() string { @@ -1790,7 +1914,7 @@ type SandboxWorkloadTemplate struct { func (x *SandboxWorkloadTemplate) Reset() { *x = SandboxWorkloadTemplate{} - mi := &file_openshell_proto_msgTypes[21] + mi := &file_openshell_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1802,7 +1926,7 @@ func (x *SandboxWorkloadTemplate) String() string { func (*SandboxWorkloadTemplate) ProtoMessage() {} func (x *SandboxWorkloadTemplate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[21] + mi := &file_openshell_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1815,7 +1939,7 @@ func (x *SandboxWorkloadTemplate) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxWorkloadTemplate.ProtoReflect.Descriptor instead. func (*SandboxWorkloadTemplate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{21} + return file_openshell_proto_rawDescGZIP(), []int{23} } func (x *SandboxWorkloadTemplate) GetMetadata() *datamodelv1.ObjectMeta { @@ -1846,7 +1970,7 @@ type SandboxWorkloadTemplateSpec struct { func (x *SandboxWorkloadTemplateSpec) Reset() { *x = SandboxWorkloadTemplateSpec{} - mi := &file_openshell_proto_msgTypes[22] + mi := &file_openshell_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1858,7 +1982,7 @@ func (x *SandboxWorkloadTemplateSpec) String() string { func (*SandboxWorkloadTemplateSpec) ProtoMessage() {} func (x *SandboxWorkloadTemplateSpec) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[22] + mi := &file_openshell_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1871,7 +1995,7 @@ func (x *SandboxWorkloadTemplateSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxWorkloadTemplateSpec.ProtoReflect.Descriptor instead. func (*SandboxWorkloadTemplateSpec) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{22} + return file_openshell_proto_rawDescGZIP(), []int{24} } func (x *SandboxWorkloadTemplateSpec) GetWorkload() *SandboxWorkloadConfig { @@ -1909,7 +2033,7 @@ type SandboxWorkloadConfig struct { func (x *SandboxWorkloadConfig) Reset() { *x = SandboxWorkloadConfig{} - mi := &file_openshell_proto_msgTypes[23] + mi := &file_openshell_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1921,7 +2045,7 @@ func (x *SandboxWorkloadConfig) String() string { func (*SandboxWorkloadConfig) ProtoMessage() {} func (x *SandboxWorkloadConfig) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[23] + mi := &file_openshell_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1934,7 +2058,7 @@ func (x *SandboxWorkloadConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxWorkloadConfig.ProtoReflect.Descriptor instead. func (*SandboxWorkloadConfig) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{23} + return file_openshell_proto_rawDescGZIP(), []int{25} } func (x *SandboxWorkloadConfig) GetImage() string { @@ -1974,7 +2098,7 @@ type SandboxResources struct { func (x *SandboxResources) Reset() { *x = SandboxResources{} - mi := &file_openshell_proto_msgTypes[24] + mi := &file_openshell_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1986,7 +2110,7 @@ func (x *SandboxResources) String() string { func (*SandboxResources) ProtoMessage() {} func (x *SandboxResources) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[24] + mi := &file_openshell_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1999,7 +2123,7 @@ func (x *SandboxResources) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxResources.ProtoReflect.Descriptor instead. func (*SandboxResources) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{24} + return file_openshell_proto_rawDescGZIP(), []int{26} } func (x *SandboxResources) GetCpu() string { @@ -2032,7 +2156,7 @@ type SandboxServiceLevel struct { func (x *SandboxServiceLevel) Reset() { *x = SandboxServiceLevel{} - mi := &file_openshell_proto_msgTypes[25] + mi := &file_openshell_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2044,7 +2168,7 @@ func (x *SandboxServiceLevel) String() string { func (*SandboxServiceLevel) ProtoMessage() {} func (x *SandboxServiceLevel) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[25] + mi := &file_openshell_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2057,7 +2181,7 @@ func (x *SandboxServiceLevel) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxServiceLevel.ProtoReflect.Descriptor instead. func (*SandboxServiceLevel) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{25} + return file_openshell_proto_rawDescGZIP(), []int{27} } func (x *SandboxServiceLevel) GetStartup() *SandboxStartup { @@ -2077,7 +2201,7 @@ type SandboxStartup struct { func (x *SandboxStartup) Reset() { *x = SandboxStartup{} - mi := &file_openshell_proto_msgTypes[26] + mi := &file_openshell_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2089,7 +2213,7 @@ func (x *SandboxStartup) String() string { func (*SandboxStartup) ProtoMessage() {} func (x *SandboxStartup) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[26] + mi := &file_openshell_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2102,7 +2226,7 @@ func (x *SandboxStartup) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStartup.ProtoReflect.Descriptor instead. func (*SandboxStartup) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{26} + return file_openshell_proto_rawDescGZIP(), []int{28} } func (x *SandboxStartup) GetReadyWithin() *durationpb.Duration { @@ -2129,7 +2253,7 @@ type SandboxWorkloadTemplateProvenance struct { func (x *SandboxWorkloadTemplateProvenance) Reset() { *x = SandboxWorkloadTemplateProvenance{} - mi := &file_openshell_proto_msgTypes[27] + mi := &file_openshell_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2141,7 +2265,7 @@ func (x *SandboxWorkloadTemplateProvenance) String() string { func (*SandboxWorkloadTemplateProvenance) ProtoMessage() {} func (x *SandboxWorkloadTemplateProvenance) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[27] + mi := &file_openshell_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2154,7 +2278,7 @@ func (x *SandboxWorkloadTemplateProvenance) ProtoReflect() protoreflect.Message // Deprecated: Use SandboxWorkloadTemplateProvenance.ProtoReflect.Descriptor instead. func (*SandboxWorkloadTemplateProvenance) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{27} + return file_openshell_proto_rawDescGZIP(), []int{29} } func (x *SandboxWorkloadTemplateProvenance) GetName() string { @@ -2203,7 +2327,7 @@ type SandboxStatus struct { func (x *SandboxStatus) Reset() { *x = SandboxStatus{} - mi := &file_openshell_proto_msgTypes[28] + mi := &file_openshell_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2215,7 +2339,7 @@ func (x *SandboxStatus) String() string { func (*SandboxStatus) ProtoMessage() {} func (x *SandboxStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[28] + mi := &file_openshell_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2228,7 +2352,7 @@ func (x *SandboxStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStatus.ProtoReflect.Descriptor instead. func (*SandboxStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{28} + return file_openshell_proto_rawDescGZIP(), []int{30} } func (x *SandboxStatus) GetSandboxName() string { @@ -2313,7 +2437,7 @@ type SandboxCondition struct { func (x *SandboxCondition) Reset() { *x = SandboxCondition{} - mi := &file_openshell_proto_msgTypes[29] + mi := &file_openshell_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2325,7 +2449,7 @@ func (x *SandboxCondition) String() string { func (*SandboxCondition) ProtoMessage() {} func (x *SandboxCondition) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[29] + mi := &file_openshell_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2338,7 +2462,7 @@ func (x *SandboxCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxCondition.ProtoReflect.Descriptor instead. func (*SandboxCondition) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{29} + return file_openshell_proto_rawDescGZIP(), []int{31} } func (x *SandboxCondition) GetType() string { @@ -2397,7 +2521,7 @@ type PlatformEvent struct { func (x *PlatformEvent) Reset() { *x = PlatformEvent{} - mi := &file_openshell_proto_msgTypes[30] + mi := &file_openshell_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2409,7 +2533,7 @@ func (x *PlatformEvent) String() string { func (*PlatformEvent) ProtoMessage() {} func (x *PlatformEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[30] + mi := &file_openshell_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2422,7 +2546,7 @@ func (x *PlatformEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use PlatformEvent.ProtoReflect.Descriptor instead. func (*PlatformEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{30} + return file_openshell_proto_rawDescGZIP(), []int{32} } func (x *PlatformEvent) GetTimestampMs() int64 { @@ -2491,7 +2615,7 @@ type CreateSandboxRequest struct { func (x *CreateSandboxRequest) Reset() { *x = CreateSandboxRequest{} - mi := &file_openshell_proto_msgTypes[31] + mi := &file_openshell_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2503,7 +2627,7 @@ func (x *CreateSandboxRequest) String() string { func (*CreateSandboxRequest) ProtoMessage() {} func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[31] + mi := &file_openshell_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2516,7 +2640,7 @@ func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSandboxRequest.ProtoReflect.Descriptor instead. func (*CreateSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{31} + return file_openshell_proto_rawDescGZIP(), []int{33} } func (x *CreateSandboxRequest) GetSpec() *SandboxSpec { @@ -2579,7 +2703,7 @@ type CreateSandboxTemplateRequest struct { func (x *CreateSandboxTemplateRequest) Reset() { *x = CreateSandboxTemplateRequest{} - mi := &file_openshell_proto_msgTypes[32] + mi := &file_openshell_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2591,7 +2715,7 @@ func (x *CreateSandboxTemplateRequest) String() string { func (*CreateSandboxTemplateRequest) ProtoMessage() {} func (x *CreateSandboxTemplateRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[32] + mi := &file_openshell_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2604,7 +2728,7 @@ func (x *CreateSandboxTemplateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSandboxTemplateRequest.ProtoReflect.Descriptor instead. func (*CreateSandboxTemplateRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{32} + return file_openshell_proto_rawDescGZIP(), []int{34} } func (x *CreateSandboxTemplateRequest) GetTemplate() *SandboxWorkloadTemplate { @@ -2632,7 +2756,7 @@ type GetSandboxTemplateRequest struct { func (x *GetSandboxTemplateRequest) Reset() { *x = GetSandboxTemplateRequest{} - mi := &file_openshell_proto_msgTypes[33] + mi := &file_openshell_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2644,7 +2768,7 @@ func (x *GetSandboxTemplateRequest) String() string { func (*GetSandboxTemplateRequest) ProtoMessage() {} func (x *GetSandboxTemplateRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[33] + mi := &file_openshell_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2657,7 +2781,7 @@ func (x *GetSandboxTemplateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxTemplateRequest.ProtoReflect.Descriptor instead. func (*GetSandboxTemplateRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{33} + return file_openshell_proto_rawDescGZIP(), []int{35} } func (x *GetSandboxTemplateRequest) GetName() string { @@ -2690,7 +2814,7 @@ type ListSandboxTemplatesRequest struct { func (x *ListSandboxTemplatesRequest) Reset() { *x = ListSandboxTemplatesRequest{} - mi := &file_openshell_proto_msgTypes[34] + mi := &file_openshell_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2702,7 +2826,7 @@ func (x *ListSandboxTemplatesRequest) String() string { func (*ListSandboxTemplatesRequest) ProtoMessage() {} func (x *ListSandboxTemplatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[34] + mi := &file_openshell_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2715,7 +2839,7 @@ func (x *ListSandboxTemplatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxTemplatesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxTemplatesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{34} + return file_openshell_proto_rawDescGZIP(), []int{36} } func (x *ListSandboxTemplatesRequest) GetLimit() uint32 { @@ -2764,7 +2888,7 @@ type DeleteSandboxTemplateRequest struct { func (x *DeleteSandboxTemplateRequest) Reset() { *x = DeleteSandboxTemplateRequest{} - mi := &file_openshell_proto_msgTypes[35] + mi := &file_openshell_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2776,7 +2900,7 @@ func (x *DeleteSandboxTemplateRequest) String() string { func (*DeleteSandboxTemplateRequest) ProtoMessage() {} func (x *DeleteSandboxTemplateRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[35] + mi := &file_openshell_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2789,7 +2913,7 @@ func (x *DeleteSandboxTemplateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxTemplateRequest.ProtoReflect.Descriptor instead. func (*DeleteSandboxTemplateRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{35} + return file_openshell_proto_rawDescGZIP(), []int{37} } func (x *DeleteSandboxTemplateRequest) GetName() string { @@ -2815,7 +2939,7 @@ type SandboxTemplateResponse struct { func (x *SandboxTemplateResponse) Reset() { *x = SandboxTemplateResponse{} - mi := &file_openshell_proto_msgTypes[36] + mi := &file_openshell_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2827,7 +2951,7 @@ func (x *SandboxTemplateResponse) String() string { func (*SandboxTemplateResponse) ProtoMessage() {} func (x *SandboxTemplateResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[36] + mi := &file_openshell_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2840,7 +2964,7 @@ func (x *SandboxTemplateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxTemplateResponse.ProtoReflect.Descriptor instead. func (*SandboxTemplateResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{36} + return file_openshell_proto_rawDescGZIP(), []int{38} } func (x *SandboxTemplateResponse) GetTemplate() *SandboxWorkloadTemplate { @@ -2859,7 +2983,7 @@ type ListSandboxTemplatesResponse struct { func (x *ListSandboxTemplatesResponse) Reset() { *x = ListSandboxTemplatesResponse{} - mi := &file_openshell_proto_msgTypes[37] + mi := &file_openshell_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2871,7 +2995,7 @@ func (x *ListSandboxTemplatesResponse) String() string { func (*ListSandboxTemplatesResponse) ProtoMessage() {} func (x *ListSandboxTemplatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[37] + mi := &file_openshell_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2884,7 +3008,7 @@ func (x *ListSandboxTemplatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxTemplatesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxTemplatesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{37} + return file_openshell_proto_rawDescGZIP(), []int{39} } func (x *ListSandboxTemplatesResponse) GetTemplates() []*SandboxWorkloadTemplate { @@ -2903,7 +3027,7 @@ type DeleteSandboxTemplateResponse struct { func (x *DeleteSandboxTemplateResponse) Reset() { *x = DeleteSandboxTemplateResponse{} - mi := &file_openshell_proto_msgTypes[38] + mi := &file_openshell_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2915,7 +3039,7 @@ func (x *DeleteSandboxTemplateResponse) String() string { func (*DeleteSandboxTemplateResponse) ProtoMessage() {} func (x *DeleteSandboxTemplateResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[38] + mi := &file_openshell_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2928,7 +3052,7 @@ func (x *DeleteSandboxTemplateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxTemplateResponse.ProtoReflect.Descriptor instead. func (*DeleteSandboxTemplateResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{38} + return file_openshell_proto_rawDescGZIP(), []int{40} } func (x *DeleteSandboxTemplateResponse) GetDeleted() bool { @@ -2956,7 +3080,7 @@ type BeginRootfsTarStagingRequest struct { func (x *BeginRootfsTarStagingRequest) Reset() { *x = BeginRootfsTarStagingRequest{} - mi := &file_openshell_proto_msgTypes[39] + mi := &file_openshell_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2968,7 +3092,7 @@ func (x *BeginRootfsTarStagingRequest) String() string { func (*BeginRootfsTarStagingRequest) ProtoMessage() {} func (x *BeginRootfsTarStagingRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[39] + mi := &file_openshell_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2981,7 +3105,7 @@ func (x *BeginRootfsTarStagingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginRootfsTarStagingRequest.ProtoReflect.Descriptor instead. func (*BeginRootfsTarStagingRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{39} + return file_openshell_proto_rawDescGZIP(), []int{41} } func (x *BeginRootfsTarStagingRequest) GetWorkspace() string { @@ -3024,7 +3148,7 @@ type BeginRootfsTarStagingResponse struct { func (x *BeginRootfsTarStagingResponse) Reset() { *x = BeginRootfsTarStagingResponse{} - mi := &file_openshell_proto_msgTypes[40] + mi := &file_openshell_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3036,7 +3160,7 @@ func (x *BeginRootfsTarStagingResponse) String() string { func (*BeginRootfsTarStagingResponse) ProtoMessage() {} func (x *BeginRootfsTarStagingResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[40] + mi := &file_openshell_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3049,7 +3173,7 @@ func (x *BeginRootfsTarStagingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginRootfsTarStagingResponse.ProtoReflect.Descriptor instead. func (*BeginRootfsTarStagingResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{40} + return file_openshell_proto_rawDescGZIP(), []int{42} } func (x *BeginRootfsTarStagingResponse) GetStagingToken() string { @@ -3093,7 +3217,7 @@ type GetSandboxRequest struct { func (x *GetSandboxRequest) Reset() { *x = GetSandboxRequest{} - mi := &file_openshell_proto_msgTypes[41] + mi := &file_openshell_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3105,7 +3229,7 @@ func (x *GetSandboxRequest) String() string { func (*GetSandboxRequest) ProtoMessage() {} func (x *GetSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[41] + mi := &file_openshell_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3118,7 +3242,7 @@ func (x *GetSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxRequest.ProtoReflect.Descriptor instead. func (*GetSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{41} + return file_openshell_proto_rawDescGZIP(), []int{43} } func (x *GetSandboxRequest) GetName() string { @@ -3152,7 +3276,7 @@ type ListSandboxesRequest struct { func (x *ListSandboxesRequest) Reset() { *x = ListSandboxesRequest{} - mi := &file_openshell_proto_msgTypes[42] + mi := &file_openshell_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3164,7 +3288,7 @@ func (x *ListSandboxesRequest) String() string { func (*ListSandboxesRequest) ProtoMessage() {} func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[42] + mi := &file_openshell_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3177,7 +3301,7 @@ func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{42} + return file_openshell_proto_rawDescGZIP(), []int{44} } func (x *ListSandboxesRequest) GetLimit() uint32 { @@ -3228,7 +3352,7 @@ type ListSandboxProvidersRequest struct { func (x *ListSandboxProvidersRequest) Reset() { *x = ListSandboxProvidersRequest{} - mi := &file_openshell_proto_msgTypes[43] + mi := &file_openshell_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3240,7 +3364,7 @@ func (x *ListSandboxProvidersRequest) String() string { func (*ListSandboxProvidersRequest) ProtoMessage() {} func (x *ListSandboxProvidersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[43] + mi := &file_openshell_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3253,7 +3377,7 @@ func (x *ListSandboxProvidersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxProvidersRequest.ProtoReflect.Descriptor instead. func (*ListSandboxProvidersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{43} + return file_openshell_proto_rawDescGZIP(), []int{45} } func (x *ListSandboxProvidersRequest) GetSandboxName() string { @@ -3290,7 +3414,7 @@ type AttachSandboxProviderRequest struct { func (x *AttachSandboxProviderRequest) Reset() { *x = AttachSandboxProviderRequest{} - mi := &file_openshell_proto_msgTypes[44] + mi := &file_openshell_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3302,7 +3426,7 @@ func (x *AttachSandboxProviderRequest) String() string { func (*AttachSandboxProviderRequest) ProtoMessage() {} func (x *AttachSandboxProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[44] + mi := &file_openshell_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3315,7 +3439,7 @@ func (x *AttachSandboxProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachSandboxProviderRequest.ProtoReflect.Descriptor instead. func (*AttachSandboxProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{44} + return file_openshell_proto_rawDescGZIP(), []int{46} } func (x *AttachSandboxProviderRequest) GetSandboxName() string { @@ -3366,7 +3490,7 @@ type DetachSandboxProviderRequest struct { func (x *DetachSandboxProviderRequest) Reset() { *x = DetachSandboxProviderRequest{} - mi := &file_openshell_proto_msgTypes[45] + mi := &file_openshell_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3378,7 +3502,7 @@ func (x *DetachSandboxProviderRequest) String() string { func (*DetachSandboxProviderRequest) ProtoMessage() {} func (x *DetachSandboxProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[45] + mi := &file_openshell_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3391,7 +3515,7 @@ func (x *DetachSandboxProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachSandboxProviderRequest.ProtoReflect.Descriptor instead. func (*DetachSandboxProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{45} + return file_openshell_proto_rawDescGZIP(), []int{47} } func (x *DetachSandboxProviderRequest) GetSandboxName() string { @@ -3435,7 +3559,7 @@ type DeleteSandboxRequest struct { func (x *DeleteSandboxRequest) Reset() { *x = DeleteSandboxRequest{} - mi := &file_openshell_proto_msgTypes[46] + mi := &file_openshell_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3447,7 +3571,7 @@ func (x *DeleteSandboxRequest) String() string { func (*DeleteSandboxRequest) ProtoMessage() {} func (x *DeleteSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[46] + mi := &file_openshell_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3460,7 +3584,7 @@ func (x *DeleteSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxRequest.ProtoReflect.Descriptor instead. func (*DeleteSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{46} + return file_openshell_proto_rawDescGZIP(), []int{48} } func (x *DeleteSandboxRequest) GetName() string { @@ -3490,7 +3614,7 @@ type StopSandboxRequest struct { func (x *StopSandboxRequest) Reset() { *x = StopSandboxRequest{} - mi := &file_openshell_proto_msgTypes[47] + mi := &file_openshell_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3502,7 +3626,7 @@ func (x *StopSandboxRequest) String() string { func (*StopSandboxRequest) ProtoMessage() {} func (x *StopSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[47] + mi := &file_openshell_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3515,7 +3639,7 @@ func (x *StopSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopSandboxRequest.ProtoReflect.Descriptor instead. func (*StopSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{47} + return file_openshell_proto_rawDescGZIP(), []int{49} } func (x *StopSandboxRequest) GetName() string { @@ -3545,7 +3669,7 @@ type StartSandboxRequest struct { func (x *StartSandboxRequest) Reset() { *x = StartSandboxRequest{} - mi := &file_openshell_proto_msgTypes[48] + mi := &file_openshell_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3557,7 +3681,7 @@ func (x *StartSandboxRequest) String() string { func (*StartSandboxRequest) ProtoMessage() {} func (x *StartSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[48] + mi := &file_openshell_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3570,7 +3694,7 @@ func (x *StartSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartSandboxRequest.ProtoReflect.Descriptor instead. func (*StartSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{48} + return file_openshell_proto_rawDescGZIP(), []int{50} } func (x *StartSandboxRequest) GetName() string { @@ -3597,7 +3721,7 @@ type SandboxResponse struct { func (x *SandboxResponse) Reset() { *x = SandboxResponse{} - mi := &file_openshell_proto_msgTypes[49] + mi := &file_openshell_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3609,7 +3733,7 @@ func (x *SandboxResponse) String() string { func (*SandboxResponse) ProtoMessage() {} func (x *SandboxResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[49] + mi := &file_openshell_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3622,7 +3746,7 @@ func (x *SandboxResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxResponse.ProtoReflect.Descriptor instead. func (*SandboxResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{49} + return file_openshell_proto_rawDescGZIP(), []int{51} } func (x *SandboxResponse) GetSandbox() *Sandbox { @@ -3642,7 +3766,7 @@ type ListSandboxesResponse struct { func (x *ListSandboxesResponse) Reset() { *x = ListSandboxesResponse{} - mi := &file_openshell_proto_msgTypes[50] + mi := &file_openshell_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3654,7 +3778,7 @@ func (x *ListSandboxesResponse) String() string { func (*ListSandboxesResponse) ProtoMessage() {} func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[50] + mi := &file_openshell_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3667,7 +3791,7 @@ func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{50} + return file_openshell_proto_rawDescGZIP(), []int{52} } func (x *ListSandboxesResponse) GetSandboxes() []*Sandbox { @@ -3687,7 +3811,7 @@ type ListSandboxProvidersResponse struct { func (x *ListSandboxProvidersResponse) Reset() { *x = ListSandboxProvidersResponse{} - mi := &file_openshell_proto_msgTypes[51] + mi := &file_openshell_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3699,7 +3823,7 @@ func (x *ListSandboxProvidersResponse) String() string { func (*ListSandboxProvidersResponse) ProtoMessage() {} func (x *ListSandboxProvidersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[51] + mi := &file_openshell_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3712,7 +3836,7 @@ func (x *ListSandboxProvidersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxProvidersResponse.ProtoReflect.Descriptor instead. func (*ListSandboxProvidersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{51} + return file_openshell_proto_rawDescGZIP(), []int{53} } func (x *ListSandboxProvidersResponse) GetProviders() []*datamodelv1.Provider { @@ -3734,7 +3858,7 @@ type AttachSandboxProviderResponse struct { func (x *AttachSandboxProviderResponse) Reset() { *x = AttachSandboxProviderResponse{} - mi := &file_openshell_proto_msgTypes[52] + mi := &file_openshell_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3746,7 +3870,7 @@ func (x *AttachSandboxProviderResponse) String() string { func (*AttachSandboxProviderResponse) ProtoMessage() {} func (x *AttachSandboxProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[52] + mi := &file_openshell_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3759,7 +3883,7 @@ func (x *AttachSandboxProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachSandboxProviderResponse.ProtoReflect.Descriptor instead. func (*AttachSandboxProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{52} + return file_openshell_proto_rawDescGZIP(), []int{54} } func (x *AttachSandboxProviderResponse) GetSandbox() *Sandbox { @@ -3788,7 +3912,7 @@ type DetachSandboxProviderResponse struct { func (x *DetachSandboxProviderResponse) Reset() { *x = DetachSandboxProviderResponse{} - mi := &file_openshell_proto_msgTypes[53] + mi := &file_openshell_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3800,7 +3924,7 @@ func (x *DetachSandboxProviderResponse) String() string { func (*DetachSandboxProviderResponse) ProtoMessage() {} func (x *DetachSandboxProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[53] + mi := &file_openshell_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3813,7 +3937,7 @@ func (x *DetachSandboxProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetachSandboxProviderResponse.ProtoReflect.Descriptor instead. func (*DetachSandboxProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{53} + return file_openshell_proto_rawDescGZIP(), []int{55} } func (x *DetachSandboxProviderResponse) GetSandbox() *Sandbox { @@ -3840,7 +3964,7 @@ type DeleteSandboxResponse struct { func (x *DeleteSandboxResponse) Reset() { *x = DeleteSandboxResponse{} - mi := &file_openshell_proto_msgTypes[54] + mi := &file_openshell_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3852,7 +3976,7 @@ func (x *DeleteSandboxResponse) String() string { func (*DeleteSandboxResponse) ProtoMessage() {} func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[54] + mi := &file_openshell_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3865,7 +3989,7 @@ func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxResponse.ProtoReflect.Descriptor instead. func (*DeleteSandboxResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{54} + return file_openshell_proto_rawDescGZIP(), []int{56} } func (x *DeleteSandboxResponse) GetDeleted() bool { @@ -3886,7 +4010,7 @@ type CreateSshSessionRequest struct { func (x *CreateSshSessionRequest) Reset() { *x = CreateSshSessionRequest{} - mi := &file_openshell_proto_msgTypes[55] + mi := &file_openshell_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3898,7 +4022,7 @@ func (x *CreateSshSessionRequest) String() string { func (*CreateSshSessionRequest) ProtoMessage() {} func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[55] + mi := &file_openshell_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3911,7 +4035,7 @@ func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSshSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSshSessionRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{55} + return file_openshell_proto_rawDescGZIP(), []int{57} } func (x *CreateSshSessionRequest) GetSandboxId() string { @@ -3954,7 +4078,7 @@ type CreateSshSessionResponse struct { func (x *CreateSshSessionResponse) Reset() { *x = CreateSshSessionResponse{} - mi := &file_openshell_proto_msgTypes[56] + mi := &file_openshell_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3966,7 +4090,7 @@ func (x *CreateSshSessionResponse) String() string { func (*CreateSshSessionResponse) ProtoMessage() {} func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[56] + mi := &file_openshell_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3979,7 +4103,7 @@ func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSshSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSshSessionResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{56} + return file_openshell_proto_rawDescGZIP(), []int{58} } func (x *CreateSshSessionResponse) GetSandboxId() string { @@ -4050,7 +4174,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_openshell_proto_msgTypes[57] + mi := &file_openshell_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4062,7 +4186,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[57] + mi := &file_openshell_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4075,7 +4199,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{57} + return file_openshell_proto_rawDescGZIP(), []int{59} } func (x *ExposeServiceRequest) GetSandbox() string { @@ -4128,7 +4252,7 @@ type GetServiceRequest struct { func (x *GetServiceRequest) Reset() { *x = GetServiceRequest{} - mi := &file_openshell_proto_msgTypes[58] + mi := &file_openshell_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4140,7 +4264,7 @@ func (x *GetServiceRequest) String() string { func (*GetServiceRequest) ProtoMessage() {} func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[58] + mi := &file_openshell_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4153,7 +4277,7 @@ func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetServiceRequest.ProtoReflect.Descriptor instead. func (*GetServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{58} + return file_openshell_proto_rawDescGZIP(), []int{60} } func (x *GetServiceRequest) GetSandbox() string { @@ -4196,7 +4320,7 @@ type ListServicesRequest struct { func (x *ListServicesRequest) Reset() { *x = ListServicesRequest{} - mi := &file_openshell_proto_msgTypes[59] + mi := &file_openshell_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4208,7 +4332,7 @@ func (x *ListServicesRequest) String() string { func (*ListServicesRequest) ProtoMessage() {} func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[59] + mi := &file_openshell_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4221,7 +4345,7 @@ func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServicesRequest.ProtoReflect.Descriptor instead. func (*ListServicesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{59} + return file_openshell_proto_rawDescGZIP(), []int{61} } func (x *ListServicesRequest) GetSandbox() string { @@ -4269,7 +4393,7 @@ type ListServicesResponse struct { func (x *ListServicesResponse) Reset() { *x = ListServicesResponse{} - mi := &file_openshell_proto_msgTypes[60] + mi := &file_openshell_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4281,7 +4405,7 @@ func (x *ListServicesResponse) String() string { func (*ListServicesResponse) ProtoMessage() {} func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[60] + mi := &file_openshell_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4294,7 +4418,7 @@ func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServicesResponse.ProtoReflect.Descriptor instead. func (*ListServicesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{60} + return file_openshell_proto_rawDescGZIP(), []int{62} } func (x *ListServicesResponse) GetServices() []*ServiceEndpointResponse { @@ -4319,7 +4443,7 @@ type DeleteServiceRequest struct { func (x *DeleteServiceRequest) Reset() { *x = DeleteServiceRequest{} - mi := &file_openshell_proto_msgTypes[61] + mi := &file_openshell_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4331,7 +4455,7 @@ func (x *DeleteServiceRequest) String() string { func (*DeleteServiceRequest) ProtoMessage() {} func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[61] + mi := &file_openshell_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4344,7 +4468,7 @@ func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceRequest.ProtoReflect.Descriptor instead. func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{61} + return file_openshell_proto_rawDescGZIP(), []int{63} } func (x *DeleteServiceRequest) GetSandbox() string { @@ -4379,7 +4503,7 @@ type DeleteServiceResponse struct { func (x *DeleteServiceResponse) Reset() { *x = DeleteServiceResponse{} - mi := &file_openshell_proto_msgTypes[62] + mi := &file_openshell_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4391,7 +4515,7 @@ func (x *DeleteServiceResponse) String() string { func (*DeleteServiceResponse) ProtoMessage() {} func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[62] + mi := &file_openshell_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4404,7 +4528,7 @@ func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceResponse.ProtoReflect.Descriptor instead. func (*DeleteServiceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{62} + return file_openshell_proto_rawDescGZIP(), []int{64} } func (x *DeleteServiceResponse) GetDeleted() bool { @@ -4435,7 +4559,7 @@ type ServiceEndpoint struct { func (x *ServiceEndpoint) Reset() { *x = ServiceEndpoint{} - mi := &file_openshell_proto_msgTypes[63] + mi := &file_openshell_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4447,7 +4571,7 @@ func (x *ServiceEndpoint) String() string { func (*ServiceEndpoint) ProtoMessage() {} func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[63] + mi := &file_openshell_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4460,7 +4584,7 @@ func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceEndpoint.ProtoReflect.Descriptor instead. func (*ServiceEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{63} + return file_openshell_proto_rawDescGZIP(), []int{65} } func (x *ServiceEndpoint) GetMetadata() *datamodelv1.ObjectMeta { @@ -4516,7 +4640,7 @@ type ServiceEndpointResponse struct { func (x *ServiceEndpointResponse) Reset() { *x = ServiceEndpointResponse{} - mi := &file_openshell_proto_msgTypes[64] + mi := &file_openshell_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4528,7 +4652,7 @@ func (x *ServiceEndpointResponse) String() string { func (*ServiceEndpointResponse) ProtoMessage() {} func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[64] + mi := &file_openshell_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4541,7 +4665,7 @@ func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceEndpointResponse.ProtoReflect.Descriptor instead. func (*ServiceEndpointResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{64} + return file_openshell_proto_rawDescGZIP(), []int{66} } func (x *ServiceEndpointResponse) GetEndpoint() *ServiceEndpoint { @@ -4569,7 +4693,7 @@ type RevokeSshSessionRequest struct { func (x *RevokeSshSessionRequest) Reset() { *x = RevokeSshSessionRequest{} - mi := &file_openshell_proto_msgTypes[65] + mi := &file_openshell_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4581,7 +4705,7 @@ func (x *RevokeSshSessionRequest) String() string { func (*RevokeSshSessionRequest) ProtoMessage() {} func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[65] + mi := &file_openshell_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4594,7 +4718,7 @@ func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSshSessionRequest.ProtoReflect.Descriptor instead. func (*RevokeSshSessionRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{65} + return file_openshell_proto_rawDescGZIP(), []int{67} } func (x *RevokeSshSessionRequest) GetToken() string { @@ -4615,7 +4739,7 @@ type RevokeSshSessionResponse struct { func (x *RevokeSshSessionResponse) Reset() { *x = RevokeSshSessionResponse{} - mi := &file_openshell_proto_msgTypes[66] + mi := &file_openshell_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4627,7 +4751,7 @@ func (x *RevokeSshSessionResponse) String() string { func (*RevokeSshSessionResponse) ProtoMessage() {} func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[66] + mi := &file_openshell_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4640,7 +4764,7 @@ func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSshSessionResponse.ProtoReflect.Descriptor instead. func (*RevokeSshSessionResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{66} + return file_openshell_proto_rawDescGZIP(), []int{68} } func (x *RevokeSshSessionResponse) GetRevoked() bool { @@ -4683,7 +4807,7 @@ type ExecSandboxRequest struct { func (x *ExecSandboxRequest) Reset() { *x = ExecSandboxRequest{} - mi := &file_openshell_proto_msgTypes[67] + mi := &file_openshell_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4695,7 +4819,7 @@ func (x *ExecSandboxRequest) String() string { func (*ExecSandboxRequest) ProtoMessage() {} func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[67] + mi := &file_openshell_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4708,7 +4832,7 @@ func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxRequest.ProtoReflect.Descriptor instead. func (*ExecSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{67} + return file_openshell_proto_rawDescGZIP(), []int{69} } func (x *ExecSandboxRequest) GetSandboxId() string { @@ -4791,7 +4915,7 @@ type ExecSandboxStdout struct { func (x *ExecSandboxStdout) Reset() { *x = ExecSandboxStdout{} - mi := &file_openshell_proto_msgTypes[68] + mi := &file_openshell_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4803,7 +4927,7 @@ func (x *ExecSandboxStdout) String() string { func (*ExecSandboxStdout) ProtoMessage() {} func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[68] + mi := &file_openshell_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4816,7 +4940,7 @@ func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxStdout.ProtoReflect.Descriptor instead. func (*ExecSandboxStdout) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{68} + return file_openshell_proto_rawDescGZIP(), []int{70} } func (x *ExecSandboxStdout) GetData() []byte { @@ -4836,7 +4960,7 @@ type ExecSandboxStderr struct { func (x *ExecSandboxStderr) Reset() { *x = ExecSandboxStderr{} - mi := &file_openshell_proto_msgTypes[69] + mi := &file_openshell_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4848,7 +4972,7 @@ func (x *ExecSandboxStderr) String() string { func (*ExecSandboxStderr) ProtoMessage() {} func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[69] + mi := &file_openshell_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4861,7 +4985,7 @@ func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxStderr.ProtoReflect.Descriptor instead. func (*ExecSandboxStderr) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{69} + return file_openshell_proto_rawDescGZIP(), []int{71} } func (x *ExecSandboxStderr) GetData() []byte { @@ -4881,7 +5005,7 @@ type ExecSandboxExit struct { func (x *ExecSandboxExit) Reset() { *x = ExecSandboxExit{} - mi := &file_openshell_proto_msgTypes[70] + mi := &file_openshell_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4893,7 +5017,7 @@ func (x *ExecSandboxExit) String() string { func (*ExecSandboxExit) ProtoMessage() {} func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[70] + mi := &file_openshell_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4906,7 +5030,7 @@ func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxExit.ProtoReflect.Descriptor instead. func (*ExecSandboxExit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{70} + return file_openshell_proto_rawDescGZIP(), []int{72} } func (x *ExecSandboxExit) GetExitCode() int32 { @@ -4931,7 +5055,7 @@ type ExecSandboxEvent struct { func (x *ExecSandboxEvent) Reset() { *x = ExecSandboxEvent{} - mi := &file_openshell_proto_msgTypes[71] + mi := &file_openshell_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4943,7 +5067,7 @@ func (x *ExecSandboxEvent) String() string { func (*ExecSandboxEvent) ProtoMessage() {} func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[71] + mi := &file_openshell_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4956,7 +5080,7 @@ func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxEvent.ProtoReflect.Descriptor instead. func (*ExecSandboxEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{71} + return file_openshell_proto_rawDescGZIP(), []int{73} } func (x *ExecSandboxEvent) GetPayload() isExecSandboxEvent_Payload { @@ -5038,7 +5162,7 @@ type TcpForwardInit struct { func (x *TcpForwardInit) Reset() { *x = TcpForwardInit{} - mi := &file_openshell_proto_msgTypes[72] + mi := &file_openshell_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5050,7 +5174,7 @@ func (x *TcpForwardInit) String() string { func (*TcpForwardInit) ProtoMessage() {} func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[72] + mi := &file_openshell_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5063,7 +5187,7 @@ func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpForwardInit.ProtoReflect.Descriptor instead. func (*TcpForwardInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{72} + return file_openshell_proto_rawDescGZIP(), []int{74} } func (x *TcpForwardInit) GetSandboxId() string { @@ -5142,7 +5266,7 @@ type TcpForwardFrame struct { func (x *TcpForwardFrame) Reset() { *x = TcpForwardFrame{} - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5154,7 +5278,7 @@ func (x *TcpForwardFrame) String() string { func (*TcpForwardFrame) ProtoMessage() {} func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5167,7 +5291,7 @@ func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpForwardFrame.ProtoReflect.Descriptor instead. func (*TcpForwardFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{73} + return file_openshell_proto_rawDescGZIP(), []int{75} } func (x *TcpForwardFrame) GetPayload() isTcpForwardFrame_Payload { @@ -5226,7 +5350,7 @@ type ExecSandboxInput struct { func (x *ExecSandboxInput) Reset() { *x = ExecSandboxInput{} - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5238,7 +5362,7 @@ func (x *ExecSandboxInput) String() string { func (*ExecSandboxInput) ProtoMessage() {} func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5251,7 +5375,7 @@ func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxInput.ProtoReflect.Descriptor instead. func (*ExecSandboxInput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{74} + return file_openshell_proto_rawDescGZIP(), []int{76} } func (x *ExecSandboxInput) GetPayload() isExecSandboxInput_Payload { @@ -5324,7 +5448,7 @@ type ExecSandboxWindowResize struct { func (x *ExecSandboxWindowResize) Reset() { *x = ExecSandboxWindowResize{} - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5336,7 +5460,7 @@ func (x *ExecSandboxWindowResize) String() string { func (*ExecSandboxWindowResize) ProtoMessage() {} func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5349,7 +5473,7 @@ func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxWindowResize.ProtoReflect.Descriptor instead. func (*ExecSandboxWindowResize) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{75} + return file_openshell_proto_rawDescGZIP(), []int{77} } func (x *ExecSandboxWindowResize) GetCols() uint32 { @@ -5386,7 +5510,7 @@ type SshSession struct { func (x *SshSession) Reset() { *x = SshSession{} - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5398,7 +5522,7 @@ func (x *SshSession) String() string { func (*SshSession) ProtoMessage() {} func (x *SshSession) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5411,7 +5535,7 @@ func (x *SshSession) ProtoReflect() protoreflect.Message { // Deprecated: Use SshSession.ProtoReflect.Descriptor instead. func (*SshSession) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{76} + return file_openshell_proto_rawDescGZIP(), []int{78} } func (x *SshSession) GetMetadata() *datamodelv1.ObjectMeta { @@ -5480,7 +5604,7 @@ type WatchSandboxRequest struct { func (x *WatchSandboxRequest) Reset() { *x = WatchSandboxRequest{} - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5492,7 +5616,7 @@ func (x *WatchSandboxRequest) String() string { func (*WatchSandboxRequest) ProtoMessage() {} func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5505,7 +5629,7 @@ func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSandboxRequest.ProtoReflect.Descriptor instead. func (*WatchSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{77} + return file_openshell_proto_rawDescGZIP(), []int{79} } func (x *WatchSandboxRequest) GetId() string { @@ -5595,7 +5719,7 @@ type SandboxStreamEvent struct { func (x *SandboxStreamEvent) Reset() { *x = SandboxStreamEvent{} - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5607,7 +5731,7 @@ func (x *SandboxStreamEvent) String() string { func (*SandboxStreamEvent) ProtoMessage() {} func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5620,7 +5744,7 @@ func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStreamEvent.ProtoReflect.Descriptor instead. func (*SandboxStreamEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{78} + return file_openshell_proto_rawDescGZIP(), []int{80} } func (x *SandboxStreamEvent) GetPayload() isSandboxStreamEvent_Payload { @@ -5733,7 +5857,7 @@ type SandboxLogLine struct { func (x *SandboxLogLine) Reset() { *x = SandboxLogLine{} - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5745,7 +5869,7 @@ func (x *SandboxLogLine) String() string { func (*SandboxLogLine) ProtoMessage() {} func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5758,7 +5882,7 @@ func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxLogLine.ProtoReflect.Descriptor instead. func (*SandboxLogLine) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{79} + return file_openshell_proto_rawDescGZIP(), []int{81} } func (x *SandboxLogLine) GetSandboxId() string { @@ -5819,7 +5943,7 @@ type SandboxStreamWarning struct { func (x *SandboxStreamWarning) Reset() { *x = SandboxStreamWarning{} - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5831,7 +5955,7 @@ func (x *SandboxStreamWarning) String() string { func (*SandboxStreamWarning) ProtoMessage() {} func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5844,7 +5968,7 @@ func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStreamWarning.ProtoReflect.Descriptor instead. func (*SandboxStreamWarning) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{80} + return file_openshell_proto_rawDescGZIP(), []int{82} } func (x *SandboxStreamWarning) GetMessage() string { @@ -5866,7 +5990,7 @@ type CreateProviderRequest struct { func (x *CreateProviderRequest) Reset() { *x = CreateProviderRequest{} - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5878,7 +6002,7 @@ func (x *CreateProviderRequest) String() string { func (*CreateProviderRequest) ProtoMessage() {} func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5891,7 +6015,7 @@ func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProviderRequest.ProtoReflect.Descriptor instead. func (*CreateProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{81} + return file_openshell_proto_rawDescGZIP(), []int{83} } func (x *CreateProviderRequest) GetProvider() *datamodelv1.Provider { @@ -5920,7 +6044,7 @@ type GetProviderRequest struct { func (x *GetProviderRequest) Reset() { *x = GetProviderRequest{} - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5932,7 +6056,7 @@ func (x *GetProviderRequest) String() string { func (*GetProviderRequest) ProtoMessage() {} func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5945,7 +6069,7 @@ func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRequest.ProtoReflect.Descriptor instead. func (*GetProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{82} + return file_openshell_proto_rawDescGZIP(), []int{84} } func (x *GetProviderRequest) GetName() string { @@ -5977,7 +6101,7 @@ type ListProvidersRequest struct { func (x *ListProvidersRequest) Reset() { *x = ListProvidersRequest{} - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5989,7 +6113,7 @@ func (x *ListProvidersRequest) String() string { func (*ListProvidersRequest) ProtoMessage() {} func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6002,7 +6126,7 @@ func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProvidersRequest.ProtoReflect.Descriptor instead. func (*ListProvidersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{83} + return file_openshell_proto_rawDescGZIP(), []int{85} } func (x *ListProvidersRequest) GetLimit() uint32 { @@ -6048,7 +6172,7 @@ type UpdateProviderRequest struct { func (x *UpdateProviderRequest) Reset() { *x = UpdateProviderRequest{} - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6060,7 +6184,7 @@ func (x *UpdateProviderRequest) String() string { func (*UpdateProviderRequest) ProtoMessage() {} func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6073,7 +6197,7 @@ func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{84} + return file_openshell_proto_rawDescGZIP(), []int{86} } func (x *UpdateProviderRequest) GetProvider() *datamodelv1.Provider { @@ -6109,7 +6233,7 @@ type DeleteProviderRequest struct { func (x *DeleteProviderRequest) Reset() { *x = DeleteProviderRequest{} - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6121,7 +6245,7 @@ func (x *DeleteProviderRequest) String() string { func (*DeleteProviderRequest) ProtoMessage() {} func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6134,7 +6258,7 @@ func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{85} + return file_openshell_proto_rawDescGZIP(), []int{87} } func (x *DeleteProviderRequest) GetName() string { @@ -6161,7 +6285,7 @@ type ProviderResponse struct { func (x *ProviderResponse) Reset() { *x = ProviderResponse{} - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6173,7 +6297,7 @@ func (x *ProviderResponse) String() string { func (*ProviderResponse) ProtoMessage() {} func (x *ProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6186,7 +6310,7 @@ func (x *ProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderResponse.ProtoReflect.Descriptor instead. func (*ProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{86} + return file_openshell_proto_rawDescGZIP(), []int{88} } func (x *ProviderResponse) GetProvider() *datamodelv1.Provider { @@ -6206,7 +6330,7 @@ type ListProvidersResponse struct { func (x *ListProvidersResponse) Reset() { *x = ListProvidersResponse{} - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6218,7 +6342,7 @@ func (x *ListProvidersResponse) String() string { func (*ListProvidersResponse) ProtoMessage() {} func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6231,7 +6355,7 @@ func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProvidersResponse.ProtoReflect.Descriptor instead. func (*ListProvidersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{87} + return file_openshell_proto_rawDescGZIP(), []int{89} } func (x *ListProvidersResponse) GetProviders() []*datamodelv1.Provider { @@ -6255,7 +6379,7 @@ type ListProviderProfilesRequest struct { func (x *ListProviderProfilesRequest) Reset() { *x = ListProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6267,7 +6391,7 @@ func (x *ListProviderProfilesRequest) String() string { func (*ListProviderProfilesRequest) ProtoMessage() {} func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6280,7 +6404,7 @@ func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{88} + return file_openshell_proto_rawDescGZIP(), []int{90} } func (x *ListProviderProfilesRequest) GetLimit() uint32 { @@ -6318,7 +6442,7 @@ type GetProviderProfileRequest struct { func (x *GetProviderProfileRequest) Reset() { *x = GetProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6330,7 +6454,7 @@ func (x *GetProviderProfileRequest) String() string { func (*GetProviderProfileRequest) ProtoMessage() {} func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6343,7 +6467,7 @@ func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderProfileRequest.ProtoReflect.Descriptor instead. func (*GetProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{89} + return file_openshell_proto_rawDescGZIP(), []int{91} } func (x *GetProviderProfileRequest) GetId() string { @@ -6371,7 +6495,7 @@ type ProviderProfileImportItem struct { func (x *ProviderProfileImportItem) Reset() { *x = ProviderProfileImportItem{} - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6383,7 +6507,7 @@ func (x *ProviderProfileImportItem) String() string { func (*ProviderProfileImportItem) ProtoMessage() {} func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6396,7 +6520,7 @@ func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileImportItem.ProtoReflect.Descriptor instead. func (*ProviderProfileImportItem) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{90} + return file_openshell_proto_rawDescGZIP(), []int{92} } func (x *ProviderProfileImportItem) GetProfile() *ProviderProfile { @@ -6427,7 +6551,7 @@ type ProviderProfileDiagnostic struct { func (x *ProviderProfileDiagnostic) Reset() { *x = ProviderProfileDiagnostic{} - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6439,7 +6563,7 @@ func (x *ProviderProfileDiagnostic) String() string { func (*ProviderProfileDiagnostic) ProtoMessage() {} func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6452,7 +6576,7 @@ func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiagnostic.ProtoReflect.Descriptor instead. func (*ProviderProfileDiagnostic) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{91} + return file_openshell_proto_rawDescGZIP(), []int{93} } func (x *ProviderProfileDiagnostic) GetSource() string { @@ -6509,7 +6633,7 @@ type ProviderCredentialTokenGrantAudienceOverride struct { func (x *ProviderCredentialTokenGrantAudienceOverride) Reset() { *x = ProviderCredentialTokenGrantAudienceOverride{} - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6521,7 +6645,7 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) String() string { func (*ProviderCredentialTokenGrantAudienceOverride) ProtoMessage() {} func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6534,7 +6658,7 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protorefle // Deprecated: Use ProviderCredentialTokenGrantAudienceOverride.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrantAudienceOverride) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{92} + return file_openshell_proto_rawDescGZIP(), []int{94} } func (x *ProviderCredentialTokenGrantAudienceOverride) GetHost() string { @@ -6588,7 +6712,7 @@ type ProviderCredentialTokenGrantSubjectToken struct { func (x *ProviderCredentialTokenGrantSubjectToken) Reset() { *x = ProviderCredentialTokenGrantSubjectToken{} - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6600,7 +6724,7 @@ func (x *ProviderCredentialTokenGrantSubjectToken) String() string { func (*ProviderCredentialTokenGrantSubjectToken) ProtoMessage() {} func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6613,7 +6737,7 @@ func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.M // Deprecated: Use ProviderCredentialTokenGrantSubjectToken.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrantSubjectToken) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{93} + return file_openshell_proto_rawDescGZIP(), []int{95} } func (x *ProviderCredentialTokenGrantSubjectToken) GetSource() string { @@ -6670,7 +6794,7 @@ type ProviderCredentialTokenGrant struct { func (x *ProviderCredentialTokenGrant) Reset() { *x = ProviderCredentialTokenGrant{} - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6682,7 +6806,7 @@ func (x *ProviderCredentialTokenGrant) String() string { func (*ProviderCredentialTokenGrant) ProtoMessage() {} func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6695,7 +6819,7 @@ func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialTokenGrant.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrant) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{94} + return file_openshell_proto_rawDescGZIP(), []int{96} } func (x *ProviderCredentialTokenGrant) GetTokenEndpoint() string { @@ -6787,7 +6911,7 @@ type ProviderProfileCredential struct { func (x *ProviderProfileCredential) Reset() { *x = ProviderProfileCredential{} - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6799,7 +6923,7 @@ func (x *ProviderProfileCredential) String() string { func (*ProviderProfileCredential) ProtoMessage() {} func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6812,7 +6936,7 @@ func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileCredential.ProtoReflect.Descriptor instead. func (*ProviderProfileCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{95} + return file_openshell_proto_rawDescGZIP(), []int{97} } func (x *ProviderProfileCredential) GetName() string { @@ -6897,7 +7021,7 @@ type ProviderCredentialRefreshMaterial struct { func (x *ProviderCredentialRefreshMaterial) Reset() { *x = ProviderCredentialRefreshMaterial{} - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6909,7 +7033,7 @@ func (x *ProviderCredentialRefreshMaterial) String() string { func (*ProviderCredentialRefreshMaterial) ProtoMessage() {} func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6922,7 +7046,7 @@ func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message // Deprecated: Use ProviderCredentialRefreshMaterial.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshMaterial) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{96} + return file_openshell_proto_rawDescGZIP(), []int{98} } func (x *ProviderCredentialRefreshMaterial) GetName() string { @@ -6967,7 +7091,7 @@ type ProviderCredentialRefreshOutput struct { func (x *ProviderCredentialRefreshOutput) Reset() { *x = ProviderCredentialRefreshOutput{} - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6979,7 +7103,7 @@ func (x *ProviderCredentialRefreshOutput) String() string { func (*ProviderCredentialRefreshOutput) ProtoMessage() {} func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6992,7 +7116,7 @@ func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshOutput.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshOutput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{97} + return file_openshell_proto_rawDescGZIP(), []int{99} } func (x *ProviderCredentialRefreshOutput) GetOutput() string { @@ -7024,7 +7148,7 @@ type ProviderCredentialRefresh struct { func (x *ProviderCredentialRefresh) Reset() { *x = ProviderCredentialRefresh{} - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7036,7 +7160,7 @@ func (x *ProviderCredentialRefresh) String() string { func (*ProviderCredentialRefresh) ProtoMessage() {} func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7049,7 +7173,7 @@ func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefresh.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefresh) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{98} + return file_openshell_proto_rawDescGZIP(), []int{100} } func (x *ProviderCredentialRefresh) GetStrategy() ProviderCredentialRefreshStrategy { @@ -7132,7 +7256,7 @@ type ProviderCredentialRefreshStatus struct { func (x *ProviderCredentialRefreshStatus) Reset() { *x = ProviderCredentialRefreshStatus{} - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7144,7 +7268,7 @@ func (x *ProviderCredentialRefreshStatus) String() string { func (*ProviderCredentialRefreshStatus) ProtoMessage() {} func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7157,7 +7281,7 @@ func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshStatus.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{99} + return file_openshell_proto_rawDescGZIP(), []int{101} } func (x *ProviderCredentialRefreshStatus) GetProviderName() string { @@ -7262,7 +7386,7 @@ type ProviderProfileDiscovery struct { func (x *ProviderProfileDiscovery) Reset() { *x = ProviderProfileDiscovery{} - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7274,7 +7398,7 @@ func (x *ProviderProfileDiscovery) String() string { func (*ProviderProfileDiscovery) ProtoMessage() {} func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7287,7 +7411,7 @@ func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiscovery.ProtoReflect.Descriptor instead. func (*ProviderProfileDiscovery) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{100} + return file_openshell_proto_rawDescGZIP(), []int{102} } func (x *ProviderProfileDiscovery) GetCredentials() []string { @@ -7351,7 +7475,7 @@ type StoredProviderCredentialRefreshState struct { func (x *StoredProviderCredentialRefreshState) Reset() { *x = StoredProviderCredentialRefreshState{} - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7363,7 +7487,7 @@ func (x *StoredProviderCredentialRefreshState) String() string { func (*StoredProviderCredentialRefreshState) ProtoMessage() {} func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7376,7 +7500,7 @@ func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Messa // Deprecated: Use StoredProviderCredentialRefreshState.ProtoReflect.Descriptor instead. func (*StoredProviderCredentialRefreshState) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{101} + return file_openshell_proto_rawDescGZIP(), []int{103} } func (x *StoredProviderCredentialRefreshState) GetMetadata() *datamodelv1.ObjectMeta { @@ -7559,7 +7683,7 @@ type StoredRefreshMaterialDeletion struct { func (x *StoredRefreshMaterialDeletion) Reset() { *x = StoredRefreshMaterialDeletion{} - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7571,7 +7695,7 @@ func (x *StoredRefreshMaterialDeletion) String() string { func (*StoredRefreshMaterialDeletion) ProtoMessage() {} func (x *StoredRefreshMaterialDeletion) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7584,7 +7708,7 @@ func (x *StoredRefreshMaterialDeletion) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredRefreshMaterialDeletion.ProtoReflect.Descriptor instead. func (*StoredRefreshMaterialDeletion) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{102} + return file_openshell_proto_rawDescGZIP(), []int{104} } func (x *StoredRefreshMaterialDeletion) GetMaterialKey() string { @@ -7613,7 +7737,7 @@ type GetProviderRefreshStatusRequest struct { func (x *GetProviderRefreshStatusRequest) Reset() { *x = GetProviderRefreshStatusRequest{} - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7625,7 +7749,7 @@ func (x *GetProviderRefreshStatusRequest) String() string { func (*GetProviderRefreshStatusRequest) ProtoMessage() {} func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7638,7 +7762,7 @@ func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusRequest.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{103} + return file_openshell_proto_rawDescGZIP(), []int{105} } func (x *GetProviderRefreshStatusRequest) GetProvider() string { @@ -7671,7 +7795,7 @@ type GetProviderRefreshStatusResponse struct { func (x *GetProviderRefreshStatusResponse) Reset() { *x = GetProviderRefreshStatusResponse{} - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7683,7 +7807,7 @@ func (x *GetProviderRefreshStatusResponse) String() string { func (*GetProviderRefreshStatusResponse) ProtoMessage() {} func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7696,7 +7820,7 @@ func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusResponse.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{104} + return file_openshell_proto_rawDescGZIP(), []int{106} } func (x *GetProviderRefreshStatusResponse) GetCredentials() []*ProviderCredentialRefreshStatus { @@ -7725,7 +7849,7 @@ type ConfigureProviderRefreshRequest struct { func (x *ConfigureProviderRefreshRequest) Reset() { *x = ConfigureProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7737,7 +7861,7 @@ func (x *ConfigureProviderRefreshRequest) String() string { func (*ConfigureProviderRefreshRequest) ProtoMessage() {} func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7750,7 +7874,7 @@ func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{105} + return file_openshell_proto_rawDescGZIP(), []int{107} } func (x *ConfigureProviderRefreshRequest) GetProvider() string { @@ -7811,7 +7935,7 @@ type ConfigureProviderRefreshResponse struct { func (x *ConfigureProviderRefreshResponse) Reset() { *x = ConfigureProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7823,7 +7947,7 @@ func (x *ConfigureProviderRefreshResponse) String() string { func (*ConfigureProviderRefreshResponse) ProtoMessage() {} func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7836,7 +7960,7 @@ func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{106} + return file_openshell_proto_rawDescGZIP(), []int{108} } func (x *ConfigureProviderRefreshResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -7858,7 +7982,7 @@ type RotateProviderCredentialRequest struct { func (x *RotateProviderCredentialRequest) Reset() { *x = RotateProviderCredentialRequest{} - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7870,7 +7994,7 @@ func (x *RotateProviderCredentialRequest) String() string { func (*RotateProviderCredentialRequest) ProtoMessage() {} func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7883,7 +8007,7 @@ func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialRequest.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{107} + return file_openshell_proto_rawDescGZIP(), []int{109} } func (x *RotateProviderCredentialRequest) GetProvider() string { @@ -7916,7 +8040,7 @@ type RotateProviderCredentialResponse struct { func (x *RotateProviderCredentialResponse) Reset() { *x = RotateProviderCredentialResponse{} - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7928,7 +8052,7 @@ func (x *RotateProviderCredentialResponse) String() string { func (*RotateProviderCredentialResponse) ProtoMessage() {} func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7941,7 +8065,7 @@ func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialResponse.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{108} + return file_openshell_proto_rawDescGZIP(), []int{110} } func (x *RotateProviderCredentialResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -7963,7 +8087,7 @@ type DeleteProviderRefreshRequest struct { func (x *DeleteProviderRefreshRequest) Reset() { *x = DeleteProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7975,7 +8099,7 @@ func (x *DeleteProviderRefreshRequest) String() string { func (*DeleteProviderRefreshRequest) ProtoMessage() {} func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7988,7 +8112,7 @@ func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{109} + return file_openshell_proto_rawDescGZIP(), []int{111} } func (x *DeleteProviderRefreshRequest) GetProvider() string { @@ -8021,7 +8145,7 @@ type DeleteProviderRefreshResponse struct { func (x *DeleteProviderRefreshResponse) Reset() { *x = DeleteProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8033,7 +8157,7 @@ func (x *DeleteProviderRefreshResponse) String() string { func (*DeleteProviderRefreshResponse) ProtoMessage() {} func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8046,7 +8170,7 @@ func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{110} + return file_openshell_proto_rawDescGZIP(), []int{112} } func (x *DeleteProviderRefreshResponse) GetDeleted() bool { @@ -8086,7 +8210,7 @@ type ProviderProfile struct { func (x *ProviderProfile) Reset() { *x = ProviderProfile{} - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8098,7 +8222,7 @@ func (x *ProviderProfile) String() string { func (*ProviderProfile) ProtoMessage() {} func (x *ProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8111,7 +8235,7 @@ func (x *ProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfile.ProtoReflect.Descriptor instead. func (*ProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{111} + return file_openshell_proto_rawDescGZIP(), []int{113} } func (x *ProviderProfile) GetId() string { @@ -8216,7 +8340,7 @@ type StoredProviderProfile struct { func (x *StoredProviderProfile) Reset() { *x = StoredProviderProfile{} - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8228,7 +8352,7 @@ func (x *StoredProviderProfile) String() string { func (*StoredProviderProfile) ProtoMessage() {} func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8241,7 +8365,7 @@ func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredProviderProfile.ProtoReflect.Descriptor instead. func (*StoredProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{112} + return file_openshell_proto_rawDescGZIP(), []int{114} } func (x *StoredProviderProfile) GetMetadata() *datamodelv1.ObjectMeta { @@ -8268,7 +8392,7 @@ type ProviderProfileResponse struct { func (x *ProviderProfileResponse) Reset() { *x = ProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8280,7 +8404,7 @@ func (x *ProviderProfileResponse) String() string { func (*ProviderProfileResponse) ProtoMessage() {} func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8293,7 +8417,7 @@ func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileResponse.ProtoReflect.Descriptor instead. func (*ProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{113} + return file_openshell_proto_rawDescGZIP(), []int{115} } func (x *ProviderProfileResponse) GetProfile() *ProviderProfile { @@ -8313,7 +8437,7 @@ type ListProviderProfilesResponse struct { func (x *ListProviderProfilesResponse) Reset() { *x = ListProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8325,7 +8449,7 @@ func (x *ListProviderProfilesResponse) String() string { func (*ListProviderProfilesResponse) ProtoMessage() {} func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8338,7 +8462,7 @@ func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{114} + return file_openshell_proto_rawDescGZIP(), []int{116} } func (x *ListProviderProfilesResponse) GetProfiles() []*ProviderProfile { @@ -8361,7 +8485,7 @@ type ImportProviderProfilesRequest struct { func (x *ImportProviderProfilesRequest) Reset() { *x = ImportProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8373,7 +8497,7 @@ func (x *ImportProviderProfilesRequest) String() string { func (*ImportProviderProfilesRequest) ProtoMessage() {} func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8386,7 +8510,7 @@ func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{115} + return file_openshell_proto_rawDescGZIP(), []int{117} } func (x *ImportProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { @@ -8415,7 +8539,7 @@ type ImportProviderProfilesResponse struct { func (x *ImportProviderProfilesResponse) Reset() { *x = ImportProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8427,7 +8551,7 @@ func (x *ImportProviderProfilesResponse) String() string { func (*ImportProviderProfilesResponse) ProtoMessage() {} func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8440,7 +8564,7 @@ func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{116} + return file_openshell_proto_rawDescGZIP(), []int{118} } func (x *ImportProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -8484,7 +8608,7 @@ type UpdateProviderProfilesRequest struct { func (x *UpdateProviderProfilesRequest) Reset() { *x = UpdateProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8496,7 +8620,7 @@ func (x *UpdateProviderProfilesRequest) String() string { func (*UpdateProviderProfilesRequest) ProtoMessage() {} func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8509,7 +8633,7 @@ func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{117} + return file_openshell_proto_rawDescGZIP(), []int{119} } func (x *UpdateProviderProfilesRequest) GetProfile() *ProviderProfileImportItem { @@ -8552,7 +8676,7 @@ type UpdateProviderProfilesResponse struct { func (x *UpdateProviderProfilesResponse) Reset() { *x = UpdateProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8564,7 +8688,7 @@ func (x *UpdateProviderProfilesResponse) String() string { func (*UpdateProviderProfilesResponse) ProtoMessage() {} func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8577,7 +8701,7 @@ func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{118} + return file_openshell_proto_rawDescGZIP(), []int{120} } func (x *UpdateProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -8614,7 +8738,7 @@ type LintProviderProfilesRequest struct { func (x *LintProviderProfilesRequest) Reset() { *x = LintProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8626,7 +8750,7 @@ func (x *LintProviderProfilesRequest) String() string { func (*LintProviderProfilesRequest) ProtoMessage() {} func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8639,7 +8763,7 @@ func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*LintProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{119} + return file_openshell_proto_rawDescGZIP(), []int{121} } func (x *LintProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { @@ -8667,7 +8791,7 @@ type LintProviderProfilesResponse struct { func (x *LintProviderProfilesResponse) Reset() { *x = LintProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8679,7 +8803,7 @@ func (x *LintProviderProfilesResponse) String() string { func (*LintProviderProfilesResponse) ProtoMessage() {} func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8692,7 +8816,7 @@ func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*LintProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{120} + return file_openshell_proto_rawDescGZIP(), []int{122} } func (x *LintProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -8719,7 +8843,7 @@ type DeleteProviderResponse struct { func (x *DeleteProviderResponse) Reset() { *x = DeleteProviderResponse{} - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8731,7 +8855,7 @@ func (x *DeleteProviderResponse) String() string { func (*DeleteProviderResponse) ProtoMessage() {} func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8744,7 +8868,7 @@ func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{121} + return file_openshell_proto_rawDescGZIP(), []int{123} } func (x *DeleteProviderResponse) GetDeleted() bool { @@ -8767,7 +8891,7 @@ type DeleteProviderProfileRequest struct { func (x *DeleteProviderProfileRequest) Reset() { *x = DeleteProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8779,7 +8903,7 @@ func (x *DeleteProviderProfileRequest) String() string { func (*DeleteProviderProfileRequest) ProtoMessage() {} func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8792,7 +8916,7 @@ func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{122} + return file_openshell_proto_rawDescGZIP(), []int{124} } func (x *DeleteProviderProfileRequest) GetId() string { @@ -8819,7 +8943,7 @@ type DeleteProviderProfileResponse struct { func (x *DeleteProviderProfileResponse) Reset() { *x = DeleteProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8831,7 +8955,7 @@ func (x *DeleteProviderProfileResponse) String() string { func (*DeleteProviderProfileResponse) ProtoMessage() {} func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8844,7 +8968,7 @@ func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{123} + return file_openshell_proto_rawDescGZIP(), []int{125} } func (x *DeleteProviderProfileResponse) GetDeleted() bool { @@ -8869,7 +8993,7 @@ type GetSandboxProviderEnvironmentRequest struct { func (x *GetSandboxProviderEnvironmentRequest) Reset() { *x = GetSandboxProviderEnvironmentRequest{} - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8881,7 +9005,7 @@ func (x *GetSandboxProviderEnvironmentRequest) String() string { func (*GetSandboxProviderEnvironmentRequest) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8894,7 +9018,7 @@ func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{124} + return file_openshell_proto_rawDescGZIP(), []int{126} } func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string { @@ -8923,7 +9047,7 @@ type StaticCredentialEndpointBinding struct { func (x *StaticCredentialEndpointBinding) Reset() { *x = StaticCredentialEndpointBinding{} - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8935,7 +9059,7 @@ func (x *StaticCredentialEndpointBinding) String() string { func (*StaticCredentialEndpointBinding) ProtoMessage() {} func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8948,7 +9072,7 @@ func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialEndpointBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialEndpointBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{125} + return file_openshell_proto_rawDescGZIP(), []int{127} } func (x *StaticCredentialEndpointBinding) GetHost() string { @@ -8992,7 +9116,7 @@ type StaticCredentialBinding struct { func (x *StaticCredentialBinding) Reset() { *x = StaticCredentialBinding{} - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[128] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9004,7 +9128,7 @@ func (x *StaticCredentialBinding) String() string { func (*StaticCredentialBinding) ProtoMessage() {} func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[128] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9017,7 +9141,7 @@ func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{126} + return file_openshell_proto_rawDescGZIP(), []int{128} } func (x *StaticCredentialBinding) GetEndpoints() []*StaticCredentialEndpointBinding { @@ -9068,7 +9192,7 @@ type GetSandboxProviderEnvironmentResponse struct { func (x *GetSandboxProviderEnvironmentResponse) Reset() { *x = GetSandboxProviderEnvironmentResponse{} - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[129] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9080,7 +9204,7 @@ func (x *GetSandboxProviderEnvironmentResponse) String() string { func (*GetSandboxProviderEnvironmentResponse) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[129] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9093,7 +9217,7 @@ func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{127} + return file_openshell_proto_rawDescGZIP(), []int{129} } func (x *GetSandboxProviderEnvironmentResponse) GetEnvironment() map[string]string { @@ -9155,7 +9279,7 @@ type ExchangeProviderSubjectTokenRequest struct { func (x *ExchangeProviderSubjectTokenRequest) Reset() { *x = ExchangeProviderSubjectTokenRequest{} - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[130] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9167,7 +9291,7 @@ func (x *ExchangeProviderSubjectTokenRequest) String() string { func (*ExchangeProviderSubjectTokenRequest) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[130] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9180,7 +9304,7 @@ func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use ExchangeProviderSubjectTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{128} + return file_openshell_proto_rawDescGZIP(), []int{130} } func (x *ExchangeProviderSubjectTokenRequest) GetSandboxId() string { @@ -9222,7 +9346,7 @@ type ExchangeProviderSubjectTokenResponse struct { func (x *ExchangeProviderSubjectTokenResponse) Reset() { *x = ExchangeProviderSubjectTokenResponse{} - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[131] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9234,7 +9358,7 @@ func (x *ExchangeProviderSubjectTokenResponse) String() string { func (*ExchangeProviderSubjectTokenResponse) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[131] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9247,7 +9371,7 @@ func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use ExchangeProviderSubjectTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{129} + return file_openshell_proto_rawDescGZIP(), []int{131} } func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { @@ -9318,7 +9442,7 @@ type UpdateConfigRequest struct { func (x *UpdateConfigRequest) Reset() { *x = UpdateConfigRequest{} - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[132] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9330,7 +9454,7 @@ func (x *UpdateConfigRequest) String() string { func (*UpdateConfigRequest) ProtoMessage() {} func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[132] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9343,7 +9467,7 @@ func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{130} + return file_openshell_proto_rawDescGZIP(), []int{132} } func (x *UpdateConfigRequest) GetName() string { @@ -9433,7 +9557,7 @@ type PolicyMergeOperation struct { func (x *PolicyMergeOperation) Reset() { *x = PolicyMergeOperation{} - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[133] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9445,7 +9569,7 @@ func (x *PolicyMergeOperation) String() string { func (*PolicyMergeOperation) ProtoMessage() {} func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[133] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9458,7 +9582,7 @@ func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{131} + return file_openshell_proto_rawDescGZIP(), []int{133} } func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { @@ -9572,7 +9696,7 @@ type AddNetworkRule struct { func (x *AddNetworkRule) Reset() { *x = AddNetworkRule{} - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[134] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9584,7 +9708,7 @@ func (x *AddNetworkRule) String() string { func (*AddNetworkRule) ProtoMessage() {} func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[134] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9597,7 +9721,7 @@ func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. func (*AddNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{132} + return file_openshell_proto_rawDescGZIP(), []int{134} } func (x *AddNetworkRule) GetRuleName() string { @@ -9625,7 +9749,7 @@ type RemoveNetworkEndpoint struct { func (x *RemoveNetworkEndpoint) Reset() { *x = RemoveNetworkEndpoint{} - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[135] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9637,7 +9761,7 @@ func (x *RemoveNetworkEndpoint) String() string { func (*RemoveNetworkEndpoint) ProtoMessage() {} func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[135] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9650,7 +9774,7 @@ func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{133} + return file_openshell_proto_rawDescGZIP(), []int{135} } func (x *RemoveNetworkEndpoint) GetRuleName() string { @@ -9683,7 +9807,7 @@ type RemoveNetworkRule struct { func (x *RemoveNetworkRule) Reset() { *x = RemoveNetworkRule{} - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9695,7 +9819,7 @@ func (x *RemoveNetworkRule) String() string { func (*RemoveNetworkRule) ProtoMessage() {} func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9708,7 +9832,7 @@ func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{134} + return file_openshell_proto_rawDescGZIP(), []int{136} } func (x *RemoveNetworkRule) GetRuleName() string { @@ -9729,7 +9853,7 @@ type AddDenyRules struct { func (x *AddDenyRules) Reset() { *x = AddDenyRules{} - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9741,7 +9865,7 @@ func (x *AddDenyRules) String() string { func (*AddDenyRules) ProtoMessage() {} func (x *AddDenyRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9754,7 +9878,7 @@ func (x *AddDenyRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. func (*AddDenyRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{135} + return file_openshell_proto_rawDescGZIP(), []int{137} } func (x *AddDenyRules) GetHost() string { @@ -9789,7 +9913,7 @@ type AddAllowRules struct { func (x *AddAllowRules) Reset() { *x = AddAllowRules{} - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9801,7 +9925,7 @@ func (x *AddAllowRules) String() string { func (*AddAllowRules) ProtoMessage() {} func (x *AddAllowRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9814,7 +9938,7 @@ func (x *AddAllowRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. func (*AddAllowRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{136} + return file_openshell_proto_rawDescGZIP(), []int{138} } func (x *AddAllowRules) GetHost() string { @@ -9848,7 +9972,7 @@ type RemoveNetworkBinary struct { func (x *RemoveNetworkBinary) Reset() { *x = RemoveNetworkBinary{} - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9860,7 +9984,7 @@ func (x *RemoveNetworkBinary) String() string { func (*RemoveNetworkBinary) ProtoMessage() {} func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9873,7 +9997,7 @@ func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{137} + return file_openshell_proto_rawDescGZIP(), []int{139} } func (x *RemoveNetworkBinary) GetRuleName() string { @@ -9909,7 +10033,7 @@ type UpdateConfigResponse struct { func (x *UpdateConfigResponse) Reset() { *x = UpdateConfigResponse{} - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9921,7 +10045,7 @@ func (x *UpdateConfigResponse) String() string { func (*UpdateConfigResponse) ProtoMessage() {} func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9934,7 +10058,7 @@ func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{138} + return file_openshell_proto_rawDescGZIP(), []int{140} } func (x *UpdateConfigResponse) GetVersion() uint32 { @@ -9989,7 +10113,7 @@ type GetSandboxPolicyStatusRequest struct { func (x *GetSandboxPolicyStatusRequest) Reset() { *x = GetSandboxPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10001,7 +10125,7 @@ func (x *GetSandboxPolicyStatusRequest) String() string { func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10014,7 +10138,7 @@ func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{139} + return file_openshell_proto_rawDescGZIP(), []int{141} } func (x *GetSandboxPolicyStatusRequest) GetName() string { @@ -10058,7 +10182,7 @@ type GetSandboxPolicyStatusResponse struct { func (x *GetSandboxPolicyStatusResponse) Reset() { *x = GetSandboxPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10070,7 +10194,7 @@ func (x *GetSandboxPolicyStatusResponse) String() string { func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10083,7 +10207,7 @@ func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{140} + return file_openshell_proto_rawDescGZIP(), []int{142} } func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { @@ -10117,7 +10241,7 @@ type ListSandboxPoliciesRequest struct { func (x *ListSandboxPoliciesRequest) Reset() { *x = ListSandboxPoliciesRequest{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10129,7 +10253,7 @@ func (x *ListSandboxPoliciesRequest) String() string { func (*ListSandboxPoliciesRequest) ProtoMessage() {} func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10142,7 +10266,7 @@ func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *ListSandboxPoliciesRequest) GetName() string { @@ -10192,7 +10316,7 @@ type ListSandboxPoliciesResponse struct { func (x *ListSandboxPoliciesResponse) Reset() { *x = ListSandboxPoliciesResponse{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10204,7 +10328,7 @@ func (x *ListSandboxPoliciesResponse) String() string { func (*ListSandboxPoliciesResponse) ProtoMessage() {} func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10217,7 +10341,7 @@ func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { @@ -10244,7 +10368,7 @@ type ReportPolicyStatusRequest struct { func (x *ReportPolicyStatusRequest) Reset() { *x = ReportPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10256,7 +10380,7 @@ func (x *ReportPolicyStatusRequest) String() string { func (*ReportPolicyStatusRequest) ProtoMessage() {} func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10269,7 +10393,7 @@ func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *ReportPolicyStatusRequest) GetSandboxId() string { @@ -10309,7 +10433,7 @@ type ReportPolicyStatusResponse struct { func (x *ReportPolicyStatusResponse) Reset() { *x = ReportPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10321,7 +10445,7 @@ func (x *ReportPolicyStatusResponse) String() string { func (*ReportPolicyStatusResponse) ProtoMessage() {} func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10334,7 +10458,7 @@ func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{146} } // A versioned policy revision with metadata. @@ -10367,7 +10491,7 @@ type SandboxPolicyRevision struct { func (x *SandboxPolicyRevision) Reset() { *x = SandboxPolicyRevision{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10379,7 +10503,7 @@ func (x *SandboxPolicyRevision) String() string { func (*SandboxPolicyRevision) ProtoMessage() {} func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10392,7 +10516,7 @@ func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *SandboxPolicyRevision) GetVersion() uint32 { @@ -10472,7 +10596,7 @@ type GetSandboxLogsRequest struct { func (x *GetSandboxLogsRequest) Reset() { *x = GetSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10484,7 +10608,7 @@ func (x *GetSandboxLogsRequest) String() string { func (*GetSandboxLogsRequest) ProtoMessage() {} func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10497,7 +10621,7 @@ func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *GetSandboxLogsRequest) GetSandboxId() string { @@ -10555,7 +10679,7 @@ type PushSandboxLogsRequest struct { func (x *PushSandboxLogsRequest) Reset() { *x = PushSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10567,7 +10691,7 @@ func (x *PushSandboxLogsRequest) String() string { func (*PushSandboxLogsRequest) ProtoMessage() {} func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10580,7 +10704,7 @@ func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *PushSandboxLogsRequest) GetSandboxId() string { @@ -10606,7 +10730,7 @@ type PushSandboxLogsResponse struct { func (x *PushSandboxLogsResponse) Reset() { *x = PushSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10618,7 +10742,7 @@ func (x *PushSandboxLogsResponse) String() string { func (*PushSandboxLogsResponse) ProtoMessage() {} func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10631,7 +10755,7 @@ func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{150} } // Get sandbox logs response. @@ -10647,7 +10771,7 @@ type GetSandboxLogsResponse struct { func (x *GetSandboxLogsResponse) Reset() { *x = GetSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10659,7 +10783,7 @@ func (x *GetSandboxLogsResponse) String() string { func (*GetSandboxLogsResponse) ProtoMessage() {} func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10672,7 +10796,7 @@ func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { @@ -10705,7 +10829,7 @@ type SupervisorMessage struct { func (x *SupervisorMessage) Reset() { *x = SupervisorMessage{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10717,7 +10841,7 @@ func (x *SupervisorMessage) String() string { func (*SupervisorMessage) ProtoMessage() {} func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10730,7 +10854,7 @@ func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. func (*SupervisorMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { @@ -10821,7 +10945,7 @@ type GatewayMessage struct { func (x *GatewayMessage) Reset() { *x = GatewayMessage{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10833,7 +10957,7 @@ func (x *GatewayMessage) String() string { func (*GatewayMessage) ProtoMessage() {} func (x *GatewayMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10846,7 +10970,7 @@ func (x *GatewayMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. func (*GatewayMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { @@ -10948,7 +11072,7 @@ type SupervisorHello struct { func (x *SupervisorHello) Reset() { *x = SupervisorHello{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10960,7 +11084,7 @@ func (x *SupervisorHello) String() string { func (*SupervisorHello) ProtoMessage() {} func (x *SupervisorHello) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10973,7 +11097,7 @@ func (x *SupervisorHello) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. func (*SupervisorHello) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{154} } func (x *SupervisorHello) GetSandboxId() string { @@ -11003,7 +11127,7 @@ type SessionAccepted struct { func (x *SessionAccepted) Reset() { *x = SessionAccepted{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11015,7 +11139,7 @@ func (x *SessionAccepted) String() string { func (*SessionAccepted) ProtoMessage() {} func (x *SessionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11028,7 +11152,7 @@ func (x *SessionAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. func (*SessionAccepted) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *SessionAccepted) GetSessionId() string { @@ -11056,7 +11180,7 @@ type SessionRejected struct { func (x *SessionRejected) Reset() { *x = SessionRejected{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11068,7 +11192,7 @@ func (x *SessionRejected) String() string { func (*SessionRejected) ProtoMessage() {} func (x *SessionRejected) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11081,7 +11205,7 @@ func (x *SessionRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. func (*SessionRejected) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{156} } func (x *SessionRejected) GetReason() string { @@ -11100,7 +11224,7 @@ type SupervisorHeartbeat struct { func (x *SupervisorHeartbeat) Reset() { *x = SupervisorHeartbeat{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11112,7 +11236,7 @@ func (x *SupervisorHeartbeat) String() string { func (*SupervisorHeartbeat) ProtoMessage() {} func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11125,7 +11249,7 @@ func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{157} } // Gateway heartbeat. @@ -11137,7 +11261,7 @@ type GatewayHeartbeat struct { func (x *GatewayHeartbeat) Reset() { *x = GatewayHeartbeat{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11149,7 +11273,7 @@ func (x *GatewayHeartbeat) String() string { func (*GatewayHeartbeat) ProtoMessage() {} func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11162,7 +11286,7 @@ func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{158} } // Terminal result reported before the supervisor shuts down. A successful RPC @@ -11179,7 +11303,7 @@ type ReportMainProcessExitRequest struct { func (x *ReportMainProcessExitRequest) Reset() { *x = ReportMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11191,7 +11315,7 @@ func (x *ReportMainProcessExitRequest) String() string { func (*ReportMainProcessExitRequest) ProtoMessage() {} func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11204,7 +11328,7 @@ func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *ReportMainProcessExitRequest) GetSandboxId() string { @@ -11236,7 +11360,7 @@ type ReportMainProcessExitResponse struct { func (x *ReportMainProcessExitResponse) Reset() { *x = ReportMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11248,7 +11372,7 @@ func (x *ReportMainProcessExitResponse) String() string { func (*ReportMainProcessExitResponse) ProtoMessage() {} func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11261,7 +11385,7 @@ func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{160} } // Terminal-delivery completion reported after all expected foreground SSH @@ -11276,7 +11400,7 @@ type FinalizeMainProcessExitRequest struct { func (x *FinalizeMainProcessExitRequest) Reset() { *x = FinalizeMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11288,7 +11412,7 @@ func (x *FinalizeMainProcessExitRequest) String() string { func (*FinalizeMainProcessExitRequest) ProtoMessage() {} func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11301,7 +11425,7 @@ func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{161} } func (x *FinalizeMainProcessExitRequest) GetSandboxId() string { @@ -11326,7 +11450,7 @@ type FinalizeMainProcessExitResponse struct { func (x *FinalizeMainProcessExitResponse) Reset() { *x = FinalizeMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11338,7 +11462,7 @@ func (x *FinalizeMainProcessExitResponse) String() string { func (*FinalizeMainProcessExitResponse) ProtoMessage() {} func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11351,7 +11475,7 @@ func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{162} } // Gateway requests the supervisor to open a relay channel. @@ -11380,7 +11504,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11392,7 +11516,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11405,7 +11529,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *RelayOpen) GetChannelId() string { @@ -11472,7 +11596,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11484,7 +11608,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11497,7 +11621,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{164} } // TCP target dialed by the supervisor from inside the sandbox. @@ -11513,7 +11637,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11525,7 +11649,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11538,7 +11662,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *TcpRelayTarget) GetHost() string { @@ -11566,7 +11690,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11578,7 +11702,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11591,7 +11715,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *RelayInit) GetChannelId() string { @@ -11618,7 +11742,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11630,7 +11754,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11643,7 +11767,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -11702,7 +11826,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11714,7 +11838,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11727,7 +11851,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{168} } func (x *RelayOpenResult) GetChannelId() string { @@ -11764,7 +11888,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11776,7 +11900,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11789,7 +11913,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{169} } func (x *RelayClose) GetChannelId() string { @@ -11823,7 +11947,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11835,7 +11959,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11848,7 +11972,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *L7RequestSample) GetMethod() string { @@ -11922,7 +12046,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11934,7 +12058,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11947,7 +12071,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{171} } func (x *DenialSummary) GetSandboxId() string { @@ -12082,7 +12206,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12094,7 +12218,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12107,7 +12231,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -12140,7 +12264,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12152,7 +12276,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12165,7 +12289,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{173} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -12253,7 +12377,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12265,7 +12389,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12278,7 +12402,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *PolicyChunk) GetId() string { @@ -12466,7 +12590,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12478,7 +12602,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12491,7 +12615,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{175} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -12549,7 +12673,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12561,7 +12685,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12574,7 +12698,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { @@ -12637,7 +12761,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12649,7 +12773,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12662,7 +12786,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -12708,7 +12832,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12720,7 +12844,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12733,7 +12857,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *GetDraftPolicyRequest) GetName() string { @@ -12773,7 +12897,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12785,7 +12909,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[179] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12798,7 +12922,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{179} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -12847,7 +12971,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12859,7 +12983,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[180] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12872,7 +12996,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{180} } func (x *ApproveDraftChunkRequest) GetName() string { @@ -12915,7 +13039,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12927,7 +13051,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12940,7 +13064,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -12974,7 +13098,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12986,7 +13110,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12999,7 +13123,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{182} } func (x *RejectDraftChunkRequest) GetName() string { @@ -13038,7 +13162,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13050,7 +13174,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13063,7 +13187,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{183} } // Approve all pending chunks. @@ -13077,7 +13201,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13089,7 +13213,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13102,7 +13226,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{184} } func (x *DraftChunkApproval) GetChunkId() string { @@ -13136,7 +13260,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13148,7 +13272,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13161,7 +13285,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *ApproveAllDraftChunksRequest) GetName() string { @@ -13209,7 +13333,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13221,7 +13345,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13234,7 +13358,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{186} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -13282,7 +13406,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13294,7 +13418,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13307,7 +13431,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *EditDraftChunkRequest) GetName() string { @@ -13346,7 +13470,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13358,7 +13482,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13371,7 +13495,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{188} } // Reverse an approval (remove merged rule from active policy). @@ -13389,7 +13513,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13401,7 +13525,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13414,7 +13538,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *UndoDraftChunkRequest) GetName() string { @@ -13450,7 +13574,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13462,7 +13586,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13475,7 +13599,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -13505,7 +13629,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13517,7 +13641,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13530,7 +13654,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{189} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *ClearDraftChunksRequest) GetName() string { @@ -13557,7 +13681,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[192] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13569,7 +13693,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[192] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13582,7 +13706,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{190} + return file_openshell_proto_rawDescGZIP(), []int{192} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -13605,7 +13729,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[193] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13617,7 +13741,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[193] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13630,7 +13754,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{191} + return file_openshell_proto_rawDescGZIP(), []int{193} } func (x *GetDraftHistoryRequest) GetName() string { @@ -13664,7 +13788,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[194] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13676,7 +13800,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[194] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13689,7 +13813,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{192} + return file_openshell_proto_rawDescGZIP(), []int{194} } func (x *DraftHistoryEntry) GetTimestampMs() int64 { @@ -13730,7 +13854,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[195] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13742,7 +13866,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[195] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13755,7 +13879,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{193} + return file_openshell_proto_rawDescGZIP(), []int{195} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -13784,7 +13908,7 @@ type PolicyRevisionPayload struct { func (x *PolicyRevisionPayload) Reset() { *x = PolicyRevisionPayload{} - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[196] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13796,7 +13920,7 @@ func (x *PolicyRevisionPayload) String() string { func (*PolicyRevisionPayload) ProtoMessage() {} func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[196] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13809,7 +13933,7 @@ func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRevisionPayload.ProtoReflect.Descriptor instead. func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{194} + return file_openshell_proto_rawDescGZIP(), []int{196} } func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { @@ -13888,7 +14012,7 @@ type DraftChunkPayload struct { func (x *DraftChunkPayload) Reset() { *x = DraftChunkPayload{} - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[197] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13900,7 +14024,7 @@ func (x *DraftChunkPayload) String() string { func (*DraftChunkPayload) ProtoMessage() {} func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[197] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13913,7 +14037,7 @@ func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkPayload.ProtoReflect.Descriptor instead. func (*DraftChunkPayload) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{195} + return file_openshell_proto_rawDescGZIP(), []int{197} } func (x *DraftChunkPayload) GetRuleName() string { @@ -14061,7 +14185,7 @@ type StoredPolicyRevision struct { func (x *StoredPolicyRevision) Reset() { *x = StoredPolicyRevision{} - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[198] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14073,7 +14197,7 @@ func (x *StoredPolicyRevision) String() string { func (*StoredPolicyRevision) ProtoMessage() {} func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[198] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14086,7 +14210,7 @@ func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredPolicyRevision.ProtoReflect.Descriptor instead. func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{196} + return file_openshell_proto_rawDescGZIP(), []int{198} } func (x *StoredPolicyRevision) GetId() string { @@ -14195,7 +14319,7 @@ type StoredDraftChunk struct { func (x *StoredDraftChunk) Reset() { *x = StoredDraftChunk{} - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[199] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14207,7 +14331,7 @@ func (x *StoredDraftChunk) String() string { func (*StoredDraftChunk) ProtoMessage() {} func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[199] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14220,7 +14344,7 @@ func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredDraftChunk.ProtoReflect.Descriptor instead. func (*StoredDraftChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{197} + return file_openshell_proto_rawDescGZIP(), []int{199} } func (x *StoredDraftChunk) GetId() string { @@ -14411,7 +14535,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[200] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14423,7 +14547,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[200] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14436,7 +14560,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{198} + return file_openshell_proto_rawDescGZIP(), []int{200} } func (x *CreateWorkspaceRequest) GetName() string { @@ -14463,7 +14587,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[201] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14475,7 +14599,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[201] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14488,7 +14612,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{199} + return file_openshell_proto_rawDescGZIP(), []int{201} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -14509,7 +14633,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[202] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14521,7 +14645,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[202] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14534,7 +14658,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{200} + return file_openshell_proto_rawDescGZIP(), []int{202} } func (x *GetWorkspaceRequest) GetName() string { @@ -14554,7 +14678,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[203] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14566,7 +14690,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[203] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14579,7 +14703,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{201} + return file_openshell_proto_rawDescGZIP(), []int{203} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -14602,7 +14726,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[204] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14614,7 +14738,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[204] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14627,7 +14751,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{202} + return file_openshell_proto_rawDescGZIP(), []int{204} } func (x *ListWorkspacesRequest) GetLimit() uint32 { @@ -14661,7 +14785,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[205] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14673,7 +14797,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[205] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14686,7 +14810,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{203} + return file_openshell_proto_rawDescGZIP(), []int{205} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -14707,7 +14831,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[206] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14719,7 +14843,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[206] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14732,7 +14856,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{204} + return file_openshell_proto_rawDescGZIP(), []int{206} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -14752,7 +14876,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[207] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14764,7 +14888,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[207] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14777,7 +14901,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{205} + return file_openshell_proto_rawDescGZIP(), []int{207} } func (x *DeleteWorkspaceResponse) GetDeleted() bool { @@ -14801,7 +14925,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[208] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14813,7 +14937,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[208] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14826,7 +14950,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{206} + return file_openshell_proto_rawDescGZIP(), []int{208} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -14865,7 +14989,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[209] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14877,7 +15001,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[209] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14890,7 +15014,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{207} + return file_openshell_proto_rawDescGZIP(), []int{209} } func (x *AddWorkspaceMemberRequest) GetWorkspace() string { @@ -14924,7 +15048,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[210] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14936,7 +15060,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[210] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14949,7 +15073,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{208} + return file_openshell_proto_rawDescGZIP(), []int{210} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -14972,7 +15096,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[211] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14984,7 +15108,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[211] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14997,7 +15121,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{209} + return file_openshell_proto_rawDescGZIP(), []int{211} } func (x *RemoveWorkspaceMemberRequest) GetWorkspace() string { @@ -15024,7 +15148,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[212] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15036,7 +15160,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[212] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15049,7 +15173,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{210} + return file_openshell_proto_rawDescGZIP(), []int{212} } func (x *RemoveWorkspaceMemberResponse) GetRemoved() bool { @@ -15072,7 +15196,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[213] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15084,7 +15208,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[213] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15097,7 +15221,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{211} + return file_openshell_proto_rawDescGZIP(), []int{213} } func (x *ListWorkspaceMembersRequest) GetWorkspace() string { @@ -15131,7 +15255,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[214] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15143,7 +15267,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[214] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15156,7 +15280,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{212} + return file_openshell_proto_rawDescGZIP(), []int{214} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -15184,7 +15308,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[215] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15196,7 +15320,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[215] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15209,7 +15333,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{213} + return file_openshell_proto_rawDescGZIP(), []int{215} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -15241,7 +15365,18 @@ const file_openshell_proto_rawDesc = "" + "\x18IssueSandboxTokenRequest\"[\n" + "\x19IssueSandboxTokenResponse\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"T\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x1b\n" + + "\x19RegisterSupervisorRequest\"\xd9\x02\n" + + "\x1bSupervisorActivationMessage\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12!\n" + + "\fsandbox_name\x18\x02 \x01(\tR\vsandboxName\x12\x1a\n" + + "\x05token\x18\x03 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12-\n" + + "\x13token_expires_at_ms\x18\x04 \x01(\x03R\x10tokenExpiresAtMs\x12i\n" + + "\x10startup_metadata\x18\x05 \x03(\v2>.openshell.v1.SupervisorActivationMessage.StartupMetadataEntryR\x0fstartupMetadata\x1aB\n" + + "\x14StartupMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"T\n" + "\x1aRefreshSandboxTokenRequest\x126\n" + "\x17extension_service_names\x18\x01 \x03(\tR\x15extensionServiceNames\"\xbc\x01\n" + "\x1bRefreshSandboxTokenResponse\x12\x1a\n" + @@ -16442,7 +16577,7 @@ const file_openshell_proto_rawDesc = "" + "1PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY\x10\x01\x12;\n" + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE\x10\x02\x12A\n" + "=PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION\x10\x03\x12;\n" + - "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\x8dM\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\x99N\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -16578,7 +16713,9 @@ const file_openshell_proto_rawDesc = "" + "\x0fGetDraftHistory\x12$.openshell.v1.GetDraftHistoryRequest\x1a%.openshell.v1.GetDraftHistoryResponse\"\x1f\x82\xb5\x18\x1b\n" + "\x06bearer\x12\x04user\"\vconfig:read\x12s\n" + "\x11IssueSandboxToken\x12&.openshell.v1.IssueSandboxTokenRequest\x1a'.openshell.v1.IssueSandboxTokenResponse\"\r\x82\xb5\x18\t\n" + - "\asandbox\x12y\n" + + "\asandbox\x12\x89\x01\n" + + "\x12RegisterSupervisor\x12'.openshell.v1.RegisterSupervisorRequest\x1a).openshell.v1.SupervisorActivationMessage\"\x1d\x82\xb5\x18\x19\n" + + "\x17supervisor_registration0\x01\x12y\n" + "\x13RefreshSandboxToken\x12(.openshell.v1.RefreshSandboxTokenRequest\x1a).openshell.v1.RefreshSandboxTokenResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12\x8d\x01\n" + "\x0fCreateWorkspace\x12$.openshell.v1.CreateWorkspaceRequest\x1a%.openshell.v1.CreateWorkspaceResponse\"-\x82\xb5\x18)\n" + @@ -16609,7 +16746,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 240) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 243) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialTokenGrantType)(0), // 1: openshell.v1.ProviderCredentialTokenGrantType @@ -16621,600 +16758,606 @@ var file_openshell_proto_goTypes = []any{ (ProviderCredentialRefreshRecoveryAction)(0), // 7: openshell.v1.ProviderCredentialRefreshRecoveryAction (*IssueSandboxTokenRequest)(nil), // 8: openshell.v1.IssueSandboxTokenRequest (*IssueSandboxTokenResponse)(nil), // 9: openshell.v1.IssueSandboxTokenResponse - (*RefreshSandboxTokenRequest)(nil), // 10: openshell.v1.RefreshSandboxTokenRequest - (*RefreshSandboxTokenResponse)(nil), // 11: openshell.v1.RefreshSandboxTokenResponse - (*HealthRequest)(nil), // 12: openshell.v1.HealthRequest - (*HealthResponse)(nil), // 13: openshell.v1.HealthResponse - (*GetCurrentUserRequest)(nil), // 14: openshell.v1.GetCurrentUserRequest - (*GetCurrentUserResponse)(nil), // 15: openshell.v1.GetCurrentUserResponse - (*GetGatewayInfoRequest)(nil), // 16: openshell.v1.GetGatewayInfoRequest - (*GetGatewayInfoResponse)(nil), // 17: openshell.v1.GetGatewayInfoResponse - (*ComputeDriverInfo)(nil), // 18: openshell.v1.ComputeDriverInfo - (*ComputeDriverCapabilities)(nil), // 19: openshell.v1.ComputeDriverCapabilities - (*ResourceCapabilities)(nil), // 20: openshell.v1.ResourceCapabilities - (*CpuResourceCapabilities)(nil), // 21: openshell.v1.CpuResourceCapabilities - (*MemoryResourceCapabilities)(nil), // 22: openshell.v1.MemoryResourceCapabilities - (*GpuResourceCapabilities)(nil), // 23: openshell.v1.GpuResourceCapabilities - (*Sandbox)(nil), // 24: openshell.v1.Sandbox - (*SandboxSpec)(nil), // 25: openshell.v1.SandboxSpec - (*ResourceRequirements)(nil), // 26: openshell.v1.ResourceRequirements - (*GpuResourceRequirements)(nil), // 27: openshell.v1.GpuResourceRequirements - (*SandboxTemplate)(nil), // 28: openshell.v1.SandboxTemplate - (*SandboxWorkloadTemplate)(nil), // 29: openshell.v1.SandboxWorkloadTemplate - (*SandboxWorkloadTemplateSpec)(nil), // 30: openshell.v1.SandboxWorkloadTemplateSpec - (*SandboxWorkloadConfig)(nil), // 31: openshell.v1.SandboxWorkloadConfig - (*SandboxResources)(nil), // 32: openshell.v1.SandboxResources - (*SandboxServiceLevel)(nil), // 33: openshell.v1.SandboxServiceLevel - (*SandboxStartup)(nil), // 34: openshell.v1.SandboxStartup - (*SandboxWorkloadTemplateProvenance)(nil), // 35: openshell.v1.SandboxWorkloadTemplateProvenance - (*SandboxStatus)(nil), // 36: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 37: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 38: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 39: openshell.v1.CreateSandboxRequest - (*CreateSandboxTemplateRequest)(nil), // 40: openshell.v1.CreateSandboxTemplateRequest - (*GetSandboxTemplateRequest)(nil), // 41: openshell.v1.GetSandboxTemplateRequest - (*ListSandboxTemplatesRequest)(nil), // 42: openshell.v1.ListSandboxTemplatesRequest - (*DeleteSandboxTemplateRequest)(nil), // 43: openshell.v1.DeleteSandboxTemplateRequest - (*SandboxTemplateResponse)(nil), // 44: openshell.v1.SandboxTemplateResponse - (*ListSandboxTemplatesResponse)(nil), // 45: openshell.v1.ListSandboxTemplatesResponse - (*DeleteSandboxTemplateResponse)(nil), // 46: openshell.v1.DeleteSandboxTemplateResponse - (*BeginRootfsTarStagingRequest)(nil), // 47: openshell.v1.BeginRootfsTarStagingRequest - (*BeginRootfsTarStagingResponse)(nil), // 48: openshell.v1.BeginRootfsTarStagingResponse - (*GetSandboxRequest)(nil), // 49: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 50: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 51: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 52: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 53: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 54: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 55: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 56: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 57: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 58: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 59: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 60: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 61: openshell.v1.DetachSandboxProviderResponse - (*DeleteSandboxResponse)(nil), // 62: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 63: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 64: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 65: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 66: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 67: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 68: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 69: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 70: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 71: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 72: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 73: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 74: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 75: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 76: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 77: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 78: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 79: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 80: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 81: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 82: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 83: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 84: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 85: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 86: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 87: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 88: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 89: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 90: openshell.v1.GetProviderRequest - (*ListProvidersRequest)(nil), // 91: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 92: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 93: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 94: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 95: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 96: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 97: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 98: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 99: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 100: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrantSubjectToken)(nil), // 101: openshell.v1.ProviderCredentialTokenGrantSubjectToken - (*ProviderCredentialTokenGrant)(nil), // 102: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 103: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 104: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 105: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 106: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 107: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 108: openshell.v1.ProviderProfileDiscovery - (*StoredProviderCredentialRefreshState)(nil), // 109: openshell.v1.StoredProviderCredentialRefreshState - (*StoredRefreshMaterialDeletion)(nil), // 110: openshell.v1.StoredRefreshMaterialDeletion - (*GetProviderRefreshStatusRequest)(nil), // 111: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 112: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 113: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 114: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 115: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 116: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 117: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 118: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 119: openshell.v1.ProviderProfile - (*StoredProviderProfile)(nil), // 120: openshell.v1.StoredProviderProfile - (*ProviderProfileResponse)(nil), // 121: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 122: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 123: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 124: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 125: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 126: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 127: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 128: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 129: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 130: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 131: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 132: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 133: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 134: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 135: openshell.v1.GetSandboxProviderEnvironmentResponse - (*ExchangeProviderSubjectTokenRequest)(nil), // 136: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 137: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 138: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 139: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 140: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 141: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 142: openshell.v1.RemoveNetworkRule - (*AddDenyRules)(nil), // 143: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 144: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 145: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 146: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 147: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 148: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 149: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 150: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 151: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 152: openshell.v1.ReportPolicyStatusResponse - (*SandboxPolicyRevision)(nil), // 153: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 154: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 155: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 156: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 157: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 158: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 159: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 160: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 161: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 162: openshell.v1.SessionRejected - (*SupervisorHeartbeat)(nil), // 163: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 164: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 165: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 166: openshell.v1.ReportMainProcessExitResponse - (*FinalizeMainProcessExitRequest)(nil), // 167: openshell.v1.FinalizeMainProcessExitRequest - (*FinalizeMainProcessExitResponse)(nil), // 168: openshell.v1.FinalizeMainProcessExitResponse - (*RelayOpen)(nil), // 169: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 170: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 171: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 172: openshell.v1.RelayInit - (*RelayFrame)(nil), // 173: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 174: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 175: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 176: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 177: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 178: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 179: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 180: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 181: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 182: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 183: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 184: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 185: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 186: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 187: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 188: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 189: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 190: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 191: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 192: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 193: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 194: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 195: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 196: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 197: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 198: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 199: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 200: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 201: openshell.v1.GetDraftHistoryResponse - (*PolicyRevisionPayload)(nil), // 202: openshell.v1.PolicyRevisionPayload - (*DraftChunkPayload)(nil), // 203: openshell.v1.DraftChunkPayload - (*StoredPolicyRevision)(nil), // 204: openshell.v1.StoredPolicyRevision - (*StoredDraftChunk)(nil), // 205: openshell.v1.StoredDraftChunk - (*CreateWorkspaceRequest)(nil), // 206: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 207: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 208: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 209: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 210: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 211: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 212: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 213: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 214: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 215: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 216: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 217: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 218: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 219: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 220: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 221: openshell.v1.ExtensionServiceCredential - nil, // 222: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 223: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 224: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 225: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 226: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - nil, // 227: openshell.v1.PlatformEvent.MetadataEntry - nil, // 228: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 229: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 230: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 231: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 232: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 233: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 234: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 235: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 236: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 237: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 238: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 239: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 240: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 241: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 242: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 243: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 244: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 245: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 246: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 247: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 248: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 249: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 250: google.protobuf.Struct - (*durationpb.Duration)(nil), // 251: google.protobuf.Duration - (*datamodelv1.Provider)(nil), // 252: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 253: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 254: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 255: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 256: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 257: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 258: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 259: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 260: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 261: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 262: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 263: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 264: openshell.sandbox.v1.GetGatewayConfigResponse + (*RegisterSupervisorRequest)(nil), // 10: openshell.v1.RegisterSupervisorRequest + (*SupervisorActivationMessage)(nil), // 11: openshell.v1.SupervisorActivationMessage + (*RefreshSandboxTokenRequest)(nil), // 12: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 13: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 14: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 15: openshell.v1.HealthResponse + (*GetCurrentUserRequest)(nil), // 16: openshell.v1.GetCurrentUserRequest + (*GetCurrentUserResponse)(nil), // 17: openshell.v1.GetCurrentUserResponse + (*GetGatewayInfoRequest)(nil), // 18: openshell.v1.GetGatewayInfoRequest + (*GetGatewayInfoResponse)(nil), // 19: openshell.v1.GetGatewayInfoResponse + (*ComputeDriverInfo)(nil), // 20: openshell.v1.ComputeDriverInfo + (*ComputeDriverCapabilities)(nil), // 21: openshell.v1.ComputeDriverCapabilities + (*ResourceCapabilities)(nil), // 22: openshell.v1.ResourceCapabilities + (*CpuResourceCapabilities)(nil), // 23: openshell.v1.CpuResourceCapabilities + (*MemoryResourceCapabilities)(nil), // 24: openshell.v1.MemoryResourceCapabilities + (*GpuResourceCapabilities)(nil), // 25: openshell.v1.GpuResourceCapabilities + (*Sandbox)(nil), // 26: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 27: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 28: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 29: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 30: openshell.v1.SandboxTemplate + (*SandboxWorkloadTemplate)(nil), // 31: openshell.v1.SandboxWorkloadTemplate + (*SandboxWorkloadTemplateSpec)(nil), // 32: openshell.v1.SandboxWorkloadTemplateSpec + (*SandboxWorkloadConfig)(nil), // 33: openshell.v1.SandboxWorkloadConfig + (*SandboxResources)(nil), // 34: openshell.v1.SandboxResources + (*SandboxServiceLevel)(nil), // 35: openshell.v1.SandboxServiceLevel + (*SandboxStartup)(nil), // 36: openshell.v1.SandboxStartup + (*SandboxWorkloadTemplateProvenance)(nil), // 37: openshell.v1.SandboxWorkloadTemplateProvenance + (*SandboxStatus)(nil), // 38: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 39: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 40: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 41: openshell.v1.CreateSandboxRequest + (*CreateSandboxTemplateRequest)(nil), // 42: openshell.v1.CreateSandboxTemplateRequest + (*GetSandboxTemplateRequest)(nil), // 43: openshell.v1.GetSandboxTemplateRequest + (*ListSandboxTemplatesRequest)(nil), // 44: openshell.v1.ListSandboxTemplatesRequest + (*DeleteSandboxTemplateRequest)(nil), // 45: openshell.v1.DeleteSandboxTemplateRequest + (*SandboxTemplateResponse)(nil), // 46: openshell.v1.SandboxTemplateResponse + (*ListSandboxTemplatesResponse)(nil), // 47: openshell.v1.ListSandboxTemplatesResponse + (*DeleteSandboxTemplateResponse)(nil), // 48: openshell.v1.DeleteSandboxTemplateResponse + (*BeginRootfsTarStagingRequest)(nil), // 49: openshell.v1.BeginRootfsTarStagingRequest + (*BeginRootfsTarStagingResponse)(nil), // 50: openshell.v1.BeginRootfsTarStagingResponse + (*GetSandboxRequest)(nil), // 51: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 52: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 53: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 54: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 55: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 56: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 57: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 58: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 59: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 60: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 61: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 62: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 63: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 64: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 65: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 66: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 67: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 68: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 69: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 70: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 71: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 72: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 73: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 74: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 75: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 76: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 77: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 78: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 79: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 80: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 81: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 82: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 83: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 84: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 85: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 86: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 87: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 88: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 89: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 90: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 91: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 92: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 93: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 94: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 95: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 96: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 97: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 98: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 99: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 100: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 101: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 102: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 103: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 104: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 105: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 106: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 107: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 108: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 109: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 110: openshell.v1.ProviderProfileDiscovery + (*StoredProviderCredentialRefreshState)(nil), // 111: openshell.v1.StoredProviderCredentialRefreshState + (*StoredRefreshMaterialDeletion)(nil), // 112: openshell.v1.StoredRefreshMaterialDeletion + (*GetProviderRefreshStatusRequest)(nil), // 113: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 114: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 115: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 116: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 117: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 118: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 119: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 120: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 121: openshell.v1.ProviderProfile + (*StoredProviderProfile)(nil), // 122: openshell.v1.StoredProviderProfile + (*ProviderProfileResponse)(nil), // 123: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 124: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 125: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 126: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 127: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 128: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 129: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 130: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 131: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 132: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 133: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 134: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 135: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 136: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 137: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 138: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 139: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 140: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 141: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 142: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 143: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 144: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 145: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 146: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 147: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 148: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 149: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 150: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 151: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 152: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 153: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 154: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 155: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 156: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 157: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 158: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 159: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 160: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 161: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 162: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 163: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 164: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 165: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 166: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 167: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 168: openshell.v1.ReportMainProcessExitResponse + (*FinalizeMainProcessExitRequest)(nil), // 169: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 170: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 171: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 172: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 173: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 174: openshell.v1.RelayInit + (*RelayFrame)(nil), // 175: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 176: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 177: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 178: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 179: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 180: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 181: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 182: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 183: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 184: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 185: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 186: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 187: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 188: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 189: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 190: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 191: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 192: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 193: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 194: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 195: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 196: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 197: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 198: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 199: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 200: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 201: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 202: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 203: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 204: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 205: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 206: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 207: openshell.v1.StoredDraftChunk + (*CreateWorkspaceRequest)(nil), // 208: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 209: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 210: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 211: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 212: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 213: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 214: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 215: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 216: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 217: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 218: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 219: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 220: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 221: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 222: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 223: openshell.v1.ExtensionServiceCredential + nil, // 224: openshell.v1.SupervisorActivationMessage.StartupMetadataEntry + nil, // 225: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 226: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 227: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 228: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 229: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + nil, // 230: openshell.v1.PlatformEvent.MetadataEntry + nil, // 231: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 232: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 233: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 234: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 235: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 236: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 237: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 238: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 239: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 240: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 241: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 242: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 243: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 244: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 245: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 246: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 247: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 248: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 249: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 250: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 251: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 252: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 253: google.protobuf.Struct + (*durationpb.Duration)(nil), // 254: google.protobuf.Duration + (*datamodelv1.Provider)(nil), // 255: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 256: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 257: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 258: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 259: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 260: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 261: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 262: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 263: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 264: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 265: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 266: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 267: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 221, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential - 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus - 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 18, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 19, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 20, // 5: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities - 21, // 6: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities - 22, // 7: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities - 23, // 8: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities - 248, // 9: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 25, // 10: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 36, // 11: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 35, // 12: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance - 222, // 13: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 28, // 14: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 249, // 15: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 26, // 16: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 27, // 17: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 223, // 18: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 224, // 19: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 225, // 20: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 250, // 21: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 250, // 22: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 248, // 23: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 30, // 24: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec - 31, // 25: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig - 250, // 26: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct - 33, // 27: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel - 226, // 28: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - 32, // 29: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources - 27, // 30: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements - 34, // 31: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup - 251, // 32: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration - 37, // 33: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition - 0, // 34: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 227, // 35: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 25, // 36: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 228, // 37: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 229, // 38: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 29, // 39: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 29, // 40: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 29, // 41: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate - 24, // 42: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 24, // 43: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 252, // 44: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 24, // 45: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 24, // 46: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 72, // 47: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 248, // 48: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 71, // 49: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 230, // 50: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 76, // 51: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 77, // 52: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 78, // 53: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 170, // 54: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 171, // 55: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 80, // 56: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 75, // 57: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 83, // 58: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 248, // 59: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 24, // 60: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 87, // 61: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 38, // 62: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 88, // 63: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 181, // 64: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 231, // 65: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 252, // 66: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 252, // 67: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 232, // 68: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 252, // 69: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 252, // 70: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 119, // 71: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 100, // 72: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 1, // 73: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 101, // 74: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 106, // 75: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 102, // 76: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 2, // 77: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 104, // 78: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 105, // 79: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 2, // 80: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 7, // 81: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 248, // 82: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 2, // 83: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 233, // 84: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 234, // 85: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 235, // 86: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - 110, // 87: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion - 7, // 88: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 253, // 89: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle - 107, // 90: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 91: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 236, // 92: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 107, // 93: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 107, // 94: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 3, // 95: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 103, // 96: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 254, // 97: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 255, // 98: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 108, // 99: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 237, // 100: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 248, // 101: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 119, // 102: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile - 119, // 103: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 119, // 104: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 98, // 105: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 106: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 119, // 107: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 98, // 108: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 109: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 119, // 110: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 98, // 111: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 112: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 133, // 113: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 238, // 114: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 239, // 115: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 240, // 116: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 241, // 117: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 249, // 118: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 256, // 119: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 139, // 120: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 242, // 121: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 140, // 122: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 141, // 123: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 142, // 124: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 143, // 125: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 144, // 126: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 145, // 127: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 257, // 128: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 258, // 129: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 259, // 130: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 243, // 131: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 153, // 132: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 153, // 133: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 4, // 134: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 4, // 135: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 249, // 136: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 244, // 137: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 87, // 138: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 87, // 139: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 160, // 140: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 163, // 141: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 174, // 142: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 175, // 143: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 161, // 144: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 162, // 145: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 164, // 146: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 169, // 147: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 175, // 148: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 170, // 149: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 171, // 150: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 172, // 151: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 176, // 152: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 178, // 153: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 257, // 154: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 249, // 155: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 156: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 177, // 157: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 180, // 158: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 179, // 159: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 180, // 160: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 190, // 161: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 257, // 162: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 200, // 163: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 249, // 164: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 245, // 165: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 257, // 166: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 249, // 167: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 168: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 246, // 169: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 249, // 170: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 171: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 247, // 172: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 260, // 173: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 260, // 174: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 260, // 175: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 248, // 176: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 6, // 177: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 6, // 178: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 214, // 179: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 214, // 180: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 253, // 181: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 103, // 182: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 134, // 183: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 12, // 184: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 14, // 185: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 16, // 186: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 39, // 187: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 47, // 188: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 49, // 189: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 50, // 190: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 40, // 191: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 41, // 192: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 42, // 193: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 43, // 194: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 51, // 195: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 52, // 196: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 53, // 197: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 54, // 198: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 55, // 199: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 56, // 200: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 63, // 201: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 65, // 202: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 66, // 203: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 67, // 204: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 69, // 205: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 73, // 206: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 75, // 207: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 81, // 208: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 82, // 209: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 89, // 210: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 90, // 211: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 91, // 212: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 96, // 213: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 97, // 214: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 123, // 215: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 125, // 216: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 127, // 217: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 92, // 218: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 111, // 219: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 113, // 220: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 115, // 221: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 117, // 222: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 93, // 223: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 130, // 224: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 261, // 225: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 262, // 226: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 138, // 227: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 147, // 228: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 149, // 229: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 151, // 230: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 132, // 231: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 136, // 232: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 154, // 233: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 155, // 234: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 158, // 235: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 165, // 236: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 167, // 237: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 173, // 238: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 85, // 239: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 182, // 240: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 184, // 241: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 186, // 242: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 188, // 243: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 191, // 244: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 193, // 245: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 195, // 246: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 197, // 247: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 199, // 248: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 8, // 249: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 10, // 250: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 206, // 251: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 208, // 252: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 210, // 253: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 212, // 254: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 215, // 255: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 217, // 256: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 219, // 257: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 13, // 258: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 15, // 259: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 17, // 260: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 57, // 261: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 48, // 262: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 57, // 263: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 58, // 264: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 44, // 265: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 44, // 266: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 45, // 267: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 46, // 268: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 59, // 269: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 60, // 270: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 61, // 271: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 62, // 272: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 57, // 273: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 57, // 274: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 64, // 275: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 72, // 276: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 72, // 277: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 68, // 278: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 70, // 279: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 74, // 280: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 79, // 281: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 81, // 282: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 79, // 283: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 94, // 284: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 94, // 285: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 95, // 286: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 122, // 287: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 121, // 288: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 124, // 289: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 126, // 290: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 128, // 291: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 94, // 292: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 112, // 293: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 114, // 294: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 116, // 295: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 118, // 296: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 129, // 297: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 131, // 298: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 263, // 299: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 264, // 300: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 146, // 301: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 148, // 302: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 150, // 303: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 152, // 304: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 135, // 305: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 137, // 306: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 157, // 307: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 156, // 308: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 159, // 309: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 166, // 310: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 168, // 311: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 173, // 312: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 86, // 313: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 183, // 314: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 185, // 315: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 187, // 316: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 189, // 317: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 192, // 318: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 194, // 319: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 196, // 320: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 198, // 321: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 201, // 322: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 9, // 323: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 11, // 324: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 207, // 325: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 209, // 326: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 211, // 327: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 213, // 328: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 216, // 329: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 218, // 330: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 220, // 331: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 258, // [258:332] is the sub-list for method output_type - 184, // [184:258] is the sub-list for method input_type - 184, // [184:184] is the sub-list for extension type_name - 184, // [184:184] is the sub-list for extension extendee - 0, // [0:184] is the sub-list for field type_name + 224, // 0: openshell.v1.SupervisorActivationMessage.startup_metadata:type_name -> openshell.v1.SupervisorActivationMessage.StartupMetadataEntry + 223, // 1: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 5, // 2: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 5, // 3: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus + 20, // 4: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 21, // 5: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 22, // 6: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities + 23, // 7: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities + 24, // 8: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities + 25, // 9: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities + 251, // 10: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 27, // 11: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 38, // 12: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 37, // 13: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance + 225, // 14: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 30, // 15: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 252, // 16: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 28, // 17: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 29, // 18: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 226, // 19: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 227, // 20: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 228, // 21: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 253, // 22: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 253, // 23: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 251, // 24: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 32, // 25: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec + 33, // 26: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig + 253, // 27: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 35, // 28: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel + 229, // 29: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 34, // 30: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources + 29, // 31: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements + 36, // 32: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup + 254, // 33: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 39, // 34: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 0, // 35: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 230, // 36: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 27, // 37: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 231, // 38: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 232, // 39: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 31, // 40: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 31, // 41: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 31, // 42: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 26, // 43: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 26, // 44: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 255, // 45: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 26, // 46: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 26, // 47: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 74, // 48: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 251, // 49: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 73, // 50: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 233, // 51: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 78, // 52: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 79, // 53: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 80, // 54: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 172, // 55: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 173, // 56: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 82, // 57: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 77, // 58: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 85, // 59: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 251, // 60: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 26, // 61: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 89, // 62: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 40, // 63: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 90, // 64: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 183, // 65: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 234, // 66: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 255, // 67: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 255, // 68: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 235, // 69: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 255, // 70: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 255, // 71: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 121, // 72: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 102, // 73: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 1, // 74: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 103, // 75: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 108, // 76: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 104, // 77: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 2, // 78: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 106, // 79: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 107, // 80: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 2, // 81: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 7, // 82: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 251, // 83: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 2, // 84: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 236, // 85: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 237, // 86: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 238, // 87: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 112, // 88: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion + 7, // 89: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 256, // 90: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 109, // 91: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 92: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 239, // 93: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 109, // 94: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 109, // 95: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 3, // 96: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 105, // 97: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 257, // 98: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 258, // 99: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 110, // 100: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 240, // 101: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 251, // 102: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 121, // 103: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 121, // 104: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 121, // 105: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 100, // 106: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 101, // 107: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 121, // 108: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 100, // 109: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 101, // 110: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 121, // 111: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 100, // 112: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 101, // 113: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 135, // 114: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 241, // 115: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 242, // 116: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 243, // 117: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 244, // 118: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 252, // 119: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 259, // 120: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 141, // 121: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 245, // 122: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 142, // 123: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 143, // 124: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 144, // 125: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 145, // 126: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 146, // 127: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 147, // 128: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 260, // 129: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 261, // 130: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 262, // 131: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 246, // 132: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 155, // 133: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 155, // 134: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 135: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 136: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 252, // 137: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 247, // 138: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 89, // 139: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 89, // 140: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 162, // 141: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 165, // 142: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 176, // 143: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 177, // 144: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 163, // 145: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 164, // 146: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 166, // 147: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 171, // 148: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 177, // 149: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 172, // 150: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 173, // 151: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 174, // 152: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 178, // 153: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 180, // 154: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 260, // 155: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 252, // 156: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 252, // 157: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 179, // 158: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 182, // 159: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 181, // 160: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 182, // 161: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 192, // 162: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 260, // 163: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 202, // 164: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 252, // 165: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 248, // 166: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 260, // 167: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 252, // 168: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 252, // 169: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 249, // 170: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 252, // 171: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 252, // 172: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 250, // 173: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 263, // 174: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 263, // 175: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 263, // 176: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 251, // 177: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 178: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 179: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 216, // 180: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 216, // 181: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 256, // 182: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 105, // 183: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 136, // 184: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 14, // 185: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 16, // 186: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 18, // 187: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 41, // 188: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 49, // 189: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 51, // 190: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 52, // 191: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 42, // 192: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 43, // 193: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 44, // 194: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 45, // 195: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 53, // 196: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 54, // 197: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 55, // 198: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 56, // 199: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 57, // 200: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 58, // 201: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 65, // 202: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 67, // 203: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 68, // 204: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 69, // 205: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 71, // 206: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 75, // 207: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 77, // 208: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 83, // 209: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 84, // 210: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 91, // 211: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 92, // 212: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 93, // 213: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 98, // 214: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 99, // 215: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 125, // 216: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 127, // 217: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 129, // 218: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 94, // 219: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 113, // 220: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 115, // 221: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 117, // 222: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 119, // 223: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 95, // 224: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 132, // 225: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 264, // 226: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 265, // 227: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 140, // 228: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 149, // 229: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 151, // 230: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 153, // 231: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 134, // 232: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 138, // 233: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 156, // 234: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 157, // 235: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 160, // 236: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 167, // 237: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 169, // 238: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 175, // 239: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 87, // 240: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 184, // 241: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 186, // 242: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 188, // 243: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 190, // 244: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 193, // 245: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 195, // 246: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 197, // 247: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 199, // 248: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 201, // 249: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 8, // 250: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 10, // 251: openshell.v1.OpenShell.RegisterSupervisor:input_type -> openshell.v1.RegisterSupervisorRequest + 12, // 252: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 208, // 253: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 210, // 254: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 212, // 255: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 214, // 256: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 217, // 257: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 219, // 258: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 221, // 259: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 15, // 260: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 17, // 261: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 19, // 262: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 59, // 263: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 50, // 264: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 59, // 265: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 60, // 266: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 46, // 267: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 46, // 268: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 47, // 269: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 48, // 270: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 61, // 271: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 62, // 272: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 63, // 273: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 64, // 274: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 59, // 275: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 59, // 276: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 66, // 277: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 74, // 278: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 74, // 279: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 70, // 280: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 72, // 281: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 76, // 282: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 81, // 283: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 83, // 284: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 81, // 285: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 96, // 286: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 96, // 287: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 97, // 288: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 124, // 289: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 123, // 290: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 126, // 291: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 128, // 292: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 130, // 293: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 96, // 294: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 114, // 295: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 116, // 296: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 118, // 297: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 120, // 298: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 131, // 299: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 133, // 300: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 266, // 301: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 267, // 302: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 148, // 303: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 150, // 304: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 152, // 305: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 154, // 306: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 137, // 307: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 139, // 308: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 159, // 309: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 158, // 310: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 161, // 311: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 168, // 312: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 170, // 313: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 175, // 314: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 88, // 315: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 185, // 316: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 187, // 317: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 189, // 318: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 191, // 319: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 194, // 320: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 196, // 321: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 198, // 322: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 200, // 323: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 203, // 324: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 9, // 325: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 11, // 326: openshell.v1.OpenShell.RegisterSupervisor:output_type -> openshell.v1.SupervisorActivationMessage + 13, // 327: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 209, // 328: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 211, // 329: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 213, // 330: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 215, // 331: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 218, // 332: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 220, // 333: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 222, // 334: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 260, // [260:335] is the sub-list for method output_type + 185, // [185:260] is the sub-list for method input_type + 185, // [185:185] is the sub-list for extension type_name + 185, // [185:185] is the sub-list for extension extendee + 0, // [0:185] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -17222,36 +17365,36 @@ func file_openshell_proto_init() { if File_openshell_proto != nil { return } - file_openshell_proto_msgTypes[19].OneofWrappers = []any{} - file_openshell_proto_msgTypes[20].OneofWrappers = []any{} - file_openshell_proto_msgTypes[28].OneofWrappers = []any{} - file_openshell_proto_msgTypes[71].OneofWrappers = []any{ + file_openshell_proto_msgTypes[21].OneofWrappers = []any{} + file_openshell_proto_msgTypes[22].OneofWrappers = []any{} + file_openshell_proto_msgTypes[30].OneofWrappers = []any{} + file_openshell_proto_msgTypes[73].OneofWrappers = []any{ (*ExecSandboxEvent_Stdout)(nil), (*ExecSandboxEvent_Stderr)(nil), (*ExecSandboxEvent_Exit)(nil), } - file_openshell_proto_msgTypes[72].OneofWrappers = []any{ + file_openshell_proto_msgTypes[74].OneofWrappers = []any{ (*TcpForwardInit_Ssh)(nil), (*TcpForwardInit_Tcp)(nil), } - file_openshell_proto_msgTypes[73].OneofWrappers = []any{ + file_openshell_proto_msgTypes[75].OneofWrappers = []any{ (*TcpForwardFrame_Init)(nil), (*TcpForwardFrame_Data)(nil), } - file_openshell_proto_msgTypes[74].OneofWrappers = []any{ + file_openshell_proto_msgTypes[76].OneofWrappers = []any{ (*ExecSandboxInput_Start)(nil), (*ExecSandboxInput_Stdin)(nil), (*ExecSandboxInput_Resize)(nil), } - file_openshell_proto_msgTypes[78].OneofWrappers = []any{ + file_openshell_proto_msgTypes[80].OneofWrappers = []any{ (*SandboxStreamEvent_Sandbox)(nil), (*SandboxStreamEvent_Log)(nil), (*SandboxStreamEvent_Event)(nil), (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[105].OneofWrappers = []any{} - file_openshell_proto_msgTypes[131].OneofWrappers = []any{ + file_openshell_proto_msgTypes[107].OneofWrappers = []any{} + file_openshell_proto_msgTypes[133].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), (*PolicyMergeOperation_RemoveRule)(nil), @@ -17259,36 +17402,36 @@ func file_openshell_proto_init() { (*PolicyMergeOperation_AddAllowRules)(nil), (*PolicyMergeOperation_RemoveBinary)(nil), } - file_openshell_proto_msgTypes[150].OneofWrappers = []any{ + file_openshell_proto_msgTypes[152].OneofWrappers = []any{ (*SupervisorMessage_Hello)(nil), (*SupervisorMessage_Heartbeat)(nil), (*SupervisorMessage_RelayOpenResult)(nil), (*SupervisorMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[151].OneofWrappers = []any{ + file_openshell_proto_msgTypes[153].OneofWrappers = []any{ (*GatewayMessage_SessionAccepted)(nil), (*GatewayMessage_SessionRejected)(nil), (*GatewayMessage_Heartbeat)(nil), (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[161].OneofWrappers = []any{ + file_openshell_proto_msgTypes[163].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[165].OneofWrappers = []any{ + file_openshell_proto_msgTypes[167].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } - file_openshell_proto_msgTypes[196].OneofWrappers = []any{} - file_openshell_proto_msgTypes[197].OneofWrappers = []any{} + file_openshell_proto_msgTypes[198].OneofWrappers = []any{} + file_openshell_proto_msgTypes[199].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 8, - NumMessages: 240, + NumMessages: 243, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index d8f3c91008..cb758525e1 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -89,6 +89,7 @@ const ( OpenShell_ClearDraftChunks_FullMethodName = "/openshell.v1.OpenShell/ClearDraftChunks" OpenShell_GetDraftHistory_FullMethodName = "/openshell.v1.OpenShell/GetDraftHistory" OpenShell_IssueSandboxToken_FullMethodName = "/openshell.v1.OpenShell/IssueSandboxToken" + OpenShell_RegisterSupervisor_FullMethodName = "/openshell.v1.OpenShell/RegisterSupervisor" OpenShell_RefreshSandboxToken_FullMethodName = "/openshell.v1.OpenShell/RefreshSandboxToken" OpenShell_CreateWorkspace_FullMethodName = "/openshell.v1.OpenShell/CreateWorkspace" OpenShell_GetWorkspace_FullMethodName = "/openshell.v1.OpenShell/GetWorkspace" @@ -285,6 +286,12 @@ type OpenShellClient interface { // drivers receive the gateway JWT directly from the create-sandbox flow // and never call this RPC. IssueSandboxToken(ctx context.Context, in *IssueSandboxTokenRequest, opts ...grpc.CallOption) (*IssueSandboxTokenResponse, error) + // Register a supervisor instance and wait for gateway activation. + // + // The initial trial activates already-bound supervisor instances immediately. Later + // warm-pool stages keep this stream pending until a SandboxClaim adopts the + // registered instance. + RegisterSupervisor(ctx context.Context, in *RegisterSupervisorRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SupervisorActivationMessage], error) // Renew the calling sandbox's gateway JWT. Older tokens remain valid // until their own expiry; deployments should keep token TTLs short to // bound replay exposure. The supervisor calls this from a background @@ -1009,6 +1016,25 @@ func (c *openShellClient) IssueSandboxToken(ctx context.Context, in *IssueSandbo return out, nil } +func (c *openShellClient) RegisterSupervisor(ctx context.Context, in *RegisterSupervisorRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SupervisorActivationMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[7], OpenShell_RegisterSupervisor_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[RegisterSupervisorRequest, SupervisorActivationMessage]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_RegisterSupervisorClient = grpc.ServerStreamingClient[SupervisorActivationMessage] + func (c *openShellClient) RefreshSandboxToken(ctx context.Context, in *RefreshSandboxTokenRequest, opts ...grpc.CallOption) (*RefreshSandboxTokenResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RefreshSandboxTokenResponse) @@ -1275,6 +1301,12 @@ type OpenShellServer interface { // drivers receive the gateway JWT directly from the create-sandbox flow // and never call this RPC. IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) + // Register a supervisor instance and wait for gateway activation. + // + // The initial trial activates already-bound supervisor instances immediately. Later + // warm-pool stages keep this stream pending until a SandboxClaim adopts the + // registered instance. + RegisterSupervisor(*RegisterSupervisorRequest, grpc.ServerStreamingServer[SupervisorActivationMessage]) error // Renew the calling sandbox's gateway JWT. Older tokens remain valid // until their own expiry; deployments should keep token TTLs short to // bound replay exposure. The supervisor calls this from a background @@ -1504,6 +1536,9 @@ func (UnimplementedOpenShellServer) GetDraftHistory(context.Context, *GetDraftHi func (UnimplementedOpenShellServer) IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) { return nil, status.Error(codes.Unimplemented, "method IssueSandboxToken not implemented") } +func (UnimplementedOpenShellServer) RegisterSupervisor(*RegisterSupervisorRequest, grpc.ServerStreamingServer[SupervisorActivationMessage]) error { + return status.Error(codes.Unimplemented, "method RegisterSupervisor not implemented") +} func (UnimplementedOpenShellServer) RefreshSandboxToken(context.Context, *RefreshSandboxTokenRequest) (*RefreshSandboxTokenResponse, error) { return nil, status.Error(codes.Unimplemented, "method RefreshSandboxToken not implemented") } @@ -2668,6 +2703,17 @@ func _OpenShell_IssueSandboxToken_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _OpenShell_RegisterSupervisor_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(RegisterSupervisorRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OpenShellServer).RegisterSupervisor(m, &grpc.GenericServerStream[RegisterSupervisorRequest, SupervisorActivationMessage]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_RegisterSupervisorServer = grpc.ServerStreamingServer[SupervisorActivationMessage] + func _OpenShell_RefreshSandboxToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RefreshSandboxTokenRequest) if err := dec(in); err != nil { @@ -3128,6 +3174,11 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ Handler: _OpenShell_WatchSandbox_Handler, ServerStreams: true, }, + { + StreamName: "RegisterSupervisor", + Handler: _OpenShell_RegisterSupervisor_Handler, + ServerStreams: true, + }, }, Metadata: "openshell.proto", } diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 7d47b18065..6f7f5097d4 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -523,13 +523,17 @@ container should not receive `OPENSHELL_ENDPOINT`, gateway TLS env vars, the sandbox token file, or those credential mounts. Instead, the network sidecar serves policy and provider environment state over the Unix control socket from `OPENSHELL_SIDECAR_CONTROL_SOCKET` (`/run/openshell-sidecar/control.sock` by -default). The process supervisor must be the first and only client. After +default). The network sidecar binds this socket before gateway activation so a +warm process supervisor can connect and wait without timing out. It authenticates +the process supervisor immediately but sends no bootstrap response until the +gateway supplies the activated identity and the sidecar has a complete policy +snapshot. The process supervisor must be the first and only client. After validating its peer UID, GID, and PID, the sidecar unlinks the listener. If the -connection later closes, the network sidecar exits non-zero so Kubernetes can -restart it with a fresh listener. If the process supervisor fails before -launching the workload, -inspect both containers for control-socket bind, connect, bootstrap, or update -errors. If new SSH/exec sessions do not pick up refreshed provider environment, +connection later closes, including before activation, the network sidecar exits +non-zero so Kubernetes can restart it with a fresh listener. If the process +supervisor fails before launching the workload, inspect both containers for +control-socket bind, connect, activation-gated bootstrap, or update errors. If +new SSH/exec sessions do not pick up refreshed provider environment, inspect the network sidecar settings-poll logs and the process container logs for provider environment update handling; the process container should consume newer provider-env revisions without receiving gateway credentials. @@ -689,6 +693,9 @@ configuration — check that the gateway spawned the driver binary you expect | Kubernetes gateway pod crash loops | Missing secret, bad DB URL, bad TLS config | `kubectl -n openshell logs deployment/openshell -c openshell-gateway` or `kubectl -n openshell logs statefulset/openshell -c openshell-gateway` | | OpenShift gateway pod fails to start with an SCC/`runAsUser` error (e.g. `unable to validate against any security context constraint`) | Chart's default `podSecurityContext`/`securityContext` hardcodes `runAsUser`/`fsGroup`, which the restricted-v2 SCC rejects; it must instead inject the namespace-assigned UID/GID range | `oc -n openshell describe pod `; deploy with `podSecurityContext: null` and clear `securityContext.runAsUser` (see `deploy/helm/openshell/ci/values-openshift-scc.yaml`) | | OpenShift sandbox pod fails to start (`unable to validate against any security context constraint`) | The `openshell-sandbox` service account lacks the privileged SCC it needs | `oc adm policy add-scc-to-user privileged -z openshell-sandbox -n openshell`; remove with `remove-scc-from-user` when done | +| Kubernetes creates cold sandboxes and discovery reports missing Agent Sandbox extension APIs | Agent Sandbox was installed with `manifest.yaml` only while OpenShell warm pooling defaults are enabled | Run `kubectl api-resources --api-group=extensions.agents.x-k8s.io`. Apply Agent Sandbox `extensions.yaml`, or set `server.warmPooling.enabled=false`; the driver rediscovers `SandboxClaim`, `SandboxTemplate`, and `SandboxWarmPool` within 30 seconds without a gateway restart. With the APIs available, disabling warm pooling prunes generated resources owned by this gateway on the next desired-state sweep | +| Gateway logs `Kubernetes RBAC configuration error for Agent Sandbox extension API` | The extension API is installed, but the gateway ServiceAccount cannot perform the logged operation and scope | Use `kubectl auth can-i .extensions.agents.x-k8s.io --as system:serviceaccount:: [-n ]`, then correct the Role or ClusterRole binding. Do not treat this as a missing CRD; claim inventory and cleanup fail until authorization is restored | +| External Kubernetes driver creates cold sandboxes even though extension APIs are installed | The remote compute-driver protocol has no claim-activation callback | Use the in-process Kubernetes driver for warm pooling; the external driver intentionally advertises warm bootstrap as unavailable | | CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways//mtls/` | | Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login `, gateway auth logs | | Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy | diff --git a/tasks/scripts/helm-k3s-local.sh b/tasks/scripts/helm-k3s-local.sh index dc8adb9bdf..a482f38a9e 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -167,6 +167,12 @@ apply_base_manifests() { local base="https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}" echo "Applying agent-sandbox manifest (${AGENT_SANDBOX_VERSION})..." kubectl --kubeconfig="${KUBECONFIG_TARGET}" apply -f "${base}/manifest.yaml" + if [[ "${AGENT_SANDBOX_VERSION}" != v0.4.* ]]; then + echo "Applying agent-sandbox extensions (${AGENT_SANDBOX_VERSION})..." + kubectl --kubeconfig="${KUBECONFIG_TARGET}" apply -f "${base}/extensions.yaml" + else + echo "Agent Sandbox ${AGENT_SANDBOX_VERSION} has no extension APIs; warm pooling must be disabled." + fi } install_trace_collector() { diff --git a/tasks/test.toml b/tasks/test.toml index 3df9b7371a..3e68721861 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -172,6 +172,18 @@ env = { AGENT_SANDBOX_VERSION = "v0.4.6" } depends = ["e2e:conformance:build"] run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +["e2e:kubernetes:warm-pool"] +description = "Run strict Kubernetes warm-pool activation and template lifecycle e2e tests" +env = { OPENSHELL_E2E_KUBE_WARM_POOL = "1", OPENSHELL_E2E_KUBE_WARM_POOL_STRICT = "1", OPENSHELL_E2E_KUBE_TEST = "kubernetes_warm_pool", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes" } +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" + +["e2e:kubernetes:warm-pool-sidecar"] +description = "Run strict Kubernetes warm-pool activation e2e tests with sidecar supervisors" +env = { OPENSHELL_E2E_KUBE_WARM_POOL = "1", OPENSHELL_E2E_KUBE_WARM_POOL_STRICT = "1", OPENSHELL_E2E_KUBE_TEST = "kubernetes_warm_pool", OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-sidecar.yaml", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes" } +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" + ["e2e:kubernetes:agent-sandbox-versions"] description = "Run Kubernetes e2e against Agent Sandbox v1beta1 and v1alpha1" depends = ["e2e:conformance:build"]