Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .github/workflows/branch-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,34 @@ jobs:
extra-helm-values: ${{ matrix.extra_helm_values }}
cli-artifact-prefix: rust-binary-cli

kubernetes-workspace-managed-e2e:
needs: [pr_metadata, build-gateway, build-supervisor, build-cli]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-kubernetes-test.yml
with:
image-tag: ${{ github.sha }}
job-name: Kubernetes E2E (workspace managed mode)
e2e-task: e2e:kubernetes:workspace-managed
cli-artifact-prefix: rust-binary-cli

kubernetes-workspace-operator-e2e:
needs: [pr_metadata, build-gateway, build-supervisor, build-cli]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-kubernetes-test.yml
with:
image-tag: ${{ github.sha }}
job-name: Kubernetes E2E (workspace operator mode)
e2e-task: e2e:kubernetes:workspace-operator
cli-artifact-prefix: rust-binary-cli

kubernetes-ha-e2e:
needs: [pr_metadata, build-gateway, build-supervisor, build-cli]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_kubernetes_ha_e2e == 'true'
Expand Down Expand Up @@ -212,7 +240,7 @@ jobs:

core-e2e-result:
name: Core E2E result
needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux, e2e, kubernetes-e2e]
needs: [pr_metadata, build-gateway, build-supervisor, build-cli, build-driver-vm-linux, e2e, kubernetes-e2e, kubernetes-workspace-managed-e2e, kubernetes-workspace-operator-e2e]
if: always() && needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
runs-on: ubuntu-latest
steps:
Expand All @@ -224,6 +252,8 @@ jobs:
BUILD_DRIVER_VM_RESULT: ${{ needs.build-driver-vm-linux.result }}
E2E_RESULT: ${{ needs.e2e.result }}
KUBERNETES_E2E_RESULT: ${{ needs.kubernetes-e2e.result }}
KUBERNETES_WORKSPACE_MANAGED_E2E_RESULT: ${{ needs.kubernetes-workspace-managed-e2e.result }}
KUBERNETES_WORKSPACE_OPERATOR_E2E_RESULT: ${{ needs.kubernetes-workspace-operator-e2e.result }}
run: |
set -euo pipefail
failed=0
Expand All @@ -233,7 +263,9 @@ jobs:
"build-cli:$BUILD_CLI_RESULT" \
"build-driver-vm-linux:$BUILD_DRIVER_VM_RESULT" \
"e2e:$E2E_RESULT" \
"kubernetes-e2e:$KUBERNETES_E2E_RESULT"; do
"kubernetes-e2e:$KUBERNETES_E2E_RESULT" \
"kubernetes-workspace-managed-e2e:$KUBERNETES_WORKSPACE_MANAGED_E2E_RESULT" \
"kubernetes-workspace-operator-e2e:$KUBERNETES_WORKSPACE_OPERATOR_E2E_RESULT"; do
name="${item%%:*}"
result="${item#*:}"
if [ "$result" != "success" ]; then
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 94 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,5 +309,99 @@ Standalone local deployments start the gateway with a selected runtime such as
Docker, Podman, or VM. The CLI can register multiple gateways and switch between
them without changing the sandbox architecture.

## Workspace Namespace Modes (Kubernetes)

The Kubernetes driver maps workspaces to namespaces through the `workspace_mode`
configuration field (`WorkspaceMode` in `crates/openshell-driver-kubernetes/src/config.rs`).
The mode controls namespace resolution, resource naming, sandbox CR watching, SA
token authentication, and RBAC requirements.

| Mode | Namespace resolution | Resource name | Namespace lifecycle |
|---|---|---|---|
| **Shared** (default) | Single static namespace from config | `{workspace}--{name}` | None |
| **Managed** | `openshell-{gateway_id}-{workspace}` | bare sandbox name | Driver creates and deletes |
| **Operator** | Workspace name maps 1:1 to a pre-provisioned namespace | bare sandbox name | External (platform team) |

**Shared** renders all sandboxes into one configured namespace. Resource names
embed the workspace prefix for collision avoidance. No namespace lifecycle
management. RBAC uses a namespace-scoped Role.

**Managed** auto-creates a K8s namespace per workspace on first sandbox create.
Each new namespace receives a ServiceAccount and the configured gateway-only
SSH ingress NetworkPolicy. Configured image-pull Secrets are copied from the
driver's source namespace on every sandbox create so registry credential
rotations propagate. The namespace also copies OpenShift SCC UID-range and
supplemental-group annotations from the gateway namespace when present. The
driver deletes the namespace during workspace deletion. The workspace remains
durably `Terminating` until the Kubernetes API accepts namespace cleanup, so a
transient failure can be retried. Namespace deletion uses the fetched UID as a
precondition to avoid deleting a replacement namespace. Requires a non-empty
`gateway_id` (validated as a
DNS-1123 label at startup) so the namespace prefix fits within the K8s 63-character
limit. RBAC promotes sandbox CRD permissions to a ClusterRole and adds namespace
`create`/`delete` and ServiceAccount `create`/`get` permissions.

Secret copies use server-side apply. Kubernetes authorizes an apply to an
existing Secret as `patch`, but also requires `create` authorization when the
target does not exist. RBAC cannot constrain `create` by `resourceNames`, so
managed mode grants cluster-wide Secret `create` while keeping source reads and
subsequent patches restricted to the explicitly configured TLS and image-pull
Secret names. The driver exercises `create` only in gateway-owned managed
namespaces. This depends on the managed-mode ownership invariant described
below; the gateway ServiceAccount must not be shared with unrelated workloads.

Operator mode does not create NetworkPolicies or copy image-pull Secrets.
Platform teams must apply the gateway ingress boundary and provision configured
image-pull Secrets in every operator-managed namespace.

**Operator** uses pre-provisioned namespaces discovered through two optional
sources: a K8s label selector (`operator_namespace_label`) and a drop-in
allowlist file (`operator_namespace_file`). At least one must be configured.
The `OperatorNamespaceAllowlist` (`Arc<RwLock<BTreeSet<String>>>`) is populated
at runtime by background watchers and read by the namespace resolver. Sandbox
creation fails closed if the workspace is not in the current allowlist. Platform
teams manage namespace lifecycle externally. RBAC uses the same ClusterRole as
managed mode but without namespace `create`/`delete` or ServiceAccount
permissions.

### 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.

### SA Token Authentication

The gateway's `K8sServiceAccountAuthenticator` adapts its `NamespaceValidator`
per mode (`crates/openshell-server/src/auth/k8s_sa.rs`):

- **Shared:** `Exact` — accepts only the single configured namespace.
- **Managed:** `Prefix` — accepts any namespace starting with `openshell-{gateway_id}-`.
- **Operator:** `Allowlist` — accepts namespaces present in the dynamic
`BTreeSet` populated by the label/file watchers. Starts empty (fail-closed)
until the first watcher update.

These checks rely on an ownership invariant. In shared and managed modes, the
gateway and its trusted Agent Sandbox controller exclusively administer the
sandbox namespace, Sandbox CRs, sandbox pods, and configured sandbox
ServiceAccount. Other principals must not create or mutate those resources or
use that ServiceAccount. In operator mode, the platform operator retains
namespace lifecycle ownership, but must preserve the same exclusive control of
Sandbox CRs and the pods and ServiceAccount used for sandbox token bootstrap.
An allowlisted namespace is therefore a trust grant, not a tenant isolation
boundary. Kubernetes owner references alone do not prove which controller
created a pod, so admitting principals that can fabricate that resource chain
would allow them to claim an existing sandbox identity.

### Credential Driver Integration

The Kubernetes Secrets credential driver (`openshell-driver-kubernetes-secrets`)
stores secrets in workspace-specific namespaces when `workspace_mode` is managed
or operator. In shared mode, all secrets render into the single configured
namespace.

When runtime infrastructure changes, validate the relevant sandbox e2e path and
update the matching driver README if a maintainer-facing constraint changes.
3 changes: 3 additions & 0 deletions crates/openshell-core/src/driver_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace";
/// Container/pod label carrying the sandbox workspace.
pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace";

/// Label carrying the gateway identity on managed namespaces.
pub const LABEL_GATEWAY_ID: &str = "openshell.ai/gateway-id";

/// Label selector that matches all OpenShell-managed resources which carry a
/// sandbox ID label. Used by list and watch operations to exclude foreign
/// resources from the same namespace.
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {

let tls_enabled = endpoint.starts_with("https://");

// TODO: TLS certs are loaded once here and never re-read. The gateway
// server side supports hot-reload (ArcSwap + notify in tls.rs). The
// supervisor should do the same so that cert-manager rotations take
// effect without restarting the sandbox.
if tls_enabled {
let ca_path = std::env::var(sandbox_env::TLS_CA)
.into_diagnostic()
Expand Down
19 changes: 17 additions & 2 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ use openshell_core::progress::{
};
use openshell_core::proto::compute::v1::{
CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse,
DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus,
DriverSandboxTemplate, GatewayListenerRequirement, GetCapabilitiesRequest,
DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent,
DriverSandbox, DriverSandboxStatus, DriverSandboxTemplate, EnsureWorkspaceRequest,
EnsureWorkspaceResponse, GatewayListenerRequirement, GetCapabilitiesRequest,
GetCapabilitiesResponse, GetGatewayListenerRequirementsRequest,
GetGatewayListenerRequirementsResponse, GetSandboxRequest, GetSandboxResponse,
GpuResourceRequirements, ListSandboxesRequest, ListSandboxesResponse, StartSandboxRequest,
Expand Down Expand Up @@ -1761,6 +1762,20 @@ impl ComputeDriver for DockerComputeDriver {

Ok(Response::new(Box::pin(ReceiverStream::new(out_rx))))
}

async fn ensure_workspace(
&self,
_request: Request<EnsureWorkspaceRequest>,
) -> Result<Response<EnsureWorkspaceResponse>, Status> {
Ok(Response::new(EnsureWorkspaceResponse {}))
}

async fn delete_workspace(
&self,
_request: Request<DeleteWorkspaceRequest>,
) -> Result<Response<DeleteWorkspaceResponse>, Status> {
Ok(Response::new(DeleteWorkspaceResponse {}))
}
}

impl DockerProvisioningFailure {
Expand Down
Loading
Loading