diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index 2a6b9ad38..fddbe8f1e 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -113,10 +113,31 @@ trace-context annotation. The same command exposes OTLP/gRPC on `gateway:vm` tasks detect the collector listener at startup and enable trace export only while it is reachable. -**HA test deploy** (two gateway replicas + external PostgreSQL Secret): uncomment -`#- ci/values-high-availability.yaml` in `deploy/helm/openshell/skaffold.yaml`, -create the Secret named `openshell-ha-pg` with a `uri` key, then run -`mise run helm:skaffold:run` or `mise run helm:skaffold:dev`. +The Skaffold profile for HA reverse-proxy development is available from +`deploy/helm/openshell/`: + +```bash +# Two gateway replicas + external PostgreSQL Secret + Envoy Gateway + Gateway API route. +KUBECONFIG=../../../kubeconfig skaffold run -p high-availability +``` + +The `high-availability` profile expects a Secret named `openshell-ha-pg` in the `openshell` +namespace with a `uri` key. For local manual testing, either create your own +PostgreSQL Secret or use the e2e PostgreSQL fixture manifest in +`e2e/kubernetes/postgres-fixture.yaml`. + +For the `high-availability` profile, return to the repository root and apply the +GatewayClass and BackendTrafficPolicy manifest after Skaffold has installed +Envoy Gateway: + +```bash +KUBECONFIG=kubeconfig mise run helm:gateway:apply +``` + +The BackendTrafficPolicy disables Envoy request and stream-duration timeouts for +OpenShell's `GRPCRoute`. Keep that policy in `deploy/kube/manifests/envoy-gateway-openshell.yaml`, +not in the Helm chart; it is required for long-lived gRPC create/watch/exec/relay +streams during gateway rollouts and scale events. ### TLS behaviour @@ -187,23 +208,85 @@ but will point to a deleted cluster — safe to ignore or clean up manually. ## Optional Add-ons -Each add-on requires uncommenting the corresponding `valuesFiles` entry in -`deploy/helm/openshell/skaffold.yaml` before running `helm:skaffold:dev` or `helm:skaffold:run`. +Some add-ons can be enabled by uncommenting values in `skaffold.yaml`, but prefer +the dedicated Skaffold profiles when they exist. Profiles avoid leaving local +manual edits in the worktree. ### Envoy Gateway (Gateway API / GRPCRoute) -Envoy Gateway is already installed by Skaffold (the `envoy-gateway` Helm release in -`skaffold.yaml`). To activate routing: +Use the `high-availability` Skaffold profile for HA reverse-proxy testing. The +profile intentionally includes Envoy Gateway so multi-replica behavior is +exercised through the same Gateway API path used by reverse-proxy deployments: -1. Uncomment `#- values-gateway.yaml` in `skaffold.yaml` -2. Redeploy: `mise run helm:skaffold:run` -3. Apply the GatewayClass: `mise run helm:gateway:apply` -4. Access: `http://127.0.0.1:8080` +```bash +cd deploy/helm/openshell +KUBECONFIG=../../../kubeconfig skaffold run -p high-availability +cd ../../.. +KUBECONFIG=kubeconfig mise run helm:gateway:apply +``` -`values-gateway.yaml` creates a `Gateway` (listener on port 80, class `eg`) and a -`GRPCRoute` in the `openshell` namespace. Envoy Gateway provisions a LoadBalancer -service for the proxy; klipper-lb binds it to hostPort 80, reachable via the -`8080:80` load balancer port mapping. +`values-gateway.yaml` creates a `Gateway` (listener on port 80, class `eg`) and +`GRPCRoute` in the `openshell` namespace. The `high-availability` profile +installs the Envoy Gateway Helm chart and layers both +`values-high-availability.yaml` and `values-gateway.yaml` onto the OpenShell +release. + +`deploy/kube/manifests/envoy-gateway-openshell.yaml` creates: + +- `GatewayClass/eg` +- `BackendTrafficPolicy/openshell-grpc-timeouts` + +The Envoy Gateway proxy Service is usually exposed through the k3d load balancer +at `http://127.0.0.1:8080`. If the cluster was created with a different +`HELM_K3S_LB_HOST_PORT`, use that host port instead. + +For manual tests against an existing cluster, prefer forwarding the Envoy proxy +Service rather than `svc/openshell`. That keeps client traffic on the same path +as a real reverse proxy while gateway pods rotate behind it: + +```bash +KUBECONFIG=kubeconfig kubectl get svc -A \ + -l gateway.envoyproxy.io/owning-gateway-name=openshell +KUBECONFIG=kubeconfig kubectl -n port-forward \ + svc/ 8080:80 +openshell gateway add http://127.0.0.1:8080 --name openshell --local +``` + +When running e2e tests manually through Envoy, register gateway metadata (as +above) instead of relying only on `OPENSHELL_GATEWAY_ENDPOINT`; some tests call +`openshell gateway info` and expect metadata for the active gateway. + +### Kubernetes E2E Notes + +Use `mise run e2e:kubernetes` for the standard Helm-backed Kubernetes suite. +The kube e2e wrapper creates only one port-forward, to `svc/openshell`; it no +longer forwards the unauthenticated health listener or runs a `/readyz` e2e +target. `/readyz` remains covered by server unit/integration tests. + +Use `mise run e2e:kubernetes:ha-rebalancing` for full-suite HA coverage. The +task creates an external PostgreSQL fixture, installs Envoy Gateway, applies +`deploy/kube/manifests/envoy-gateway-openshell.yaml`, enables the chart +`GRPCRoute`, and runs the full Kubernetes e2e suite, including +`kubernetes_ha_rebalancing`. That coverage validates sandbox create/watch and +exec through the Envoy proxy while gateway replicas scale up, scale down, and +rotate. It also keeps a long-running sandbox alive and runs upload/download +operations while gateway pods roll, so file sync exercises the same relay retry +path as interactive sessions. + +If you reuse an existing Skaffold cluster for the full kube suite, make sure the +chart has `server.hostGatewayIP` set so sandbox pods can resolve +`host.openshell.internal` back to the test host. The e2e wrapper detects this on +chart installs; manual reuse may require: + +```bash +HOST_GATEWAY_IP="${OPENSHELL_E2E_HOST_GATEWAY_IP:?set host gateway IP}" +KUBECONFIG=kubeconfig helm upgrade openshell deploy/helm/openshell \ + --namespace openshell --reuse-values \ + --set "server.hostGatewayIP=${HOST_GATEWAY_IP}" \ + --wait --timeout 5m +``` + +Use the IP that pods in that cluster use to reach listeners on the test host. ### BackendTLSPolicy (end-to-end TLS) @@ -356,6 +439,6 @@ for dependencies still declared in `Chart.yaml`. | `deploy/helm/openshell/ci/values-spire-stack.yaml` | SPIRE hardened chart values for local dev | | `deploy/helm/openshell/ci/values-tls-disabled.yaml` | Lint-only: TLS + auth disabled (reverse-proxy edge termination) | | `deploy/helm/openshell/ci/values-credential-driver-vault.yaml` | Vault credential-driver validation overlay with HTTPS and private-CA trust | -| `deploy/kube/manifests/envoy-gateway-openshell.yaml` | GatewayClass for Envoy Gateway (`mise run helm:gateway:apply`) | +| `deploy/kube/manifests/envoy-gateway-openshell.yaml` | GatewayClass and BackendTrafficPolicy for Envoy Gateway (`mise run helm:gateway:apply`) | | `tasks/scripts/helm-k3s-local.sh` | k3d cluster create/delete/start/stop/status | | `tasks/scripts/keycloak-k8s-setup.sh` | Keycloak deploy, realm import, and development TLS trust anchor | diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 827823df4..0491fb313 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -426,6 +426,9 @@ jobs: job-name: Kubernetes HA E2E (Rust smoke) extra-helm-values: deploy/helm/openshell/ci/values-high-availability.yaml external-postgres-secret: openshell-ha-pg + test-name: kubernetes_ha_rebalancing + kubernetes-features: e2e,e2e-host-gateway,e2e-kubernetes,e2e-kubernetes-ha + use-envoy-gateway: true conformance-artifact-prefix: openshell-conformance kubernetes-credential-drivers-e2e: diff --git a/.github/workflows/codex-security.yml b/.github/workflows/codex-security.yml index b79bc54b3..348817ba8 100644 --- a/.github/workflows/codex-security.yml +++ b/.github/workflows/codex-security.yml @@ -4,9 +4,6 @@ name: Codex Security Release Qualification on: - push: - tags: - - "v*.*.*-pre.*" workflow_call: inputs: candidate_ref: diff --git a/.github/workflows/e2e-kubernetes-test.yml b/.github/workflows/e2e-kubernetes-test.yml index e109acf4f..4aaa93264 100644 --- a/.github/workflows/e2e-kubernetes-test.yml +++ b/.github/workflows/e2e-kubernetes-test.yml @@ -52,6 +52,11 @@ on: required: false type: string default: "" + use-envoy-gateway: + description: "Route the test through Envoy Gateway instead of a direct gateway Service port-forward" + required: false + type: boolean + default: false mise-version: description: "mise version to install on the bare Kubernetes e2e runner" required: false @@ -140,6 +145,7 @@ jobs: OPENSHELL_E2E_KUBE_EXTERNAL_POSTGRES_SECRET: ${{ inputs.external-postgres-secret }} OPENSHELL_E2E_KUBE_TEST: ${{ inputs.test-name }} OPENSHELL_E2E_KUBERNETES_FEATURES: ${{ inputs.kubernetes-features }} + OPENSHELL_E2E_KUBE_USE_ENVOY: ${{ inputs.use-envoy-gateway && '1' || '0' }} IMAGE_TAG: ${{ inputs.image-tag }} OPENSHELL_REGISTRY: ghcr.io/nvidia/openshell E2E_TASK: ${{ inputs.e2e-task }} diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 69e366dc5..1048e5abd 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -24,7 +24,7 @@ on: type: string default: >- [ - {"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"conformance"}, + {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, {"environment":"fedora-podman-rootful","installer":"binaries","testsuite":"conformance"}, {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"conformance"} ] diff --git a/.github/workflows/prepare-integration-inputs.yml b/.github/workflows/prepare-integration-inputs.yml index af6d61d29..1fb14e583 100644 --- a/.github/workflows/prepare-integration-inputs.yml +++ b/.github/workflows/prepare-integration-inputs.yml @@ -11,6 +11,11 @@ on: required: false type: string default: "" + deb-artifact-name: + description: Debian package artifact to include in the tmachine inputs + required: false + type: string + default: "" outputs: source_sha: description: Source revision of the candidate artifacts @@ -65,6 +70,22 @@ jobs: github-token: ${{ github.token }} run-id: ${{ inputs['artifact-run-id'] || github.run_id }} + - name: Download Debian package artifact + if: inputs['deb-artifact-name'] != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs['deb-artifact-name'] }} + path: artifacts/packages/download + github-token: ${{ github.token }} + run-id: ${{ inputs['artifact-run-id'] || github.run_id }} + + - name: Stage Debian package input + if: inputs['deb-artifact-name'] != '' + run: | + set -euo pipefail + mv artifacts/packages/download/*.deb artifacts/packages/openshell.deb + rmdir artifacts/packages/download + - name: Log in to GHCR run: echo "${{ github.token }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 23dd415d2..c188ba3dc 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -102,12 +102,14 @@ jobs: checkout-ref: ${{ github.sha }} prepare-integration: - needs: [build-binaries, build-images] + needs: [build-binaries, build-deb, build-images] permissions: actions: read contents: read packages: read uses: ./.github/workflows/prepare-integration-inputs.yml + with: + deb-artifact-name: deb-linux-amd64 conformance-integration: needs: prepare-integration diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e7e479543..9bbce87ec 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*.*.*" + - "!v*.*.*-pre.*" workflow_dispatch: inputs: tag: @@ -129,13 +130,35 @@ jobs: image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ needs.compute-versions.outputs.source_sha }} + security: + name: Security Scan + needs: [compute-versions, build-images] + permissions: + actions: read + contents: read + packages: read + security-events: write + uses: ./.github/workflows/security-scan.yml + with: + candidate_ref: ${{ inputs.tag || github.ref_name }} + allow-high-critical: false + images: | + ghcr.io/nvidia/openshell/gateway:${{ needs.compute-versions.outputs.source_sha }} + ghcr.io/nvidia/openshell/sandbox:${{ needs.compute-versions.outputs.source_sha }} + ghcr.io/nvidia/openshell/supervisor:${{ needs.compute-versions.outputs.source_sha }} + secrets: + CODEX_SECURITY_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} + CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + prepare-integration: - needs: [compute-versions, build-binaries, build-images] + needs: [compute-versions, build-binaries, build-deb, build-images] permissions: actions: read contents: read packages: read uses: ./.github/workflows/prepare-integration-inputs.yml + with: + deb-artifact-name: deb-linux-amd64 conformance-integration: needs: prepare-integration @@ -306,6 +329,7 @@ jobs: - feature-specific-integration - docker-e2e - vm-e2e + - security - build-deb - build-rpm - build-snap diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 2ee09d799..d3a22cd85 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: candidate_ref: - description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N) + description: Release tag to scan; Codex runs only for pre-release tags required: true type: string stable_ref: @@ -33,7 +33,7 @@ on: workflow_call: inputs: candidate_ref: - description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N) + description: Release tag to scan; Codex runs only for pre-release tags required: true type: string stable_ref: @@ -69,6 +69,7 @@ permissions: jobs: codex: name: Codex Security + if: contains(inputs.candidate_ref, '-pre.') uses: ./.github/workflows/codex-security.yml permissions: actions: read diff --git a/CI.md b/CI.md index e5518c851..f76baf909 100644 --- a/CI.md +++ b/CI.md @@ -89,13 +89,13 @@ nix develop --command zizmor --offline --persona=regular --min-severity=high --n ## Run the security scans together -`Security Scan` (`.github/workflows/security-scan.yml`) calls Codex Security, -CodeQL, Trivy, Cargo Deny, and Workflow Security Reports (Actionlint/Zizmor) in -parallel. Run it manually from Actions or call it from another workflow. All -children scan `candidate_ref`, which must be an existing `vX.Y.Z-pre.N` tag -because Codex qualifies pre-releases. Codex compares it with the previous stable; -the other scanners analyze the candidate snapshot. Cargo Deny uses its existing -NVIDIA self-hosted runner and CI container. +`Security Scan` (`.github/workflows/security-scan.yml`) calls CodeQL, Trivy, +Cargo Deny, and Workflow Security Reports (Actionlint/Zizmor) in parallel for a +release tag. It additionally calls Codex Security for `vX.Y.Z-pre.N` tags, which +Codex compares with the previous stable release. Run the parent manually from +Actions or call it from another workflow. All applicable children analyze the +candidate snapshot. Cargo Deny uses its existing NVIDIA self-hosted runner and +CI container. ```shell gh workflow run security-scan.yml --ref main \ @@ -132,6 +132,8 @@ jobs: ``` Set `needs: security` on a downstream promotion job to require successful scans. +The tagged release workflow does this after publishing its commit-addressed OCI +images, so failed scans or High/Critical findings block release publication. `CODEX_SECURITY_API_KEY` is required; `CACHIX_AUTH_TOKEN` is optional. The parent publishes SARIF, including Codex results for manual parent runs. Codex keeps its @@ -375,7 +377,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. | File | Role | |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway, sandbox, and supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.` pin). Also dispatchable manually. | -| `.github/workflows/release-tag.yml` | Publishes a tagged stable release. Its automatic tag trigger excludes `-pre.*`; manual dispatch remains maintainer-controlled. | +| `.github/workflows/release-tag.yml` | Publishes tagged stable releases and manually dispatched pre-releases. Its automatic tag trigger excludes `-pre.*`. Security Scan gates release publication. | | `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts on `macos`, `ubuntu`, `fedora`, and `kubernetes` (kind + Helm) runners. Each job reaches its gateway and creates, exercises, and deletes a sandbox. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | ## Required status contexts diff --git a/Cargo.lock b/Cargo.lock index 6f4175ce6..2ec0ebc7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,9 +984,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher", @@ -1540,9 +1540,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -1699,7 +1699,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der 0.8.0", + "der 0.8.2", "digest 0.11.2", "elliptic-curve 0.14.1", "rfc6979 0.6.0", @@ -4394,6 +4394,8 @@ dependencies = [ "openshell-core", "rustix 1.1.4", "serde", + "serde_json", + "sha2 0.10.9", "tokio", ] @@ -5294,7 +5296,7 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.0", + "der 0.8.2", "spki 0.8.0", ] @@ -5307,7 +5309,7 @@ dependencies = [ "aes", "aes-gcm", "cbc", - "der 0.8.0", + "der 0.8.2", "pbkdf2", "rand_core 0.10.1", "scrypt", @@ -5331,7 +5333,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", + "der 0.8.2", "pkcs5", "rand_core 0.10.1", "spki 0.8.0", @@ -6137,7 +6139,7 @@ dependencies = [ "curve25519-dalek 5.0.0", "data-encoding", "delegate", - "der 0.8.0", + "der 0.8.2", "digest 0.11.2", "ecdsa 0.17.0", "ed25519-dalek 3.0.0", @@ -6278,9 +6280,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -6352,9 +6354,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6466,7 +6468,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der 0.8.0", + "der 0.8.2", "hybrid-array", "subtle", "zeroize", @@ -6889,9 +6891,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -6913,7 +6915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.2", ] [[package]] @@ -7335,7 +7337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -8868,13 +8870,14 @@ dependencies = [ [[package]] name = "wnaf" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" dependencies = [ "ff 0.14.0", "group 0.14.0", "hybrid-array", + "primefield", ] [[package]] diff --git a/architecture/build.md b/architecture/build.md index 5ac5ff5f1..121ab0449 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -275,7 +275,7 @@ includes, templates, inventory, and requirements. The digest uses sorted relative paths, file contents, and executable permissions; source symlinks are unsupported. Both keys also retain the ordered playbook paths and contents, their base disk contents, and whether Galaxy is enabled; install keys -include named binary inputs. The top-level `.roles` directory is excluded: +include named artifact inputs. The top-level `.roles` directory is excluded: Galaxy release pins in `requirements.yaml` are treated as immutable, including any transitive dependency pins. Cache misses with Galaxy enabled reinstall the required roles and their dependencies before running playbooks. @@ -286,7 +286,17 @@ the gateway, sandbox, and supervisor as separate binaries for their respective Dockerfiles. The helpers stage binaries under `artifacts/binaries` so local and CI builds expose the same inputs to tmachine and image assembly. The Ubuntu Docker and Fedora Podman environments import both local runtime images and -configure the gateway to use them. +configure the gateway to use them. The Ubuntu `deb` installer consumes +`artifacts/packages/openshell.deb`; the `binaries` installer remains available +for direct executable installation on every environment. Release Dev and +Release Tag run Ubuntu conformance through the Debian package, while Fedora +continues using direct executable installation until RPM coverage is available. +The Debian qualification profile keeps candidate-image overrides outside the +operator-owned gateway configuration: it writes a harness-owned file under +`/var/lib/openshell-qualification` and selects it through the packaged systemd +unit's `gateway.env` hook. Ordinary package installations continue to use the +gateway's built-in runtime-image defaults unless the operator configures an +override. ## Python Wheel Packaging @@ -358,8 +368,9 @@ Triggers differ by workflow: `.github/workflows/workflow-security.yml` runs on `merge_group` only, because it needs a base and head commit to compare; `.github/workflows/codeql.yml` runs nightly on the default branch (`main`) via `schedule`, with `workflow_dispatch` kept for manual diagnostics; and -`.github/workflows/codex-security.yml` runs on pushed `v*.*.*-pre.*` tags, and is -also callable through `workflow_call` and `workflow_dispatch`. CodeQL does +`.github/workflows/codex-security.yml` is called by the aggregate release scan +for pre-release tags and is also callable through `workflow_call` and +`workflow_dispatch`. CodeQL does not run on `pull_request`, `merge_group`, or pushes to `main`, so it reports repository-level Code Scanning state on the default branch instead of per-PR results, and its four-language matrix stays off the per-change critical path. @@ -470,6 +481,13 @@ workflow only reports on candidates that already exist. Gating stable promotion on qualification results remains proposed in [RFC 0014](../rfc/0014-release-stability/release-qualification.md). +The tagged release workflow calls the aggregate Security Scan after publishing +the candidate's commit-addressed gateway, sandbox, and supervisor images. CodeQL, +Trivy, Cargo Deny, and Actionlint/Zizmor run for every release tag; Codex Security +also runs for pre-release tags. The release job depends on the aggregate result, +which fails on scanner errors, Cargo Deny advisories, and unexcepted High or +Critical findings. + `release-auto-tag.yml` runs at 14:00 Europe/Zurich on weekdays (including daylight saving time changes) and supports manual dispatch. Maintainers start weekday pre-release publishing by tagging the initial `vX.Y.Z-pre.1` release candidate. diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 4a7ea2194..4fd27e2b9 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -98,15 +98,14 @@ shortly after carrying a stale `Provisioning` or `Unknown` backend phase. The composition rule treats a connected session as the stronger signal and keeps `Ready` in that case, preventing a lagging snapshot from undoing the session-driven promotion. -**Known HA limitation:** Supervisor sessions are process-local while the public -sandbox phase is shared. A replica that reconciles a driver snapshot without owning -the active supervisor session can demote the shared phase to `Provisioning`. The -session-owning replica may not receive another connection event to restore `Ready`, -so a usable sandbox can remain unavailable through the public phase gate. Reliable -HA readiness requires persisted or leased supervisor presence plus routing to the -session-owning replica. That work is deferred to GitHub issue #1868. Until then, -deployments that require reliable readiness composition must run a single gateway -replica. +**HA session composition:** Live relay handles remain process-local, while the +session-owning gateway publishes a short-lived owner record in shared PostgreSQL. +Driver reconciliation treats a fresh local or remote owner as connected, so a +non-owner replica cannot demote the shared sandbox phase merely because it lacks the +in-memory stream. Session-bound requests are forwarded to the owning gateway; a +supervisor reconnect publishes a higher connection epoch before stale-session cleanup +can demote readiness. Multi-replica deployments therefore require shared PostgreSQL +and the gateway peer Service configured by the Helm chart. **Extension point:** Driver-reported readiness is a capability, not an operator-configurable hook. A driver may enable it only when it owns workload @@ -119,9 +118,12 @@ The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated when a sandbox create request asks for GPU resources. -The gateway records driver identity and version from the startup capability -response. Elevated gateway info reports that initialized driver snapshot instead -of re-querying drivers on each request. +The gateway sends its common extension peer metadata with the startup capability +request. The driver validates that metadata before responding, and the gateway +rejects a driver whose protocol major or capability requirements are +incompatible. It records the negotiated protocol, implementation identity and +version, capability sets, and typed resource support once. Elevated gateway info +reports that immutable snapshot instead of re-querying drivers on each request. ## Compiled Driver Selection @@ -182,6 +184,10 @@ supervisor or Sandbox Runtime process replacement does not resume a running generation. Planned upgrades stop the sandbox first; the following start mints a fresh session, TLS identity, and credential pair. An unexpected replacement leaves the old workload on the normal fail-closed disconnect path. +The gateway commits the new authorization identity and the durable `Starting` +phase in one resource-version update. A concurrent start that loses that update +reuses the winning identity, so every idempotent driver retry receives credentials +that match the persisted sandbox. A driver stop operation does not complete while its backend still reports an in-progress stop. This prevents an immediate start from racing the previous diff --git a/architecture/gateway.md b/architecture/gateway.md index 45e934d61..f237fce0c 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -254,6 +254,8 @@ IDs fail instead of creating source precedence. The gateway treats configured interceptors as trusted sources and does not verify signature annotations in their profile payloads. +The CLI exposes reusable profile definitions through `openshell profile`, with `list` and `describe` reading the same effective catalog used by provider creation. Export, import, update, lint, and delete share that top-level command group. Workspace selection and explicit platform scope apply at the existing profile API boundary; `openshell provider` manages credential-bearing instances. + Each logical gateway request captures the selected sources into one validated, immutable effective catalog before deriving provider behavior. Policy layers, credential scope, injected environment material, dynamic token grants, and @@ -352,6 +354,70 @@ authenticated sandbox ID with any sandbox ID or name resolved from the request. Supervisor control and relay streams require a matching sandbox principal before the gateway registers the session or bridges relay bytes. +## HA Supervisor Ownership + +In multi-replica Kubernetes deployments, every gateway pod can accept client +RPCs, but a sandbox supervisor maintains one active stream to one gateway +replica at a time. The connected replica publishes a short-lived supervisor +owner record in the shared Postgres object store with its replica id, peer DNS +endpoint, supervisor instance id, and connection epoch. Ownership does not move +because another gateway receives a client request. It changes only when the +supervisor opens a new control stream, usually after the previous owner pod is +terminated or the stream breaks. A reconnect from the same supervisor instance +with a newer epoch can supersede the previous owner before the TTL expires, and +heartbeats from the active connection renew that current owner record. +Cleanup from an older connection checks the shared owner record before and +after changing sandbox readiness. It cannot demote a sandbox after a newer +replica has published replacement ownership. + +Session-bound operations such as exec, TCP forwarding, file sync, and sandbox +service routing first check the local session registry. If the supervisor is +owned by another gateway replica, the serving gateway opens an internal +`PeerRelay` stream to that owner and asks it to open the supervisor relay. This +keeps client traffic working when a Kubernetes Service routes the client to a +non-owner gateway pod. If a peer owner is stale or unreachable during a rollout, +the serving gateway retries ownership lookup until the normal relay wait +deadline. Each retry re-reads the owner record, so a supervisor reconnect or +heartbeat can surface a new owner; if no fresh reachable owner appears before +the deadline, the client operation fails rather than electing an owner itself. +Provider-readiness reports, endpoint-status reports, and provider-status reads +also follow the durable owner record through unary peer RPCs. The owning replica +validates the current supervisor session and keeps the in-memory evidence; a +non-owner never accepts evidence from a stale local session or projects a +remote session as disconnected. + +Nothing redistributes established sessions, so after a rolling restart the last +surviving replica holds most sessions and a new replica serves none until +sandboxes reconnect. That skew decays only as sandboxes churn. Client traffic +stays correct throughout because a non-owner relays to the owner. + +File upload and download use tar-over-SSH through the same relay path. A gateway +pod termination drops the active SSH proxy byte stream, so the CLI retries the +whole sync operation with a fresh SSH session instead of attempting mid-stream +resume. + +Gateway peer RPCs authenticate with Kubernetes ServiceAccount identity rather +than a shared secret. Helm mounts a projected, pod-bound token with audience +`openshell-gateway-peer`; the receiving gateway validates it through +TokenReview, checks the live pod UID and chart selector labels, and authorizes +only the internal peer RPC methods. When gateway TLS is enabled, peer clients +also trust the chart CA, present the chart-generated client certificate for +mTLS, and verify the stable gateway Service DNS name even when connecting to a +Deployment pod IP. + +`WatchSandbox` uses the local update bus for same-replica writes. On +multi-replica backends one shared poller per gateway observes resource-version +changes made by other replicas and feeds that bus for all local watchers, +avoiding a database poll per client stream. SQLite deployments do not run the +poller because they are single-replica and the local bus already sees every +write. + +Mutations whose invariants span sandbox, provider-profile, policy, or provider +records take a process-local mutex and a shared PostgreSQL advisory lock. The +database session remains dedicated to the request and closes when the guard is +dropped, which releases the lock on normal completion, cancellation, or error. +SQLite deployments use only the local mutex because they are single-replica. + ## API Surface The gateway API is organized around platform objects and operational streams: @@ -403,6 +469,15 @@ Allow and deny append requests carry `L7RuleTarget` to declare the rule, endpoin `GetSandboxProviderStatus` and `ReportProviderReadiness` are unary public gateway RPCs. The first lets authorized users inspect a provider change; the second accepts installation reports only from the sandbox's current authenticated supervisor session. +`CreateSandboxRequest.service_exposures` is an additive public API field. Its +`SandboxServiceExposure` entries register service endpoints as part of sandbox +creation and are represented in the Rust, Python, TypeScript, and Go SDK create +options. The request-only exposure description is not durable; the gateway +persists the resulting `ServiceEndpoint` objects through the existing endpoint +store after it persists the sandbox. `SandboxResponse.service_urls` returns the +routed URLs keyed by service name for `CreateSandbox`; the empty key represents +the unnamed endpoint, and other sandbox operations leave the map empty. + The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost @@ -742,7 +817,7 @@ migrations backfill existing rows with version 1. Provider profile imports, updates, and deletes hold the sandbox synchronization guard while checking attached-sandbox dynamic token grant ambiguity or in-use state and writing the profile record. Sandbox creation with initial providers and -sandbox provider attach/detach use the same guard, so one gateway process cannot +sandbox provider attach/detach use the same guard, so gateway replicas cannot interleave a profile mutation with a sandbox provider-set mutation that would leave an ambiguous final dynamic-token state or a deleted custom profile that is still referenced by a sandbox. @@ -771,6 +846,15 @@ resolution and again by the sandbox placeholder resolver. This keeps expired credentials from resolving even when a running sandbox still has retained placeholder generations from an earlier provider credential snapshot. +All gateway-owned extension registries negotiate the same peer metadata envelope +before accepting work. Compute drivers, credential drivers, gateway interceptors, +and supervisor middleware retain their typed family manifests. Both the gateway +and extension run the shared validator against the startup exchange, enforcing +protocol-major compatibility and mutual required-capability sets before either +peer accepts the other. The gateway aggregates immutable, non-secret startup +snapshots for the protected gateway-info API; it does not publish transport, +authentication, or backend configuration. + Static credential delivery is capability-negotiated and endpoint-bound. The gateway classifies each returned environment entry as either a credential or non-secret provider configuration and associates every credential key with the @@ -799,7 +883,13 @@ Provider receipts, installation status, and common operations represent absolute Provider installation reports belong to the existing `ConnectSupervisor` session. Each report names that session, has an increasing sequence, and expires unless the supervisor reports again. Reconnection or disconnect invalidates prior observations; stored change records survive a gateway restart, but runtime evidence does not. Replaying an identical report cannot extend its lifetime. -Reports and status also compare the supervisor instance with the sandbox's persisted current instance. A different supervisor becoming current invalidates an older connection, including one retained by another gateway replica. Observations stay local to the gateway holding the supervisor session; a status request reaching a replica without that session returns pending. Multi-replica deployments therefore retain the existing supervisor-session routing requirement. +Reports and status also compare the supervisor instance with the sandbox's +persisted current instance. A different supervisor becoming current invalidates +an older connection, including one retained by another gateway replica. +Observations stay local to the gateway holding the supervisor session. A status +request or report reaching another replica follows the shared owner record to +that gateway, which remains the sole authority for accepting and projecting the +session's evidence. The supervisor reports success only after it installs the matching credentials, activates the effective policy, and receives an acknowledgment from the authenticated workload boundary that it installed the environment for future processes. Environment synchronization shares the process-launch lock, and its acknowledgment identifies the exact publication, including retries at the same provider revision. Failed policy installation cannot reuse evidence for a different installed policy. Ready and revoked statuses also recheck the requested sandbox, provider, attachment and configuration identities; revision fingerprints are compared only for equality. Revocation applies to future credential resolution and future processes. Requests already forwarded upstream can still finish. diff --git a/architecture/sandbox.md b/architecture/sandbox.md index cf661d32c..815fbef0c 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -63,20 +63,52 @@ replacement from granting authority. ## Startup Flow 1. The driver resolves the immutable workload identity, installs the outer - network fence, and starts `openshell-sandbox` with one-use bootstrap state. + network fence, validates its native evidence, and starts `openshell-sandbox` + with one-use bootstrap state. Docker inspects container networking, + Kubernetes verifies its NetworkPolicy, and VM drivers inspect the guest + device model; those native schemas remain in their driver crates. 2. The sandbox consumes and unlinks bootstrap material, proves the admitted runtime posture, and listens on the protected driver channel. It does not run untrusted code yet. 3. `openshell-supervisor` loads policy and runtime settings from the gateway, attaches to the sandbox, and verifies the driver's generation and evidence. 4. The sandbox installs its seccomp notification broker and Landlock baseline, - then reports measured confirmation. The supervisor must accept that evidence -before it sends the launch permit. + validates its mechanism-specific audit evidence, and reports backend-neutral + enforcement properties. The supervisor must accept those properties and + their immutable session and resource binding before it sends the launch + permit. Other isolation backends may establish the same properties with + different mechanisms and retain their detailed evidence in backend-owned + audit data. 5. The sandbox starts the canonical process through its single workload launcher. The supervisor starts SSH and registers its gateway session. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the authenticated channel for the lifetime of the sandbox generation. +The shared isolation contract receives only normalized outer-fence guarantees: +egress is default-deny, there is no unmanaged egress path, the evidence is bound +to the sandbox generation, revocation has been verified, and controller loss +fails closed. A digest commits those guarantees to the native +evidence without teaching the shared contract about container networks, +Kubernetes objects, VM devices, or accelerator resources. + +The component that owns the outer fence also validates its native evidence and +makes that projection explicitly. In the current Docker, Podman, Kubernetes, +and VM placements, that component is the compute driver. A delegated isolation +backend may own the fence and make the same projection instead. Non-empty native +evidence alone does not establish a guarantee: + +| Current enforcement owner | Native evidence | Guarantees projected by the owner | +|---|---|---| +| Docker | Pinned container ID, `network_mode=none`, and no unexpected network attachments | No workload route establishes default-deny, revocation, and controller-loss behavior; the attachment inspection establishes that no unmanaged route exists. | +| Podman | Pinned container ID, `--network=none`, and no unexpected network attachments | The same container-network facts establish the same four guarantees. | +| Kubernetes | NetworkPolicy UID and resource version, ingress and egress isolation, and zero workload egress rules | The persisted, selecting policy establishes default-deny and continued denial after revocation or controller loss; zero egress rules establish that no unmanaged route is permitted. | +| VM | Generation and zero guest network devices | The absent NIC establishes all four guarantees; approved traffic uses the separate supervisor-owned channel. | + +The shared contract checks that all four guarantees are present, that the +projection names the admitted generation, and that its evidence digest matches +the value passed to the workload-side runtime. It does not infer guarantees or +interpret the native fields. + When the admitted main process exits, its status and retained terminal output remain available. The confirmed sandbox and supervisor-owned access plane continue to serve policy-authorized exec and loopback forwarding until explicit stop or @@ -100,7 +132,7 @@ OpenShell uses overlapping controls rather than a single sandbox primitive: | Filesystem policy | Landlock restricts the paths the agent can read or write. | | Process policy | Sandbox and children run as one immutable non-root identity with zero capabilities. | | Seccomp notification | Virtualizes supported INET sockets and sends DNS/TCP decisions to the supervisor without nftables or proxy environment variables. | -| Driver outer fence | Docker `network_mode=none`, a NIC-less VM, or Kubernetes NetworkPolicy prevents any missed or unsupported kernel path from escaping. | +| Outer network fence | The component that owns network enforcement prevents any missed or unsupported kernel path from escaping. Current examples are Docker `network_mode=none`, a NIC-less VM, and Kubernetes NetworkPolicy. | | Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and inference interception. | The supervisor may enrich baseline filesystem allowances for runtime-required @@ -201,7 +233,7 @@ cannot transfer that approval to another socket. The outer fence remains mandatory. If notification handling misses a syscall, loses the supervisor, exceeds a bound, or encounters an unsupported socket -type, the request fails and the driver-owned fence still blocks direct egress. +type, the request fails and the outer fence still blocks direct egress. CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same egress pipeline. Each adapter normalizes its request into an egress intent, and @@ -586,6 +618,14 @@ sandbox workload directly. The relay supports: `BASH_ENV` when the child environment sets it. - Tar-based file sync. - Port forwarding where supported by the CLI/TUI surface. +- Persistent HTTP and WebSocket service routing through gateway-managed + `ServiceEndpoint` records. `CreateSandboxRequest.service_exposures` registers + named or unnamed endpoints as part of sandbox creation, and the gateway + returns their routed URLs keyed by service name. The empty key identifies the + unnamed endpoint. Routing starts only while the sandbox is ready. `sandbox + create --expose PORT` uses the unnamed create-time endpoint and keeps the + sandbox. The standalone service API can add, update, or remove endpoints + later. Sandbox logs are emitted locally and can also be pushed back to the gateway. Security-relevant sandbox behavior uses OCSF structured events; internal @@ -596,6 +636,11 @@ HTTP Activity records contain a request or response; early rejections with only connection context use Network Activity. Producer regression tests validate required fields and `at_least_one` constraints against the vendored OCSF 1.8 schemas. +Network Activity records identify at least one observed endpoint; connection +failures retain their known peer or listening endpoint. +Configuration diagnostics use Config State Change. +Unix socket relay and relay-control notifications, plus proxy and mediation +failures without an observed network endpoint, use Base Event. ## Policy Proposals diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 0e1fe9242..07bd31cb4 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -20,7 +20,7 @@ use openshell_bootstrap::{ save_active_gateway, store_gateway_metadata, }; use openshell_bootstrap::{GatewayMetadataSource, ListedGateway}; -use openshell_core::proto::{GetGatewayInfoRequest, HealthRequest, ServiceStatus}; +use openshell_core::proto::{ExtensionKind, GetGatewayInfoRequest, HealthRequest, ServiceStatus}; use std::io::IsTerminal; use std::path::PathBuf; use tonic::{Code, Status}; @@ -33,6 +33,7 @@ struct GatewayInfoView { status: String, version: String, compute_drivers: Vec, + extensions: Vec, } #[derive(Debug, Clone)] @@ -47,6 +48,17 @@ struct ComputeDriverCapabilitiesView { driver_version: String, } +#[derive(Debug, Clone)] +struct ExtensionInfoView { + kind: String, + configured_name: String, + implementation_name: String, + implementation_version: String, + protocol_version: String, + supported_capabilities: Vec, + required_capabilities: Vec, +} + /// Show gateway status. #[allow(clippy::branches_sharing_code)] pub async fn gateway_status( @@ -376,6 +388,19 @@ pub async fn gateway_info( })? .into_inner(); + let extensions = info + .extensions + .into_iter() + .map(|extension| ExtensionInfoView { + kind: extension_kind_name(extension.kind).to_string(), + configured_name: extension.configured_name, + implementation_name: extension.implementation_name, + implementation_version: extension.implementation_version, + protocol_version: format!("{}.{}", extension.protocol_major, extension.protocol_minor), + supported_capabilities: extension.supported_capabilities, + required_capabilities: extension.required_capabilities, + }) + .collect(); let view = GatewayInfoView { gateway: gateway_name.to_string(), server: server.to_string(), @@ -396,6 +421,7 @@ pub async fn gateway_info( } }) .collect(), + extensions, }; print_gateway_info(&view, output) @@ -422,10 +448,52 @@ fn print_gateway_info(view: &GatewayInfoView, output: &str) -> Result<()> { println!(" {} {}", "Status:".dimmed(), view.status); println!(" {} {}", "Version:".dimmed(), view.version); print_compute_driver_info(&view.compute_drivers); + print_extension_info(&view.extensions); Ok(()) } +fn extension_kind_name(kind: i32) -> &'static str { + match ExtensionKind::try_from(kind).unwrap_or(ExtensionKind::Unspecified) { + ExtensionKind::ComputeDriver => "compute-driver", + ExtensionKind::CredentialDriver => "credential-driver", + ExtensionKind::GatewayInterceptor => "gateway-interceptor", + ExtensionKind::SupervisorMiddleware => "supervisor-middleware", + ExtensionKind::Unspecified => "unspecified", + } +} + +fn print_extension_info(extensions: &[ExtensionInfoView]) { + if extensions.is_empty() { + return; + } + println!(" {}", "Extensions:".dimmed()); + for extension in extensions { + println!(" {} ({})", extension.configured_name, extension.kind); + println!( + " {} {} {} (protocol {})", + "Implementation:".dimmed(), + extension.implementation_name, + extension.implementation_version, + extension.protocol_version + ); + if !extension.supported_capabilities.is_empty() { + println!( + " {} {}", + "Capabilities:".dimmed(), + extension.supported_capabilities.join(", ") + ); + } + if !extension.required_capabilities.is_empty() { + println!( + " {} {}", + "Requires gateway:".dimmed(), + extension.required_capabilities.join(", ") + ); + } + } +} + fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) { if drivers.is_empty() { return; @@ -467,6 +535,15 @@ fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value { }, })) .collect::>(), + "extensions": view.extensions.iter().map(|extension| serde_json::json!({ + "kind": &extension.kind, + "configured_name": &extension.configured_name, + "implementation_name": &extension.implementation_name, + "implementation_version": &extension.implementation_version, + "protocol_version": &extension.protocol_version, + "supported_capabilities": &extension.supported_capabilities, + "required_capabilities": &extension.required_capabilities, + })).collect::>(), }) } @@ -1507,9 +1584,9 @@ pub fn gateway_remove(name: &str) -> Result<()> { #[cfg(test)] mod tests { use super::{ - ComputeDriverCapabilitiesView, ComputeDriverInfoView, GatewayAuthenticationState, - GatewayInfoView, TlsOptions, format_gateway_select_header, format_gateway_select_items, - gateway_add, gateway_auth_label, gateway_authentication_state, + ComputeDriverCapabilitiesView, ComputeDriverInfoView, ExtensionInfoView, + GatewayAuthenticationState, GatewayInfoView, TlsOptions, format_gateway_select_header, + format_gateway_select_items, gateway_add, gateway_auth_label, gateway_authentication_state, gateway_env_override_warning, gateway_info_to_json, gateway_remote_label, gateway_select_with, gateway_to_json, gateway_type_label, http_health_check, import_local_package_mtls_bundle, mtls_certs_exist_for_gateway, package_managed_tls_dirs, @@ -1824,6 +1901,15 @@ mod tests { driver_version: "0.0.75".to_string(), }, }], + extensions: vec![ExtensionInfoView { + kind: "compute-driver".to_string(), + configured_name: "podman".to_string(), + implementation_name: "openshell/podman".to_string(), + implementation_version: "0.0.75".to_string(), + protocol_version: "1.0".to_string(), + supported_capabilities: vec!["openshell.compute.contract".to_string()], + required_capabilities: vec!["openshell.compute.contract".to_string()], + }], }; let json = gateway_info_to_json(&view); @@ -1836,6 +1922,7 @@ mod tests { json["compute_drivers"][0]["capabilities"]["driver_name"], "podman" ); + assert_eq!(json["extensions"][0]["protocol_version"], "1.0"); assert_eq!( json["compute_drivers"][0]["capabilities"]["driver_version"], "0.0.75" @@ -1851,6 +1938,7 @@ mod tests { status: "healthy".to_string(), version: "0.0.74".to_string(), compute_drivers: Vec::new(), + extensions: Vec::new(), }; let json = gateway_info_to_json(&view); diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 05febb396..6f29ab844 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -28,6 +28,9 @@ use openshell_core::proto::{ }; use openshell_core::rpc_error::{ERROR_DOMAIN, decode_details}; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; +use openshell_policy::{ + network_access_preset_to_str, network_enforcement_mode_to_str, network_tls_mode_to_str, +}; use openshell_providers::{ ProviderTypeProfile, RealDiscoveryContext, discover_from_profile, parse_profile_json, parse_profile_yaml, profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, @@ -1558,56 +1561,83 @@ pub async fn provider_list( Ok(()) } +/// List the provider profiles visible in the requested workspace. pub async fn provider_list_profiles( server: &str, output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { + let rendered = provider_list_profiles_text(server, output, workspace, tls).await?; + println!("{}", rendered.trim_end()); + Ok(()) +} + +/// Fetch and render every page of the visible provider profile catalog. +pub async fn provider_list_profiles_text( + server: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result { let mut client = grpc_client(server, tls).await?; let mut dto_profiles = fetch_provider_profile_catalog(&mut client, workspace).await?; dto_profiles.sort_by(|left, right| { - left.category - .cmp(&right.category) - .then_with(|| left.id.cmp(&right.id)) + left.id + .cmp(&right.id) + .then_with(|| left.scope.cmp(&right.scope)) + .then_with(|| left.source.cmp(&right.source)) }); let profiles = dto_profiles .iter() .map(ProviderTypeProfile::to_proto) .collect::>(); - if crate::output::print_output_direct( - output, - || profiles_to_json(&dto_profiles).into_diagnostic(), - || profiles_to_yaml(&dto_profiles).into_diagnostic(), - )? { - return Ok(()); - } - - if profiles.is_empty() { - println!("No provider profiles found."); - return Ok(()); - } - - println!("{}", "Available Provider Profiles:".cyan().bold()); - let id_width = provider_profile_id_width(&profiles); - let display_width = provider_profile_display_width(&profiles); - let source_width = provider_profile_source_width(&profiles); - let scope_width = provider_profile_scope_width(&profiles); - let mut current_category = i32::MIN; - for profile in &profiles { - if profile.category != current_category { - current_category = profile.category; - println!(); - println!(" {}", display_provider_category(current_category).bold()); - print_provider_type_header(id_width, scope_width, source_width, display_width); - } - print_provider_type_row(profile, id_width, scope_width, source_width, display_width); + match output { + "json" => profiles_to_json(&dto_profiles).into_diagnostic(), + "yaml" => profiles_to_yaml(&dto_profiles).into_diagnostic(), + "table" => Ok(format_provider_profile_table(&profiles)), + _ => Err(miette!("unsupported output format: {output}")), } +} +/// Describe one resolved provider profile without retrieving provider credentials. +pub async fn provider_profile_describe( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let rendered = provider_profile_describe_text(server, id, output, workspace, tls).await?; + println!("{}", rendered.trim_end()); Ok(()) } +/// Fetch and render a profile definition using workspace-aware resolution. +pub async fn provider_profile_describe_text( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace_scope: provider_profile_workspace_scope(workspace), + }) + .await + .into_diagnostic()?; + let profile = response + .into_inner() + .profile + .ok_or_else(|| miette!("provider profile '{id}' not found"))?; + + format_provider_profile_description(&profile, output) +} + pub async fn provider_profile_export( server: &str, id: &str, @@ -2218,7 +2248,6 @@ fn display_provider_category(category: i32) -> &'static str { } const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32; -const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40; const PROVIDER_PROFILE_SOURCE_MAX_WIDTH: usize = 24; fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { @@ -2232,21 +2261,6 @@ fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { .min(PROVIDER_PROFILE_ID_MAX_WIDTH) }) .max() - .unwrap_or(2) - .max(2) -} - -fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| { - profile - .display_name - .chars() - .count() - .min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH) - }) - .max() .unwrap_or(4) .max(4) } @@ -2275,40 +2289,226 @@ fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize { .max(6) } -fn print_provider_type_header( - id_width: usize, - scope_width: usize, - source_width: usize, - display_width: usize, -) { - let endpoints = "ENDPOINTS"; - println!( - " {: String { + use std::fmt::Write as _; + + if profiles.is_empty() { + return "No profiles found.\n".to_string(); + } + + let id_width = provider_profile_id_width(profiles); + let scope_width = provider_profile_scope_width(profiles); + let source_width = provider_profile_source_width(profiles); + let category_width = profiles + .iter() + .map(|profile| display_provider_category(profile.category).len()) + .max() + .unwrap_or(8) + .max(8); + let mut rendered = String::new(); + let _ = writeln!( + rendered, + "{: Result { + let dto = ProviderTypeProfile::from_proto(profile); + match output { + "json" => profile_to_json(&dto).into_diagnostic(), + "yaml" => profile_to_yaml(&dto).into_diagnostic(), + "table" => Ok(format_provider_profile_details(profile)), + _ => Err(miette!("unsupported output format: {output}")), + } +} + +fn format_provider_profile_details(profile: &ProviderProfile) -> String { + use std::fmt::Write as _; + + let mut rendered = format!("{} (provider)\n", profile.id); + for (label, value) in [ + ("Category", display_provider_category(profile.category)), + ("Display name", profile.display_name.as_str()), + ("Description", profile.description.as_str()), + ("Source", profile.source.as_str()), + ("Scope", profile.scope.as_str()), + ] { + let value = if value.is_empty() { + "not specified" + } else { + value + }; + let _ = writeln!(rendered, "{label}: {value}"); + } + let _ = writeln!(rendered, "Inference capable: {}", profile.inference_capable); + + rendered.push_str("\nCredentials:\n"); + if profile.credentials.is_empty() { + rendered.push_str(" None declared.\n"); + } + // Profiles declare credential names and injection metadata. Never resolve + // local discovery sources or provider instances while describing a profile. + for credential in &profile.credentials { + let requirement = if credential.required { + "required" + } else { + "optional" + }; + let _ = writeln!(rendered, " {} ({requirement})", credential.name); + if !credential.description.is_empty() { + let _ = writeln!(rendered, " Description: {}", credential.description); + } + let env_vars = if credential.env_vars.is_empty() { + "none declared".to_string() + } else { + credential.env_vars.join(", ") + }; + let _ = writeln!(rendered, " Environment variables: {env_vars}"); + let auth_style = if credential.auth_style.is_empty() { + "not specified" + } else { + &credential.auth_style + }; + let _ = writeln!(rendered, " Authentication: {auth_style}"); + if !credential.header_name.is_empty() { + let _ = writeln!(rendered, " Header: {}", credential.header_name); + } + if !credential.query_param.is_empty() { + let _ = writeln!(rendered, " Query parameter: {}", credential.query_param); + } + } + + rendered.push_str("\nEndpoints (declared policy):\n"); + if profile.endpoints.is_empty() { + rendered.push_str(" None declared.\n"); + } + for endpoint in &profile.endpoints { + let host = if endpoint.host.is_empty() { + "any host matching allowed IPs" + } else { + &endpoint.host + }; + let _ = writeln!(rendered, " {host}"); + // The endpoint contract gives the repeated ports field precedence over + // the single port; display the effective declared set in that order. + let ports = if !endpoint.ports.is_empty() { + endpoint + .ports + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + } else if endpoint.port != 0 { + endpoint.port.to_string() + } else { + "not specified".to_string() + }; + let _ = writeln!(rendered, " Ports: {ports}"); + if !endpoint.path.is_empty() { + let _ = writeln!(rendered, " Path: {}", endpoint.path); + } + let protocol = match endpoint.protocol.as_str() { + "" => "tcp (default; no L7 inspection)", + "tcp" => "tcp (no L7 inspection)", + protocol => protocol, + }; + let _ = writeln!(rendered, " Protocol: {protocol}"); + // A declared L7 protocol does not imply inspection when TLS handling + // selects a raw tunnel. Keep this separate from the declared rules. + // Unknown protobuf values must remain visible rather than appearing + // to select the valid default mode. + let tls = match network_tls_mode_to_str(endpoint.tls) { + Some("") => "auto (default)".to_string(), + Some("skip") => "skip (raw tunnel; no L7 inspection or credential rewrite)".to_string(), + Some("terminate") => "terminate (automatic TLS detection)".to_string(), + Some("passthrough") => "passthrough (automatic TLS detection)".to_string(), + Some(tls) => tls.to_string(), + None => format!("unknown({})", endpoint.tls), + }; + let _ = writeln!(rendered, " TLS: {tls}"); + let _ = writeln!( + rendered, + " Allow uninspected credentials: {}", + endpoint.allow_uninspected_credentials + ); + let mcp = endpoint.mcp.as_ref(); + let is_mcp = endpoint.protocol.eq_ignore_ascii_case("mcp"); + let allow_all_known_mcp_methods = + mcp.and_then(|options| options.allow_all_known_mcp_methods); + // An explicit rule set can include writes even when other endpoints + // use read-only presets. Report its shape without guessing its access. + let access = match network_access_preset_to_str(endpoint.access) { + Some("") if !endpoint.rules.is_empty() => "custom rules".to_string(), + Some("") if is_mcp && allow_all_known_mcp_methods == Some(true) => { + "all known MCP methods (subject to tool and deny rules)".to_string() + } + Some("") => "not specified".to_string(), + Some(access) => access.to_string(), + None => format!("unknown({})", endpoint.access), + }; + let _ = writeln!(rendered, " Access: {access}"); + if !endpoint.rules.is_empty() || !endpoint.deny_rules.is_empty() { + let _ = writeln!( + rendered, + " Rules: {} allow, {} deny", + endpoint.rules.len(), + endpoint.deny_rules.len() + ); + } + let enforcement = match network_enforcement_mode_to_str(endpoint.enforcement) { + Some("") => "audit (default)".to_string(), + Some(enforcement) => enforcement.to_string(), + None => format!("unknown({})", endpoint.enforcement), + }; + let _ = writeln!(rendered, " Enforcement: {enforcement}"); + if is_mcp { + // MCP method defaults and tool-name validation are independent of + // explicit allow/deny rules; show both even when rules are present. + let methods = allow_all_known_mcp_methods + .map_or_else(|| "false (default)".to_string(), |value| value.to_string()); + let strict_names = mcp + .and_then(|options| options.strict_tool_names) + .map_or_else(|| "true (default)".to_string(), |value| value.to_string()); + let versions = mcp + .filter(|options| !options.versions.is_empty()) + .map_or_else( + || "not specified".to_string(), + |options| options.versions.join(", "), + ); + let _ = writeln!(rendered, " Allow all known MCP methods: {methods}"); + let _ = writeln!(rendered, " Strict MCP tool names: {strict_names}"); + let _ = writeln!(rendered, " MCP versions (declared): {versions}"); + } + if !endpoint.allowed_ips.is_empty() { + let _ = writeln!( + rendered, + " Allowed IPs: {}", + endpoint.allowed_ips.join(", ") + ); + } + } + + rendered.push_str("\nBinaries:\n"); + if profile.binaries.is_empty() { + rendered.push_str(" None declared.\n"); + } + for binary in &profile.binaries { + let _ = writeln!(rendered, " {}", binary.path); + } + rendered } /// Credential update inputs and observation choices for all attached sandboxes. @@ -2562,6 +2762,359 @@ mod tests { )); } + #[test] + fn profile_list_table_includes_type_category_source_and_scope() { + let profiles = vec![ + ProviderProfile { + id: "github".to_string(), + category: ProviderProfileCategory::SourceControl as i32, + source: "builtin".to_string(), + scope: "platform".to_string(), + ..Default::default() + }, + ProviderProfile { + id: "github".to_string(), + category: ProviderProfileCategory::SourceControl as i32, + source: "user".to_string(), + scope: "workspace".to_string(), + ..Default::default() + }, + ProviderProfile { + id: "search".to_string(), + category: ProviderProfileCategory::Knowledge as i32, + source: "interceptor/search".to_string(), + ..Default::default() + }, + ]; + + let rendered = format_provider_profile_table(&profiles); + let rows = rendered.lines().collect::>(); + assert_eq!(rows.len(), 4); + assert_eq!( + rows[0].split_whitespace().collect::>(), + ["NAME", "TYPE", "CATEGORY", "SOURCE", "SCOPE"] + ); + assert_eq!( + rows[1].split_whitespace().collect::>(), + [ + "github", "provider", "SOURCE", "CONTROL", "builtin", "platform" + ] + ); + assert_eq!( + rows[2].split_whitespace().collect::>(), + [ + "github", + "provider", + "SOURCE", + "CONTROL", + "user", + "workspace" + ] + ); + assert_eq!( + rows[3].split_whitespace().collect::>(), + ["search", "provider", "KNOWLEDGE", "interceptor/search"] + ); + } + + #[test] + fn profile_list_table_reports_empty_catalog() { + assert_eq!(format_provider_profile_table(&[]), "No profiles found.\n"); + } + + #[test] + fn profile_description_keeps_credential_values_out_of_human_output() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _credential = EnvVarGuard::set("GITHUB_TOKEN", "test-private-token-value"); + let mut profile = openshell_providers::example_profiles::load("github").to_proto(); + profile.source = "user".to_string(); + profile.scope = "platform".to_string(); + + let rendered = + format_provider_profile_description(&profile, "table").expect("profile details render"); + assert!(rendered.starts_with("github (provider)\nCategory: SOURCE CONTROL\n")); + assert!( + rendered.contains("Display name: GitHub\nDescription: GitHub API and Git operations\n") + ); + assert!(rendered.contains("Source: user\nScope: platform\n")); + assert!(rendered.contains("Environment variables: GITHUB_TOKEN, GH_TOKEN\n")); + assert!(rendered.contains("Authentication: bearer\n")); + assert!(rendered.contains("Header: authorization\n")); + assert!(!rendered.contains("test-private-token-value")); + assert!(!rendered.contains("Default URL")); + assert!(rendered.contains("Protocol: graphql\n")); + assert!(rendered.contains("Path: /graphql\n")); + assert_eq!(rendered.matches("Access: read-only\n").count(), 2); + assert!(rendered.contains("Access: custom rules\n Rules: 4 allow, 0 deny\n")); + assert!(rendered.contains("Enforcement: enforce\n")); + assert!(rendered.contains(" /usr/bin/gh\n")); + assert!(rendered.contains(" /usr/local/bin/git\n")); + } + + #[test] + fn profile_description_distinguishes_defaults_and_explicit_policy() { + let profile = ProviderProfile { + id: "custom".to_string(), + endpoints: vec![ + openshell_core::proto::NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ports: vec![8443, 9443], + ..Default::default() + }, + openshell_core::proto::NetworkEndpoint { + host: "write.example.com".to_string(), + port: 443, + protocol: "rest".to_string(), + access: openshell_core::proto::NetworkAccessPreset::ReadWrite.into(), + enforcement: openshell_core::proto::NetworkEnforcementMode::Audit.into(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let rendered = format_provider_profile_details(&profile); + assert!(rendered.contains("Ports: 8443, 9443\n")); + assert!(rendered.contains("Protocol: tcp (default; no L7 inspection)\n")); + assert!(rendered.contains("Access: not specified\n Enforcement: audit (default)\n")); + assert!(rendered.contains("Access: read-write\n Enforcement: audit\n")); + assert!(!rendered.contains("read-only")); + assert!(rendered.contains("Credentials:\n None declared.\n")); + assert!(rendered.contains("Binaries:\n None declared.\n")); + } + + #[test] + fn profile_description_distinguishes_tls_skip_and_credential_opt_in() { + let profile = parse_profile_yaml( + r" +id: custom +display_name: Custom API +credentials: + - name: token + env_vars: [CUSTOM_TOKEN] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: api.example.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/curl] +", + ) + .expect("valid profile fixture"); + let mut descriptions = Vec::new(); + for (tls, allow_uninspected_credentials) in [ + ("", false), + ("", true), + ("skip", true), + ("terminate", false), + ("passthrough", false), + ] { + let mut variant = profile.clone(); + variant.endpoints[0].tls = tls.to_string(); + variant.endpoints[0].allow_uninspected_credentials = allow_uninspected_credentials; + let diagnostics = openshell_providers::validate_profile_set(&[( + "profile.yaml".to_string(), + variant.clone(), + )]); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.severity != "error"), + "variant must be importable: {diagnostics:?}" + ); + descriptions.push(format_provider_profile_details(&variant.to_proto())); + } + + assert_ne!( + descriptions[0], descriptions[1], + "the credential inspection opt-in must be visible independently of TLS mode" + ); + assert_ne!( + descriptions[1], descriptions[2], + "a raw tunnel must not look identical to an inspected endpoint" + ); + assert!(descriptions[0].contains("TLS: auto (default)\n")); + assert!(descriptions[0].contains("Allow uninspected credentials: false\n")); + assert!( + descriptions[2] + .contains("TLS: skip (raw tunnel; no L7 inspection or credential rewrite)\n") + ); + assert!(descriptions[2].contains("Allow uninspected credentials: true\n")); + assert!(descriptions[3].contains("TLS: terminate (automatic TLS detection)\n")); + assert!(descriptions[4].contains("TLS: passthrough (automatic TLS detection)\n")); + } + + #[test] + fn profile_description_preserves_unknown_security_modes() { + // Exercise each independent wire field with other modes left valid. + // Unknown access must also take precedence over the inferred rule label. + for value in [-1, 99] { + for field in ["TLS", "Access", "Enforcement"] { + let mut endpoint = openshell_core::proto::NetworkEndpoint { + host: "api.example.com".to_string(), + rules: vec![openshell_core::proto::L7Rule::default()], + ..Default::default() + }; + match field { + "TLS" => endpoint.tls = value, + "Access" => endpoint.access = value, + "Enforcement" => endpoint.enforcement = value, + _ => unreachable!("test cases enumerate the security mode fields"), + } + let profile = ProviderProfile { + endpoints: vec![endpoint], + ..Default::default() + }; + let rendered = format_provider_profile_details(&profile); + assert!( + rendered.contains(&format!(" {field}: unknown({value})\n")), + "unknown {field} must not appear as a valid default: {rendered}" + ); + } + } + } + + #[test] + fn profile_description_displays_each_access_preset() { + use openshell_core::proto::NetworkAccessPreset; + + for (access, expected) in [ + (NetworkAccessPreset::Unspecified, "not specified"), + (NetworkAccessPreset::ReadOnly, "read-only"), + (NetworkAccessPreset::ReadWrite, "read-write"), + (NetworkAccessPreset::Full, "full"), + ] { + let profile = ProviderProfile { + endpoints: vec![openshell_core::proto::NetworkEndpoint { + access: access.into(), + ..Default::default() + }], + ..Default::default() + }; + let rendered = format_provider_profile_details(&profile); + assert!(rendered.contains(&format!(" Access: {expected}\n"))); + } + } + + #[test] + fn profile_description_exposes_mcp_method_and_tool_name_options() { + let profile = parse_profile_yaml( + r" +id: mcp-review +display_name: MCP Review +endpoints: + - host: mcp.example.com + port: 443 + protocol: mcp + enforcement: enforce + mcp: + versions: ['2025-11-25'] + allow_all_known_mcp_methods: true +binaries: [/usr/bin/curl] +", + ) + .expect("valid profile fixture"); + for protocol in ["mcp", "MCP", "McP"] { + let mut variant = profile.clone(); + variant.endpoints[0].protocol = protocol.to_string(); + let diagnostics = openshell_providers::validate_profile_set(&[( + "profile.yaml".to_string(), + variant.clone(), + )]); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.severity != "error"), + "ruleless MCP opt-in must be importable: {diagnostics:?}" + ); + let mut proto = variant.to_proto(); + let rendered = format_provider_profile_details(&proto); + assert!( + rendered + .contains("Access: all known MCP methods (subject to tool and deny rules)\n") + ); + assert!(rendered.contains("Allow all known MCP methods: true\n")); + assert!(rendered.contains("Strict MCP tool names: true (default)\n")); + assert!(rendered.contains("MCP versions (declared): 2025-11-25\n")); + + // Explicit rules remain relevant when the method default is enabled, + // and independent tool-name validation must not disappear from view. + proto.endpoints[0].rules = vec![openshell_core::proto::L7Rule { + allow: Some(openshell_core::proto::L7Allow { + method: "tools/list".to_string(), + ..Default::default() + }), + }]; + for allow_all in [false, true] { + let options = proto.endpoints[0].mcp.as_mut().expect("MCP options"); + options.allow_all_known_mcp_methods = Some(allow_all); + options.strict_tool_names = Some(false); + let rendered = format_provider_profile_details(&proto); + assert!(rendered.contains("Access: custom rules\n Rules: 1 allow, 0 deny\n")); + assert!(rendered.contains(&format!("Allow all known MCP methods: {allow_all}\n"))); + assert!(rendered.contains("Strict MCP tool names: false\n")); + } + } + } + + #[test] + fn profile_description_structured_output_preserves_full_definition() { + let profile = parse_profile_yaml( + r" +id: custom +display_name: Custom API +category: data +source: user +scope: workspace +resource_version: 7 +annotations: { owner: example } +credentials: + - name: token + env_vars: [CUSTOM_TOKEN] + required: true + auth_style: bearer +endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + allow_encoded_slash: true + rules: + - allow: + method: GET + path: /records/** + query: { mode: safe } + deny_rules: + - method: GET + path: /records/private/** +binaries: [/usr/bin/curl] +", + ) + .expect("valid profile fixture"); + let proto = profile.to_proto(); + for output in ["json", "yaml"] { + let rendered = format_provider_profile_description(&proto, output) + .expect("structured description renders"); + let roundtrip = if output == "json" { + parse_profile_json(&rendered) + } else { + parse_profile_yaml(&rendered) + } + .expect("structured description is a full profile document"); + assert_eq!(roundtrip, ProviderTypeProfile::from_proto(&proto)); + assert!(rendered.contains("allow_encoded_slash")); + assert!(rendered.contains("/records/private/**")); + assert!(rendered.contains("CUSTOM_TOKEN")); + } + } + #[test] fn attached_provider_json_is_sorted_and_secret_safe() { let provider = Provider { diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 139271ed0..047f35f6a 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -267,6 +267,7 @@ const HELP_TEMPLATE: &str = "\ policy: Manage sandbox policy settings: Manage sandbox and global settings provider: Manage provider configuration + profile: Browse and manage profiles \x1b[1mGATEWAY COMMANDS\x1b[0m gateway: Manage gateways @@ -388,6 +389,14 @@ const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell provider delete openai "; +const PROFILE_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m + $ openshell profile list + $ openshell profile list --type provider -o json + $ openshell profile describe openai + $ openshell profile export openai + $ openshell profile import -f custom-api.yaml +"; + const WORKSPACE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m ws @@ -576,6 +585,13 @@ enum Commands { command: Option, }, + /// Browse and manage profiles. + #[command(after_help = PROFILE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Profile { + #[command(subcommand)] + command: Option, + }, + /// Manage workspaces. #[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Workspace { @@ -898,7 +914,7 @@ enum ProviderCommands { all_workspaces: bool, }, - /// List available provider profiles. + /// List available provider profiles (alias for `profile list --type provider`). #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] ListProfiles { /// Output format. @@ -910,9 +926,9 @@ enum ProviderCommands { global: bool, }, - /// Manage provider profiles. + /// Manage provider profiles (alias for `profile`). #[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)] - Profile(ProviderProfileCommands), + Profile(ProfileCommands), /// Update an existing provider's credentials or config. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] @@ -1034,8 +1050,46 @@ enum ProviderRefreshCommands { }, } -#[derive(Subcommand, Debug)] -enum ProviderProfileCommands { +/// Profile kinds exposed by the gateway's profile catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum ProfileType { + /// Credential, endpoint, and policy definitions for providers. + Provider, +} + +#[derive(Subcommand, Debug, PartialEq, Eq)] +enum ProfileCommands { + /// List available profiles. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Filter profiles by type. + #[arg(long = "type", value_enum)] + profile_type: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + + /// List platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Describe a profile's credentials, endpoints, and policy defaults. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Describe { + /// Profile id. + id: String, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + + /// Describe a platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, + }, + /// Export a provider profile. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Export { @@ -1111,6 +1165,83 @@ enum ProviderProfileCommands { }, } +impl ProfileCommands { + /// Execute either command spelling against the same authenticated gateway. + async fn run(self, endpoint: &str, workspace: &str, tls: &TlsOptions) -> Result<()> { + let profile_workspace = |global: bool| -> &str { if global { "" } else { workspace } }; + match self { + Self::List { + profile_type, + output, + global, + } => { + // The catalog currently contains only provider profiles. An exhaustive + // match requires a routing decision when another profile kind is added. + match profile_type { + None | Some(ProfileType::Provider) => { + run::provider_list_profiles( + endpoint, + output.as_str(), + profile_workspace(global), + tls, + ) + .await?; + } + } + } + Self::Describe { id, output, global } => { + run::provider_profile_describe( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Export { id, output, global } => { + run::provider_profile_export( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Import { file, from, global } => { + run::provider_profile_import( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Update { id, file, global } => { + run::provider_profile_update(endpoint, &id, &file, profile_workspace(global), tls) + .await?; + } + Self::Lint { file, from, global } => { + run::provider_profile_lint( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Delete { ids, global } => { + run::provider_profile_delete(endpoint, &ids, profile_workspace(global), tls) + .await?; + } + } + Ok(()) + } +} + // ----------------------------------------------------------------------- // Gateway commands (replaces the old `cluster` / `cluster admin` groups) // ----------------------------------------------------------------------- @@ -1316,7 +1447,7 @@ enum SandboxCommands { keep: bool, /// Delete the sandbox after the initial command or shell exits. - #[arg(long, conflicts_with_all = ["keep", "editor", "forward"])] + #[arg(long, conflicts_with_all = ["keep", "editor", "forward", "expose"])] no_keep: bool, /// Launch a remote editor after the sandbox is ready. @@ -1363,6 +1494,16 @@ enum SandboxCommands { #[arg(long, conflicts_with = "no_keep")] forward: Option, + /// Expose a loopback HTTP or WebSocket service after the sandbox is ready. + /// Keeps the sandbox alive. Use `openshell service expose` to add a named service. + #[arg( + long, + value_name = "PORT", + value_parser = clap::value_parser!(u16).range(1..), + conflicts_with = "no_keep" + )] + expose: Option, + /// Allocate a pseudo-terminal for the remote command. /// Defaults to auto-detection (on when stdin and stdout are terminals). /// Use --tty to force a PTY even when auto-detection fails, or @@ -1416,7 +1557,7 @@ enum SandboxCommands { approval_mode: String, /// Output format. - #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["editor", "command", "no_keep", "forward"])] + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["editor", "no_keep", "forward"])] output: OutputFormat, /// Command to run after "--" (defaults to an interactive shell). @@ -3144,6 +3285,7 @@ async fn run_async() -> Result<()> { providers, policy, forward, + expose, tty, no_tty, detach, @@ -3211,7 +3353,11 @@ async fn run_async() -> Result<()> { let forward = forward .map(|s| openshell_core::forward::ForwardSpec::parse(&s)) .transpose()?; - let keep = keep || !no_keep || editor.is_some() || forward.is_some(); + let keep = keep + || !no_keep + || editor.is_some() + || forward.is_some() + || expose.is_some(); let gpu_requirements: Option = gpu.map(Into::into); let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; @@ -3235,6 +3381,7 @@ async fn run_async() -> Result<()> { providers: &providers, policy: policy.as_deref(), forward, + expose, command: &command, tty_override, auto_providers_override, @@ -3756,6 +3903,20 @@ async fn run_async() -> Result<()> { .await?; } }, + ProviderCommands::ListProfiles { output, global } => { + // Normalize the legacy list command before execution so output, + // pagination, and scope handling stay identical for both spellings. + ProfileCommands::List { + profile_type: Some(ProfileType::Provider), + output, + global, + } + .run(endpoint, &cli.workspace, &tls) + .await?; + } + ProviderCommands::Profile(command) => { + command.run(endpoint, &cli.workspace, &tls).await?; + } ProviderCommands::Get { name } => { run::provider_get(endpoint, &name, &cli.workspace, &tls).await?; } @@ -3778,65 +3939,6 @@ async fn run_async() -> Result<()> { ) .await?; } - ProviderCommands::ListProfiles { output, global } => { - let ws = if global { "" } else { &cli.workspace }; - run::provider_list_profiles(endpoint, output.as_str(), ws, &tls).await?; - } - ProviderCommands::Profile(command) => { - let profile_workspace = - |global: bool| -> &str { if global { "" } else { &cli.workspace } }; - match command { - ProviderProfileCommands::Export { id, output, global } => { - run::provider_profile_export( - endpoint, - &id, - output.as_str(), - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Import { file, from, global } => { - run::provider_profile_import( - endpoint, - file.as_deref(), - from.as_deref(), - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Update { id, file, global } => { - run::provider_profile_update( - endpoint, - &id, - &file, - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Lint { file, from, global } => { - run::provider_profile_lint( - endpoint, - file.as_deref(), - from.as_deref(), - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Delete { ids, global } => { - run::provider_profile_delete( - endpoint, - &ids, - profile_workspace(global), - &tls, - ) - .await?; - } - } - } ProviderCommands::Update { name, from_existing, @@ -3865,6 +3967,15 @@ async fn run_async() -> Result<()> { } } } + Some(Commands::Profile { + command: Some(command), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let endpoint = &ctx.endpoint; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name)?; + command.run(endpoint, &cli.workspace, &tls).await?; + } Some(Commands::Term { theme }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); @@ -3990,6 +4101,13 @@ async fn run_async() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Profile { command: None }) => { + Cli::command() + .find_subcommand_mut("profile") + .ok_or_else(|| miette::miette!("profile command is unavailable"))? + .print_help() + .map_err(|error| miette::miette!("failed to print profile help: {error}"))?; + } Some(Commands::Provider { command: None }) => { Cli::command() .find_subcommand_mut("provider") @@ -4781,118 +4899,260 @@ mod tests { } #[test] - fn provider_list_profiles_parses() { - let cli = Cli::try_parse_from(["openshell", "provider", "list-profiles"]) - .expect("provider list-profiles should parse"); - + fn profile_list_defaults_and_type_filter_parse() { + let cli = Cli::try_parse_from(["openshell", "profile", "list"]).expect("profile list"); assert!(matches!( cli.command, - Some(Commands::Provider { - command: Some(ProviderCommands::ListProfiles { + Some(Commands::Profile { + command: Some(ProfileCommands::List { + profile_type: None, output: OutputFormat::Table, global: false, }) }) )); + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let cli = Cli::try_parse_from([ + "openshell", + "profile", + "list", + "--type", + "provider", + "-o", + format, + "--global", + ]) + .expect("profile list with type, output, and scope"); + assert!(matches!(cli.command, Some(Commands::Profile { + command: Some(ProfileCommands::List { + profile_type: Some(ProfileType::Provider), output, global: true, + }) + }) if output == expected)); + } + assert!( + Cli::try_parse_from(["openshell", "profile", "list", "--type", "unknown"]).is_err() + ); } #[test] - fn provider_list_profiles_accepts_output_format() { - let cli = Cli::try_parse_from(["openshell", "provider", "list-profiles", "-o", "json"]) - .expect("provider list-profiles -o json should parse"); - + fn profile_describe_parses_output_and_scope() { + for (format, expected) in [ + ("table", OutputFormat::Table), + ("json", OutputFormat::Json), + ("yaml", OutputFormat::Yaml), + ] { + let cli = Cli::try_parse_from([ + "openshell", + "--workspace", + "team", + "profile", + "describe", + "openai", + "-o", + format, + "--global", + ]) + .expect("profile describe"); + assert_eq!(cli.workspace, "team"); + assert!(matches!(cli.command, Some(Commands::Profile { + command: Some(ProfileCommands::Describe {id, output, global: true}) + }) if id == "openai" && output == expected)); + } + let cli = Cli::try_parse_from(["openshell", "profile", "describe", "openai"]) + .expect("profile describe defaults"); assert!(matches!( cli.command, - Some(Commands::Provider { - command: Some(ProviderCommands::ListProfiles { - output: OutputFormat::Json, + Some(Commands::Profile { + command: Some(ProfileCommands::Describe { + output: OutputFormat::Table, global: false, + .. }) }) )); + assert!(Cli::try_parse_from(["openshell", "profile", "describe"]).is_err()); } #[test] - fn provider_profile_commands_parse() { - let export = Cli::try_parse_from([ - "openshell", - "provider", - "profile", - "export", - "custom-api", - "-o", - "yaml", - ]) - .expect("provider profile export should parse"); - assert!(matches!( - export.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { - id, - output: OutputFormat::Yaml, - .. - })) - }) if id == "custom-api" - )); - - let import = Cli::try_parse_from([ - "openshell", - "provider", - "profile", - "import", - "--from", - "./profiles", - ]) - .expect("provider profile import should parse"); - assert!(matches!( - import.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Import { - from: Some(_), - .. - })) - }) - )); - + fn profile_crud_subcommands_parse() { + let export = Cli::try_parse_from(["openshell", "profile", "export", "custom-api"]) + .expect("profile export"); + assert!(matches!(export.command, Some(Commands::Profile { + command: Some(ProfileCommands::Export {id, output: OutputFormat::Yaml, global: false}) + }) if id == "custom-api")); let update = Cli::try_parse_from([ "openshell", - "provider", "profile", "update", "custom-api", "-f", - "./profiles/custom-api.yaml", + "custom-api.yaml", + "--global", ]) - .expect("provider profile update should parse"); - assert!(matches!( - update.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { - id, - file: _, - .. - })) - }) if id == "custom-api" - )); - + .expect("profile update"); + assert!(matches!(update.command, Some(Commands::Profile { + command: Some(ProfileCommands::Update {id, file, global: true}) + }) if id == "custom-api" && file == std::path::Path::new("custom-api.yaml"))); let delete = Cli::try_parse_from([ "openshell", - "provider", "profile", "delete", "custom-api", "custom-alt", + "--global", ]) - .expect("provider profile delete should parse"); + .expect("profile delete"); + assert!(matches!(delete.command, Some(Commands::Profile { + command: Some(ProfileCommands::Delete {ids, global: true}) + }) if ids == ["custom-api", "custom-alt"])); + } + + #[test] + fn profile_import_and_lint_require_exactly_one_source() { + for verb in ["import", "lint"] { + for source in ["-f", "--from"] { + let cli = Cli::try_parse_from([ + "openshell", + "profile", + verb, + source, + "./profiles", + "--global", + ]) + .expect("profile source should parse"); + let (file, from, global) = match cli.command { + Some(Commands::Profile { + command: + Some( + ProfileCommands::Import { file, from, global } + | ProfileCommands::Lint { file, from, global }, + ), + }) => (file, from, global), + other => panic!("unexpected profile command: {other:?}"), + }; + assert!(global); + assert_eq!(file.is_some(), source == "-f"); + assert_eq!(from.is_some(), source == "--from"); + } + assert!(Cli::try_parse_from(["openshell", "profile", verb]).is_err()); + assert!( + Cli::try_parse_from([ + "openshell", + "profile", + verb, + "-f", + "file.yaml", + "--from", + "./profiles" + ]) + .is_err() + ); + } + } + + #[test] + fn profile_help_exposes_all_commands_without_gateway() { + let cli = Cli::try_parse_from(["openshell", "profile"]).expect("profile help"); assert!(matches!( - delete.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - ids, - .. - })) - }) if ids == vec!["custom-api".to_string(), "custom-alt".to_string()] + cli.command, + Some(Commands::Profile { command: None }) )); + let mut root = Cli::command(); + assert!(root.render_help().to_string().contains("profile:")); + let profile = root + .find_subcommand_mut("profile") + .expect("profile command"); + assert!(!profile.is_hide_set()); + for verb in [ + "list", "describe", "export", "import", "update", "lint", "delete", + ] { + assert!(profile.find_subcommand(verb).is_some(), "missing {verb}"); + } + } + + #[test] + fn profile_legacy_list_preserves_output_and_scope_flags() { + let default = Cli::try_parse_from(["openshell", "provider", "list-profiles"]) + .expect("legacy profile list"); + assert!(matches!( + default.command, + Some(Commands::Provider { + command: Some(ProviderCommands::ListProfiles { + output: OutputFormat::Table, + global: false + }) + }) + )); + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let cli = Cli::try_parse_from([ + "openshell", + "provider", + "list-profiles", + "--workspace", + "team", + "--global", + "-o", + format, + ]) + .expect("legacy list with output and scope"); + assert_eq!(cli.workspace, "team"); + assert!(matches!(cli.command, Some(Commands::Provider { + command: Some(ProviderCommands::ListProfiles {output, global: true}) + }) if output == expected)); + } + } + + #[test] + fn profile_legacy_subcommands_match_top_level_arguments() { + for args in [ + vec!["export", "openai"], + vec!["export", "openai", "-o", "json", "--global"], + vec!["import", "-f", "profile.yaml"], + vec!["import", "--from", "profiles", "--global"], + vec!["update", "custom-api", "--file", "profile.yaml", "--global"], + vec!["lint", "--file", "profile.yaml", "--global"], + vec!["lint", "--from", "profiles"], + vec!["delete", "first", "second", "--global"], + ] { + let top = Cli::try_parse_from( + ["openshell", "--workspace", "team", "profile"] + .into_iter() + .chain(args.iter().copied()), + ) + .expect("top-level profile command"); + let legacy = Cli::try_parse_from( + ["openshell", "--workspace", "team", "provider", "profile"] + .into_iter() + .chain(args.iter().copied()), + ) + .expect("nested profile command"); + assert_eq!(top.workspace, legacy.workspace); + match (top.command, legacy.command) { + ( + Some(Commands::Profile { command: Some(top) }), + Some(Commands::Provider { + command: Some(ProviderCommands::Profile(legacy)), + }), + ) => assert_eq!(top, legacy), + other => panic!("unexpected command pair: {other:?}"), + } + } + for verb in ["import", "lint"] { + assert!(Cli::try_parse_from(["openshell", "provider", "profile", verb]).is_err()); + assert!( + Cli::try_parse_from([ + "openshell", + "provider", + "profile", + verb, + "-f", + "profile.yaml", + "--from", + "profiles" + ]) + .is_err() + ); + } } #[test] @@ -5837,7 +6097,6 @@ mod tests { fn sandbox_create_output_conflicts_with_side_effect_args() { for (label, extra_args) in [ ("--editor", &["--editor", "code"][..]), - ("trailing command", &["--", "claude"][..]), ("--no-keep", &["--no-keep"][..]), ("--forward", &["--forward", "8080"][..]), ] { @@ -5852,6 +6111,22 @@ mod tests { } } + #[test] + fn sandbox_create_detached_command_accepts_structured_output() { + Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--detach", + "--output", + "json", + "--", + "codex", + "app-server", + ]) + .expect("a detached command should support structured create output"); + } + #[test] fn sandbox_create_resource_flags_parse() { let cli = Cli::try_parse_from([ @@ -6239,6 +6514,50 @@ mod tests { ); } + #[test] + fn sandbox_create_accepts_service_exposure() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--expose", + "4500", + "--detach", + ]) + .expect("sandbox create --expose should parse"); + + match cli.command { + Some(Commands::Sandbox { + command: Some(SandboxCommands::Create { expose, detach, .. }), + }) => { + assert_eq!(expose, Some(4500)); + assert!(detach); + } + other => panic!("expected SandboxCommands::Create, got: {other:?}"), + } + } + + #[test] + fn sandbox_create_rejects_service_exposure_with_no_keep() { + let result = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--expose", + "4500", + "--no-keep", + ]); + + assert!(result.is_err()); + } + + #[test] + fn sandbox_create_rejects_zero_service_port() { + let result = Cli::try_parse_from(["openshell", "sandbox", "create", "--expose", "0"]); + + assert!(result.is_err()); + } + #[test] fn service_expose_accepts_positional_target_port_and_service() { let cli = Cli::try_parse_from([ diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 5ac8d4b1a..4a9919c1e 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -26,7 +26,8 @@ pub use crate::commands::provider::{ ProviderCreateCredentialSource, ProviderCreateOptions, ProviderRefreshConfigInput, ProviderUpdateOptions, ensure_required_providers, provider_create, provider_create_with_options, provider_delete, provider_get, provider_list, - provider_list_profiles, provider_profile_delete, provider_profile_export, + provider_list_profiles, provider_list_profiles_text, provider_profile_delete, + provider_profile_describe, provider_profile_describe_text, provider_profile_export, provider_profile_export_text, provider_profile_import, provider_profile_lint, provider_profile_update, provider_refresh_config, provider_refresh_delete, provider_refresh_status, provider_rotate, provider_update, sandbox_provider_attach, @@ -55,8 +56,8 @@ use openshell_core::proto::{ ListSandboxPoliciesRequest, ListSandboxTemplatesRequest, ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, RejectDraftChunkRequest, ResourceRequirements, RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy, - SandboxResources, SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplate, - SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, + SandboxResources, SandboxServiceExposure, SandboxServiceLevel, SandboxSpec, SandboxStartup, + SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceEndpointResponse, SettingScope, StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init, @@ -249,8 +250,8 @@ pub fn doctor_check() -> Result<()> { Err(miette::miette!("docker info failed: {}", stderr.trim())) } -fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>) -> bool { - keep || forward.is_some() +fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>, expose: Option) -> bool { + keep || forward.is_some() || expose.is_some() } fn has_main_process_result(sandbox: &Sandbox) -> bool { @@ -440,6 +441,7 @@ pub struct SandboxCreateConfig<'a> { pub providers: &'a [String], pub policy: Option<&'a str>, pub forward: Option, + pub expose: Option, pub command: &'a [String], pub tty_override: Option, pub auto_providers_override: Option, @@ -467,6 +469,7 @@ impl Default for SandboxCreateConfig<'_> { providers: &[], policy: None, forward: None, + expose: None, command: &[], tty_override: None, auto_providers_override: None, @@ -502,6 +505,7 @@ pub async fn sandbox_create( providers, policy, forward, + expose, command, tty_override, auto_providers_override, @@ -528,6 +532,9 @@ pub async fn sandbox_create( "structured output cannot be combined with an attached trailing command; use table output to stream the command or add --detach" )); } + if expose == Some(0) { + return Err(miette::miette!("--expose port must be in 1..=65535")); + } // Check port availability *before* creating the sandbox so we don't // leave an orphaned sandbox behind when the forward would fail. @@ -634,7 +641,7 @@ pub async fn sandbox_create( // (bash when present, otherwise /bin/sh on minimal images like Alpine). // Baking a shell here would force a shell the image may not ship. let main_command = command.to_vec(); - let persist = sandbox_should_persist(keep, forward.as_ref()); + let persist = sandbox_should_persist(keep, forward.as_ref(), expose); let create_detaches = detach || (persist && command.is_empty() @@ -672,6 +679,13 @@ pub async fn sandbox_create( )), await_main_process_attachment, workload_template: template.unwrap_or_default().to_string(), + service_exposures: expose + .map(|target_port| SandboxServiceExposure { + service: String::new(), + target_port: u32::from(target_port), + }) + .into_iter() + .collect(), }; let response = match client.create_sandbox(request).await { @@ -684,8 +698,13 @@ pub async fn sandbox_create( } Err(status) => return Err(miette::miette!(status.to_string())), }; + let response = response.into_inner(); + let service_urls = response + .service_urls + .into_iter() + .map(|(service, url)| (service, service_url_for_gateway(&url, &effective_server))) + .collect::>(); let sandbox = response - .into_inner() .sandbox .ok_or_else(|| miette::miette!("sandbox missing from response"))?; @@ -1095,8 +1114,27 @@ pub async fn sandbox_create( ); } + if let Some(target_port) = expose + && !structured_output + { + eprintln!( + " {} Exposed sandbox {sandbox_name} service on 127.0.0.1:{target_port}", + "\u{2713}".green().bold(), + ); + if let Some(url) = service_urls.get("").filter(|url| !url.is_empty()) { + eprintln!( + " Access at: {}", + service_url_for_gateway(url, &effective_server) + ); + } + } + if structured_output { - crate::output::print_output_single(output, &last_sandbox, sandbox_to_json)?; + let mut value = sandbox_to_json(&last_sandbox); + if let Some(object) = value.as_object_mut() { + object.insert("service_urls".to_string(), serde_json::json!(service_urls)); + } + crate::output::print_output_single(output, &value, Clone::clone)?; return Ok(0); } @@ -3627,21 +3665,8 @@ pub async fn service_expose( workspace: &str, tls: &TlsOptions, ) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .expose_service(ExposeServiceRequest { - request_id: String::new(), - sandbox: (sandbox).to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - workspace.to_string(), - )), - name: service.to_string(), - target_port: u32::from(target_port), - domain: true, - }) - .await - .map_err(service_expose_status_error)? - .into_inner(); + let response = + expose_service_endpoint(server, sandbox, service, target_port, workspace, tls).await?; if service.is_empty() { println!( @@ -3666,6 +3691,31 @@ pub async fn service_expose( Ok(()) } +async fn expose_service_endpoint( + server: &str, + sandbox: &str, + service: &str, + target_port: u16, + workspace: &str, + tls: &TlsOptions, +) -> Result { + let mut client = grpc_client(server, tls).await?; + client + .expose_service(ExposeServiceRequest { + request_id: String::new(), + sandbox: sandbox.to_string(), + name: service.to_string(), + target_port: u32::from(target_port), + domain: true, + workspace_scope: Some(openshell_core::proto::workspace_selector( + workspace.to_string(), + )), + }) + .await + .map_err(service_expose_status_error) + .map(tonic::Response::into_inner) +} + fn service_expose_status_error(status: Status) -> miette::Report { service_status_error("expose service", "sandbox:write", status) } @@ -6745,18 +6795,23 @@ mod tests { #[test] fn sandbox_should_persist_defaults_to_persistent() { - assert!(sandbox_should_persist(true, None)); + assert!(sandbox_should_persist(true, None, None)); } #[test] fn sandbox_should_not_persist_when_no_keep_is_set() { - assert!(!sandbox_should_persist(false, None)); + assert!(!sandbox_should_persist(false, None, None)); } #[test] fn sandbox_should_persist_when_forward_is_requested() { let spec = openshell_core::forward::ForwardSpec::new(8080); - assert!(sandbox_should_persist(false, Some(&spec))); + assert!(sandbox_should_persist(false, Some(&spec), None)); + } + + #[test] + fn sandbox_should_persist_when_service_exposure_is_requested() { + assert!(sandbox_should_persist(false, None, Some(8080))); } #[test] diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index e6db26321..947c84ced 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -5,7 +5,7 @@ use crate::color::Colorize; use crate::tls::{TlsOptions, grpc_client}; -use miette::{IntoDiagnostic, Result, WrapErr}; +use miette::{IntoDiagnostic, Report, Result, WrapErr}; #[cfg(unix)] use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; use openshell_core::driver_mounts; @@ -44,6 +44,8 @@ const FORWARD_LISTENER_CONNECT_TIMEOUT: Duration = Duration::from_millis(200); /// command has already reported its terminal result. const TERMINAL_RELAY_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(5); const TERMINAL_RELAY_REGISTRATION_INTERVAL: Duration = Duration::from_millis(50); +const SYNC_RETRY_ATTEMPTS: usize = 4; +const SYNC_RETRY_DELAY: Duration = Duration::from_secs(2); #[derive(Clone, Copy, Debug)] pub enum Editor { @@ -659,6 +661,7 @@ pub async fn sandbox_exec( } /// What to pack into the tar archive streamed to the sandbox. +#[derive(Clone)] enum UploadSource { /// A single local file or directory. `tar_name` controls the entry name /// inside the archive (e.g. the target basename for file-to-file uploads). @@ -1036,18 +1039,15 @@ pub async fn sandbox_sync_up_files( if files.is_empty() { return Ok(()); } - ssh_tar_upload( - server, - name, - dest, - UploadSource::FileList { - base_dir: base_dir.to_path_buf(), - files: files.to_vec(), - archive_prefix: file_list_archive_prefix(local_path), - }, - tls, - workspace, - ) + let source = UploadSource::FileList { + base_dir: base_dir.to_path_buf(), + files: files.to_vec(), + archive_prefix: file_list_archive_prefix(local_path), + }; + retry_sandbox_sync("upload", || { + let source = source.clone(); + async move { ssh_tar_upload(server, name, dest, source, tls, workspace).await } + }) .await } @@ -1082,17 +1082,16 @@ pub async fn sandbox_sync_up( { let (parent, target_name) = split_sandbox_path(path); if parent != "/" { - return ssh_tar_upload( - server, - name, - Some(parent), - UploadSource::SinglePath { - local_path: local_path.to_path_buf(), - tar_name: target_name.into(), - }, - tls, - workspace, - ) + let source = UploadSource::SinglePath { + local_path: local_path.to_path_buf(), + tar_name: target_name.into(), + }; + return retry_sandbox_sync("upload", || { + let source = source.clone(); + async move { + ssh_tar_upload(server, name, Some(parent), source, tls, workspace).await + } + }) .await; } } @@ -1110,17 +1109,14 @@ pub async fn sandbox_sync_up( directory_upload_prefix(local_path) }; - ssh_tar_upload( - server, - name, - sandbox_path, - UploadSource::SinglePath { - local_path: local_path.to_path_buf(), - tar_name, - }, - tls, - workspace, - ) + let source = UploadSource::SinglePath { + local_path: local_path.to_path_buf(), + tar_name, + }; + retry_sandbox_sync("upload", || { + let source = source.clone(); + async move { ssh_tar_upload(server, name, sandbox_path, source, tls, workspace).await } + }) .await } @@ -1259,6 +1255,20 @@ pub async fn sandbox_sync_down( dest: &str, tls: &TlsOptions, workspace: &str, +) -> Result<()> { + retry_sandbox_sync("download", || async { + sandbox_sync_down_once(server, name, sandbox_path, dest, tls, workspace).await + }) + .await +} + +async fn sandbox_sync_down_once( + server: &str, + name: &str, + sandbox_path: &str, + dest: &str, + tls: &TlsOptions, + workspace: &str, ) -> Result<()> { let session = ssh_session_config(server, name, tls, workspace, None).await?; let sandbox_path = resolve_sandbox_source_path(&session, sandbox_path).await?; @@ -1272,6 +1282,54 @@ pub async fn sandbox_sync_down( } } +async fn retry_sandbox_sync(operation: &str, mut run: F) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut attempt = 1; + loop { + match run().await { + Ok(()) => return Ok(()), + Err(error) if attempt < SYNC_RETRY_ATTEMPTS && sync_error_is_retryable(&error) => { + tracing::warn!( + operation, + attempt, + max_attempts = SYNC_RETRY_ATTEMPTS, + error = %error, + "sandbox sync operation failed; retrying" + ); + tokio::time::sleep(SYNC_RETRY_DELAY).await; + attempt += 1; + } + Err(error) => return Err(error), + } + } +} + +fn sync_error_is_retryable(error: &Report) -> bool { + let message = format!("{error:?}").to_ascii_lowercase(); + [ + "broken pipe", + "connection", + "early eof", + "http2", + "h2 protocol", + "reset before headers", + "service is currently unavailable", + "transport error", + "unexpected eof", + "unavailable", + "upstream connect error", + "ssh probe exited with status exit status: 255", + "ssh tar create exited", + "ssh tar extract exited", + "failed to extract tar archive from sandbox", + ] + .iter() + .any(|needle| message.contains(needle)) +} + /// Stream a tar archive from the sandbox and extract it into a fresh /// destination directory. The source is always wrapped on the sandbox side so /// the host can pick a basename when needed. @@ -1788,6 +1846,28 @@ mod tests { assert_eq!(output.matches("Host openshell-demo").count(), 1); } + #[test] + fn sync_error_retry_filter_accepts_transport_failures() { + let error = miette::miette!("transport error: connection reset by peer"); + assert!(sync_error_is_retryable(&error)); + } + + #[test] + fn sync_error_retry_filter_accepts_transient_ssh_probe_failures() { + let error = Err::<(), _>(miette::miette!( + "ssh probe exited with status exit status: 255" + )) + .wrap_err("failed to resolve sandbox source path '/sandbox/ha-sync/ha-sync-upload'") + .unwrap_err(); + assert!(sync_error_is_retryable(&error)); + } + + #[test] + fn sync_error_retry_filter_rejects_validation_failures() { + let error = miette::miette!("sandbox source path '/etc/passwd' resolves outside /sandbox"); + assert!(!sync_error_is_retryable(&error)); + } + #[test] #[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024. fn install_ssh_config_adds_include_once_and_updates_managed_file() { diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 9541d38bf..e7221e007 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -79,6 +79,27 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -708,6 +729,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type PeerRelayStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn peer_relay( + &self, + _request: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + type ForwardTcpStream = tokio_stream::wrappers::ReceiverStream< Result, >; diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 59ba2d287..ca969dde1 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -32,6 +32,27 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -560,6 +581,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type PeerRelayStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn peer_relay( + &self, + _request: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + type ForwardTcpStream = tokio_stream::wrappers::ReceiverStream< Result, >; diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a26334c0..ae1e7363f 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -29,13 +29,13 @@ use openshell_core::proto::{ ProviderReadinessReason, ProviderReadinessState, ProviderReadinessStatus, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, - SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, + SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, WorkspaceSelector, }; use openshell_core::rpc_error::{ERROR_DOMAIN, ErrorDetails, StatusExt}; use openshell_core::{ObjectId, ObjectName}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::Duration; use tempfile::TempDir; use tokio::net::TcpListener; @@ -46,6 +46,8 @@ use tonic::{Code, Response, Status}; type ReadinessScript = HashMap>; type ReceiptCorruption = fn(&mut ProviderMutationReceipt); +type ProfileListRequestLog = (Option, i32, String); +type ProfileGetRequestLog = (Option, String); #[derive(Clone)] enum ReadinessReply { @@ -62,7 +64,7 @@ const SYNTHETIC_PROFILE_BACKEND_ERROR: &str = "TESTLEAK"; const SYNTHETIC_MUTATION_ERROR_METADATA: &str = "fixture-mutation-error-metadata"; const STORAGE_UNCERTAIN_REASON: &str = "CONFIG_OPERATION_STORAGE_UNCERTAIN"; -fn selected_workspace(scope: &Option) -> Option<&str> { +fn selected_workspace(scope: &Option) -> Option<&str> { scope .as_ref() .and_then(|scope| match scope.selection.as_ref() { @@ -87,6 +89,12 @@ struct ProviderState { fail_sandbox_reads: Arc, profile_read_errors: Arc>>, profile_read_requests: Arc>>, + // Preserve selector presence so platform requests cannot pass with an + // explicitly empty workspace instead of omitting the selector. + profile_list_requests: Arc>>, + profile_page_size_cap: Arc, + profile_get_requests: Arc>>, + omit_profile_response: Arc, delete_provider_requests: Arc>>, delete_provider_profile_requests: Arc>>, fail_configure_refresh_message: Arc>>, @@ -221,6 +229,27 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -322,6 +351,7 @@ impl OpenShell for TestOpenShell { status: None, ..Sandbox::default() }), + service_urls: HashMap::new(), })) } @@ -750,17 +780,66 @@ impl OpenShell for TestOpenShell { async fn list_provider_profiles( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { + let request = request.into_inner(); + self.state.profile_list_requests.lock().await.push(( + request.workspace_scope.clone(), + request.page_size, + request.page_token.clone(), + )); let mut profiles = helpers::example_profiles() .iter() .map(openshell_providers::ProviderTypeProfile::to_proto) .collect::>(); profiles.extend(self.state.profiles.lock().await.values().cloned()); + profiles.extend( + self.state + .scoped_profiles + .lock() + .await + .iter() + .filter(|((workspace, _), _)| { + workspace == selected_workspace(&request.workspace_scope).unwrap_or_default() + }) + .map(|(_, profile)| profile.clone()), + ); + // The fixture owns token interpretation. The CLI must forward opaque + // tokens unchanged and follow them even when the server caps page size. + profiles.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.scope.cmp(&right.scope)) + }); + let offset = if request.page_token.is_empty() { + 0 + } else { + request + .page_token + .strip_prefix("profile-page:") + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| Status::invalid_argument("unknown profile page token"))? + }; + let mut page_size = usize::try_from(request.page_size) + .map_err(|_| Status::invalid_argument("negative profile page size"))?; + if page_size == 0 { + page_size = 100; + } + let cap = self.state.profile_page_size_cap.load(Ordering::SeqCst); + if cap > 0 { + page_size = page_size.min(cap); + } + let next_offset = offset.saturating_add(page_size); + let next_page_token = if next_offset < profiles.len() { + format!("profile-page:{next_offset:04}") + } else { + String::new() + }; + let profiles = profiles.into_iter().skip(offset).take(page_size).collect(); Ok(Response::new( openshell_core::proto::ListProviderProfilesResponse { profiles, - next_page_token: String::new(), + next_page_token, }, )) } @@ -770,6 +849,16 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + self.state + .profile_get_requests + .lock() + .await + .push((request.workspace_scope.clone(), request.id.clone())); + if self.state.omit_profile_response.load(Ordering::SeqCst) { + return Ok(Response::new( + openshell_core::proto::ProviderProfileResponse { profile: None }, + )); + } let id = request.id; self.state .profile_read_requests @@ -1435,6 +1524,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type PeerRelayStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn peer_relay( + &self, + _request: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + type ForwardTcpStream = tokio_stream::wrappers::ReceiverStream< Result, >; @@ -3347,12 +3447,252 @@ async fn provider_cli_run_functions_support_full_crud_flow() { } #[tokio::test] -async fn provider_list_profiles_cli_uses_profile_browsing_rpc() { +async fn profile_list_reads_all_pages_in_the_requested_workspace() { let ts = run_server().await; + ts.state.deny_provider_reads.store(true, Ordering::SeqCst); + let example_count = helpers::example_profiles().len(); - run::provider_list_profiles(&ts.endpoint, "table", "default", &ts.tls) + // A full terminal page stops immediately. A short nonterminal page must + // continue, including across more than two server-issued tokens. + for (count, page_size) in [ + (100_usize.saturating_sub(example_count), 100), + (105, 100), + (105, 37), + ] { + ts.state + .profile_page_size_cap + .store(page_size, Ordering::SeqCst); + ts.state.profiles.lock().await.clear(); + ts.state.profile_list_requests.lock().await.clear(); + for i in 0..count { + install_test_profile(&ts, &format!("custom-{i:03}"), "CUSTOM_TOKEN").await; + } + let rendered = run::provider_list_profiles_text(&ts.endpoint, "json", "team", &ts.tls) + .await + .expect("profile catalog"); + let profiles: Vec = + serde_json::from_str(&rendered).expect("profile JSON"); + assert_eq!(profiles.len(), example_count + count); + for i in 0..count { + assert!( + profiles + .iter() + .any(|profile| profile["id"] == format!("custom-{i:03}")) + ); + } + let requests = ts.state.profile_list_requests.lock().await; + assert_eq!(requests.len(), (example_count + count).div_ceil(page_size)); + for (page, (workspace, requested_size, token)) in requests.iter().enumerate() { + assert_eq!(selected_workspace(workspace), Some("team")); + assert_eq!(*requested_size, 100); + let expected_token = if page == 0 { + String::new() + } else { + format!("profile-page:{:04}", page * page_size) + }; + assert_eq!(token, &expected_token); + } + } +} + +#[tokio::test] +async fn profile_describe_preserves_scope_and_structured_definition() { + let ts = run_server().await; + ts.state.deny_provider_reads.store(true, Ordering::SeqCst); + let mut scoped = helpers::example_profiles() + .iter() + .find(|profile| profile.id == "openai") + .expect("openai profile") + .to_proto(); + scoped.display_name = "Team-only OpenAI".to_string(); + scoped.scope = "workspace".to_string(); + scoped.source = "custom".to_string(); + ts.state + .scoped_profiles + .lock() .await - .expect("provider list-profiles"); + .insert(("team".to_string(), "openai".to_string()), scoped); + + let human = + run::provider_profile_describe_text(&ts.endpoint, "openai", "table", "team", &ts.tls) + .await + .expect("scoped description"); + assert!(human.contains("Team-only OpenAI")); + assert!(human.contains("OPENAI_API_KEY")); + assert!(!human.contains('\u{1b}')); + let global = run::provider_profile_describe_text(&ts.endpoint, "openai", "table", "", &ts.tls) + .await + .expect("platform description"); + assert!(!global.contains("Team-only OpenAI")); + for format in ["json", "yaml"] { + let description = + run::provider_profile_describe_text(&ts.endpoint, "openai", format, "team", &ts.tls) + .await + .expect("structured description"); + let exported = + run::provider_profile_export_text(&ts.endpoint, "openai", format, "team", &ts.tls) + .await + .expect("profile export"); + assert_eq!(description, exported); + } + let missing = + run::provider_profile_describe_text(&ts.endpoint, "missing", "table", "team", &ts.tls) + .await + .expect_err("unknown profile must fail"); + assert!(missing.to_string().contains("not found")); + ts.state.omit_profile_response.store(true, Ordering::SeqCst); + let absent = + run::provider_profile_describe_text(&ts.endpoint, "openai", "json", "team", &ts.tls) + .await + .expect_err("empty successful response must fail"); + assert!(absent.to_string().contains("openai")); +} + +#[tokio::test] +async fn profile_commands_dispatch_scope_and_output_to_gateway() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("test address")); + let state = ProviderState::default(); + state.deny_provider_reads.store(true, Ordering::SeqCst); + let service = TestOpenShell { + state: state.clone(), + }; + let server = tokio::spawn(async move { + Server::builder() + .add_service(OpenShellServer::new(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .expect("test gateway"); + }); + let config = tempfile::tempdir().expect("isolated CLI config"); + // The subprocess uses an explicit local endpoint and isolated config so + // profile dispatch cannot accidentally consult the developer's gateway. + for (verb, global, format) in [ + ("list", false, "json"), + ("list", true, "yaml"), + ("describe", false, "json"), + ("describe", true, "table"), + ("export", false, "yaml"), + ("export", true, "json"), + ] { + let mut outputs = Vec::new(); + for legacy in [false, true] { + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); + command + .env("XDG_CONFIG_HOME", config.path()) + .env("OPENSHELL_TELEMETRY_ENABLED", "false") + .env_remove("OPENSHELL_GATEWAY") + .env_remove("COMPLETE") + .args([ + "--gateway-endpoint", + &endpoint, + "--workspace", + "team", + "--color", + "never", + ]); + if legacy && verb == "list" { + command.args(["provider", "list-profiles"]); + } else { + if legacy { + command.arg("provider"); + } + command.args(["profile", verb]); + if verb == "list" { + command.args(["--type", "provider"]); + } else { + command.arg("openai"); + } + } + if global { + command.arg("--global"); + } + let output = command + .args(["-o", format]) + .output() + .await + .expect("profile subprocess"); + assert!( + output.status.success(), + "profile {verb} (legacy={legacy}): {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + match (verb, format) { + ("list", "json") => assert!( + serde_json::from_str::>(&stdout) + .expect("JSON list") + .iter() + .any(|p| p["id"] == "openai") + ), + ("list", "yaml") => assert!( + serde_yml::from_str::>(&stdout) + .expect("YAML list") + .iter() + .any(|p| p["id"] == "openai") + ), + (_, "json") => assert_eq!( + serde_json::from_str::(&stdout).expect("JSON profile")["id"], + "openai" + ), + (_, "yaml") => assert_eq!( + serde_yml::from_str::(&stdout).expect("YAML profile")["id"], + "openai" + ), + _ => assert!(stdout.contains("openai (provider)")), + } + outputs.push(stdout); + } + // Both entry points must produce identical output, including structure + // and human-readable formatting, rather than only parsing successfully. + assert_eq!(outputs[0], outputs[1], "profile {verb} output differs"); + } + assert_eq!( + *state.profile_list_requests.lock().await, + [ + ( + Some(openshell_core::proto::workspace_selector("team")), + 100, + String::new() + ), + ( + Some(openshell_core::proto::workspace_selector("team")), + 100, + String::new() + ), + (None, 100, String::new()), + (None, 100, String::new()), + ] + ); + assert_eq!( + *state.profile_get_requests.lock().await, + [ + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + (None, "openai".to_string()), + (None, "openai".to_string()), + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + (None, "openai".to_string()), + (None, "openai".to_string()), + ] + ); + // The listener belongs to this fixture and must not outlive the test. + server.abort(); } #[tokio::test] @@ -3967,7 +4307,7 @@ binaries: [/usr/bin/custom] .expect("profile export"); run::provider_list_profiles(&ts.endpoint, "json", "default", &ts.tls) .await - .expect("provider list-profiles json"); + .expect("profile list json"); run::provider_create( &ts.endpoint, "custom-provider", diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 901471390..f1408493b 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -64,6 +64,7 @@ struct SandboxState { fail_list_provider_profiles: Arc, deleted_names: Arc>>>, create_requests: Arc>>, + expose_service_requests: Arc>>, fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, vm_error_with_observed_exit: Arc, @@ -91,6 +92,27 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn begin_rootfs_tar_staging( &self, _request: tonic::Request, @@ -142,6 +164,16 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { let request = request.into_inner(); let name = request.name.clone(); + let service_urls = request + .service_exposures + .iter() + .map(|exposure| { + ( + exposure.service.clone(), + "https://default--sandbox.openshell.localhost:17670/".to_string(), + ) + }) + .collect(); self.state.create_requests.lock().await.push(request); let sandbox_name = if name.is_empty() { "test-sandbox".to_string() @@ -165,6 +197,7 @@ impl OpenShell for TestOpenShell { sandbox.set_phase(SandboxPhase::Provisioning as i32); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), + service_urls, })) } @@ -204,6 +237,7 @@ impl OpenShell for TestOpenShell { sandbox.set_phase(SandboxPhase::Ready as i32); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -429,10 +463,18 @@ impl OpenShell for TestOpenShell { async fn expose_service( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { + self.state + .expose_service_requests + .lock() + .await + .push(request.into_inner()); Ok(Response::new( - openshell_core::proto::ServiceEndpointResponse::default(), + openshell_core::proto::ServiceEndpointResponse { + url: "https://default--sandbox.openshell.localhost:17670/".to_string(), + ..Default::default() + }, )) } @@ -440,7 +482,12 @@ impl OpenShell for TestOpenShell { &self, _: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("unused")) + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse { + url: "https://default--sandbox.openshell.localhost:17670/".to_string(), + ..Default::default() + }, + )) } async fn list_services( @@ -993,6 +1040,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type PeerRelayStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn peer_relay( + &self, + _request: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + type ForwardTcpStream = tokio_stream::wrappers::ReceiverStream< Result, >; @@ -1437,6 +1495,18 @@ async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } +async fn expose_service_requests( + server: &TestServer, +) -> Vec { + server + .openshell + .state + .expose_service_requests + .lock() + .await + .clone() +} + async fn template_create_requests(server: &TestServer) -> Vec { server .openshell @@ -2686,6 +2756,39 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { .status(); } +#[tokio::test] +async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("sandbox"), + keep: false, + expose: Some(4500), + detach: true, + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create with service exposure should succeed"); + + assert!(deleted_names(&server).await.is_empty()); + let create_requests = create_requests(&server).await; + assert_eq!(create_requests.len(), 1); + assert_eq!(create_requests[0].service_exposures.len(), 1); + assert_eq!(create_requests[0].service_exposures[0].service, ""); + assert_eq!(create_requests[0].service_exposures[0].target_port, 4500); + assert!(expose_service_requests(&server).await.is_empty()); +} + #[tokio::test] async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails() { let server = run_server().await; @@ -3149,15 +3252,30 @@ async fn sandbox_create_continues_with_unexpired_cached_token_when_refresh_fails async fn sandbox_create_json_stdout_is_parseable() { let server = run_server().await; - let result = run_cli_sandbox_create(&server, "json-clean", &["--output=json"]).await; + let result = run_cli_sandbox_create( + &server, + "json-clean", + &["--output=json", "--expose=4500", "--detach"], + ) + .await; assert!( result.status.success(), "sandbox create failed:\n{}", String::from_utf8_lossy(&result.stderr) ); let stdout = String::from_utf8(result.stdout).expect("stdout should be UTF-8"); - serde_json::from_str::(&stdout) + let value = serde_json::from_str::(&stdout) .unwrap_or_else(|err| panic!("stdout should contain only JSON: {err}\n{stdout}")); + let gateway_port = url::Url::parse(&server.endpoint) + .expect("test gateway endpoint should be a URL") + .port() + .expect("test gateway endpoint should include a port"); + assert_eq!( + value["service_urls"], + serde_json::json!({ + "": format!("https://default--sandbox.openshell.localhost:{gateway_port}/") + }) + ); } #[tokio::test] diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index bd295a936..7a5780d7f 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -47,6 +47,27 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -143,6 +164,7 @@ impl OpenShell for TestOpenShell { }), ..Default::default() }), + service_urls: std::collections::HashMap::new(), })) } @@ -650,6 +672,17 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type PeerRelayStream = tokio_stream::wrappers::ReceiverStream< + Result, + >; + + async fn peer_relay( + &self, + _request: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + type ForwardTcpStream = tokio_stream::wrappers::ReceiverStream< Result, >; diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs new file mode 100644 index 000000000..9b5a8f2a9 --- /dev/null +++ b/crates/openshell-core/src/extension_protocol.rs @@ -0,0 +1,490 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared version and capability negotiation for `OpenShell` extensions. + +use std::collections::BTreeSet; + +use thiserror::Error; + +use crate::proto::extension::v1::{PeerMetadata, ProtocolVersion}; + +pub const PROTOCOL_MAJOR: u32 = 1; +pub const PROTOCOL_MINOR: u32 = 0; + +const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; +const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; +const MAX_CAPABILITY_BYTES: usize = 128; +const MAX_CAPABILITIES: usize = 128; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ExtensionFamily { + Compute, + Credentials, + GatewayInterceptor, + SupervisorMiddleware, +} + +impl ExtensionFamily { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Compute => "compute", + Self::Credentials => "credentials", + Self::GatewayInterceptor => "gateway-interceptor", + Self::SupervisorMiddleware => "supervisor-middleware", + } + } + + #[must_use] + pub fn contract_capability(self) -> String { + format!("openshell.{}.contract", self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiatedExtension { + pub family: ExtensionFamily, + pub configured_name: String, + pub implementation_name: String, + pub implementation_version: String, + pub protocol_major: u32, + pub protocol_minor: u32, + pub supported_capabilities: Vec, + pub required_capabilities: Vec, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NegotiationError { + #[error( + "{family} extension '{name}' did not provide protocol metadata; upgrade the extension to a version that supports OpenShell extension negotiation" + )] + MissingMetadata { family: &'static str, name: String }, + #[error( + "gateway did not provide protocol metadata to {family} extension '{name}'; upgrade the gateway and extension together" + )] + MissingGatewayMetadata { family: &'static str, name: String }, + #[error("{family} extension '{name}' did not provide a protocol version")] + MissingProtocolVersion { family: &'static str, name: String }, + #[error( + "{family} extension '{name}' uses unsupported protocol {remote_major}.{remote_minor}; gateway supports {local_major}.{local_minor}" + )] + IncompatibleProtocol { + family: &'static str, + name: String, + remote_major: u32, + remote_minor: u32, + local_major: u32, + local_minor: u32, + }, + #[error("{family} extension '{name}' has invalid {field}: {reason}")] + InvalidMetadata { + family: &'static str, + name: String, + field: &'static str, + reason: String, + }, + #[error("{family} extension '{name}' is missing required capabilities: {capabilities}")] + MissingExtensionCapabilities { + family: &'static str, + name: String, + capabilities: String, + }, + #[error( + "gateway is missing capabilities required by {family} extension '{name}': {capabilities}" + )] + MissingGatewayCapabilities { + family: &'static str, + name: String, + capabilities: String, + }, +} + +#[must_use] +pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata { + let contract = family.contract_capability(); + PeerMetadata { + protocol_version: Some(ProtocolVersion { + major: PROTOCOL_MAJOR, + minor: PROTOCOL_MINOR, + }), + implementation_name: "openshell/gateway".to_string(), + implementation_version: crate::VERSION.to_string(), + supported_capabilities: vec![contract.clone()], + required_capabilities: vec![contract], + } +} + +#[must_use] +pub fn extension_metadata( + family: ExtensionFamily, + implementation_name: impl Into, + implementation_version: impl Into, + additional_capabilities: impl IntoIterator, +) -> PeerMetadata { + let contract = family.contract_capability(); + let mut supported_capabilities = vec![contract.clone()]; + supported_capabilities.extend(additional_capabilities); + PeerMetadata { + protocol_version: Some(ProtocolVersion { + major: PROTOCOL_MAJOR, + minor: PROTOCOL_MINOR, + }), + implementation_name: implementation_name.into(), + implementation_version: implementation_version.into(), + supported_capabilities, + required_capabilities: vec![contract], + } +} + +pub fn negotiate( + family: ExtensionFamily, + configured_name: impl Into, + gateway: &PeerMetadata, + extension: Option, +) -> Result { + let configured_name = configured_name.into(); + let family_name = family.as_str(); + let extension = extension.ok_or_else(|| NegotiationError::MissingMetadata { + family: family_name, + name: configured_name.clone(), + })?; + let version = extension.protocol_version.as_ref().ok_or_else(|| { + NegotiationError::MissingProtocolVersion { + family: family_name, + name: configured_name.clone(), + } + })?; + let gateway_version = gateway.protocol_version.as_ref().ok_or_else(|| { + NegotiationError::MissingProtocolVersion { + family: family_name, + name: "gateway".to_string(), + } + })?; + if version.major != gateway_version.major { + return Err(NegotiationError::IncompatibleProtocol { + family: family_name, + name: configured_name, + remote_major: version.major, + remote_minor: version.minor, + local_major: gateway_version.major, + local_minor: gateway_version.minor, + }); + } + + validate_text( + family_name, + &configured_name, + "implementation_name", + &extension.implementation_name, + MAX_IMPLEMENTATION_NAME_BYTES, + )?; + validate_text( + family_name, + &configured_name, + "implementation_version", + &extension.implementation_version, + MAX_IMPLEMENTATION_VERSION_BYTES, + )?; + let extension_supported = normalize_capabilities( + family_name, + &configured_name, + "supported_capabilities", + &extension.supported_capabilities, + )?; + let extension_required = normalize_capabilities( + family_name, + &configured_name, + "required_capabilities", + &extension.required_capabilities, + )?; + let gateway_supported = normalize_capabilities( + family_name, + "gateway", + "supported_capabilities", + &gateway.supported_capabilities, + )?; + let gateway_required = normalize_capabilities( + family_name, + "gateway", + "required_capabilities", + &gateway.required_capabilities, + )?; + + let missing_extension = gateway_required + .difference(&extension_supported) + .cloned() + .collect::>(); + if !missing_extension.is_empty() { + return Err(NegotiationError::MissingExtensionCapabilities { + family: family_name, + name: configured_name, + capabilities: missing_extension.join(", "), + }); + } + let missing_gateway = extension_required + .difference(&gateway_supported) + .cloned() + .collect::>(); + if !missing_gateway.is_empty() { + return Err(NegotiationError::MissingGatewayCapabilities { + family: family_name, + name: configured_name, + capabilities: missing_gateway.join(", "), + }); + } + + Ok(NegotiatedExtension { + family, + configured_name, + implementation_name: extension.implementation_name, + implementation_version: extension.implementation_version, + protocol_major: version.major, + protocol_minor: version.minor, + supported_capabilities: extension_supported.into_iter().collect(), + required_capabilities: extension_required.into_iter().collect(), + }) +} + +pub fn validate_gateway_metadata( + family: ExtensionFamily, + extension_name: impl Into, + extension: Option<&PeerMetadata>, + gateway: Option, +) -> Result<(), NegotiationError> { + let extension_name = extension_name.into(); + let gateway = gateway.ok_or_else(|| NegotiationError::MissingGatewayMetadata { + family: family.as_str(), + name: extension_name.clone(), + })?; + negotiate(family, extension_name, &gateway, extension.cloned()).map(drop) +} + +fn validate_text( + family: &'static str, + name: &str, + field: &'static str, + value: &str, + max_bytes: usize, +) -> Result<(), NegotiationError> { + let reason = if value.trim().is_empty() { + Some("must not be empty".to_string()) + } else if value.len() > max_bytes { + Some(format!("must be at most {max_bytes} bytes")) + } else if value.chars().any(char::is_control) { + Some("must not contain control characters".to_string()) + } else { + None + }; + if let Some(reason) = reason { + return Err(NegotiationError::InvalidMetadata { + family, + name: name.to_string(), + field, + reason, + }); + } + Ok(()) +} + +fn normalize_capabilities( + family: &'static str, + name: &str, + field: &'static str, + capabilities: &[String], +) -> Result, NegotiationError> { + if capabilities.len() > MAX_CAPABILITIES { + return Err(NegotiationError::InvalidMetadata { + family, + name: name.to_string(), + field, + reason: format!("must contain at most {MAX_CAPABILITIES} entries"), + }); + } + let mut normalized = BTreeSet::new(); + for capability in capabilities { + if !valid_capability(capability) { + return Err(NegotiationError::InvalidMetadata { + family, + name: name.to_string(), + field, + reason: format!("'{capability}' must be a lowercase namespaced identifier"), + }); + } + if !normalized.insert(capability.clone()) { + return Err(NegotiationError::InvalidMetadata { + family, + name: name.to_string(), + field, + reason: format!("contains duplicate '{capability}'"), + }); + } + } + Ok(normalized) +} + +fn valid_capability(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CAPABILITY_BYTES + && value.starts_with("openshell.") + && value.split('.').count() >= 3 + && value.split('.').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compatible() -> (PeerMetadata, PeerMetadata) { + let gateway = gateway_metadata(ExtensionFamily::Compute); + let extension = extension_metadata( + ExtensionFamily::Compute, + "example/compute", + "2.3.4", + ["openshell.compute.optional-future".to_string()], + ); + (gateway, extension) + } + + #[test] + fn compatible_metadata_negotiates_and_sorts_unknown_optional_capabilities() { + let (gateway, mut extension) = compatible(); + extension.supported_capabilities.reverse(); + let result = negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension), + ) + .unwrap(); + assert_eq!(result.protocol_major, 1); + assert_eq!( + result.supported_capabilities, + vec![ + "openshell.compute.contract", + "openshell.compute.optional-future" + ] + ); + } + + #[test] + fn same_major_minor_skew_is_compatible() { + let (gateway, mut extension) = compatible(); + extension.protocol_version.as_mut().unwrap().minor = 99; + assert!( + negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension) + ) + .is_ok() + ); + } + + #[test] + fn missing_metadata_and_major_skew_are_actionable() { + let (gateway, mut extension) = compatible(); + assert!(matches!( + negotiate(ExtensionFamily::Compute, "example", &gateway, None), + Err(NegotiationError::MissingMetadata { .. }) + )); + extension.protocol_version.as_mut().unwrap().major = 2; + assert!(matches!( + negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension) + ), + Err(NegotiationError::IncompatibleProtocol { .. }) + )); + } + + #[test] + fn both_peers_require_capabilities_from_the_other() { + let (mut gateway, mut extension) = compatible(); + gateway + .required_capabilities + .push("openshell.compute.gateway-required".to_string()); + assert!(matches!( + negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension.clone()) + ), + Err(NegotiationError::MissingExtensionCapabilities { .. }) + )); + + gateway.required_capabilities.pop(); + extension + .required_capabilities + .push("openshell.compute.extension-required".to_string()); + assert!(matches!( + negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension) + ), + Err(NegotiationError::MissingGatewayCapabilities { .. }) + )); + } + + #[test] + fn malformed_and_duplicate_capabilities_are_rejected() { + let (gateway, mut extension) = compatible(); + extension + .supported_capabilities + .push("NOT-NAMESPACED".to_string()); + assert!(matches!( + negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension) + ), + Err(NegotiationError::InvalidMetadata { .. }) + )); + + let (gateway, mut extension) = compatible(); + extension + .supported_capabilities + .push("openshell.compute.contract".to_string()); + assert!(matches!( + negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension) + ), + Err(NegotiationError::InvalidMetadata { .. }) + )); + } + + #[test] + fn extension_side_rejects_missing_and_incompatible_gateway_metadata() { + let (mut gateway, extension) = compatible(); + assert!(matches!( + validate_gateway_metadata(ExtensionFamily::Compute, "example", Some(&extension), None,), + Err(NegotiationError::MissingGatewayMetadata { .. }) + )); + + gateway.protocol_version.as_mut().unwrap().major = 2; + assert!(matches!( + validate_gateway_metadata( + ExtensionFamily::Compute, + "example", + Some(&extension), + Some(gateway), + ), + Err(NegotiationError::IncompatibleProtocol { .. }) + )); + } +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index a0a317d41..b310aba8a 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod dynamic_string_allowlist; pub mod endpoint_path; pub mod endpoint_status; pub mod error; +pub mod extension_protocol; #[cfg(unix)] pub mod external_driver_socket; pub mod forward; diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index a4023bfb1..ff8ceff56 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -12,9 +12,9 @@ use tonic::{Request, Response, Status}; use crate::proto::{ HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent, - HttpResponseEventResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase, - ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, - WebSocketSessionEventResult, + HttpResponseEventResult, MiddlewareDescribeRequest, MiddlewareManifest, RequestContext, + SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, + WebSocketSessionEvent, WebSocketSessionEventResult, }; /// Transport-neutral result stream for one HTTP response middleware stage. @@ -37,7 +37,10 @@ pub type WebSocketResponseStream = Pin< /// whether invocations are direct calls or serialized gRPC requests. #[tonic::async_trait] pub trait SupervisorMiddlewareEndpoint: Send + Sync { - async fn describe(&self, request: Request<()>) -> Result, Status>; + async fn describe( + &self, + request: Request, + ) -> Result, Status>; async fn validate_config( &self, @@ -193,6 +196,12 @@ impl<'a> HttpRequestView<'a> { /// request_timeout: None, /// }], /// expected_audience: String::new(), +/// extension: Some(openshell_core::extension_protocol::extension_metadata( +/// openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, +/// "example/audit", +/// "1", +/// [], +/// )), /// } /// } /// diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index e05222e67..c6076620a 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -40,6 +40,10 @@ pub mod compute { pub use super::generated::openshell::compute::v1; } +pub mod extension { + pub use super::generated::openshell::extension::v1; +} + #[allow( clippy::all, clippy::pedantic, diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 5e50a37ad..d921b8c34 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -11,12 +11,52 @@ use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::PathBuf; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct DockerOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl DockerOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() { + return Err(BackendError::Descriptor( + "Docker outer fence evidence is incomplete".to_string(), + )); + } + let mut established = Vec::new(); + if self.network_mode == "none" { + // With no container network namespace attachment, workload egress + // remains denied both after revocation and if the supervisor exits. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.unexpected_networks.is_empty() { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) + })?; + let projection = + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; + projection.validate(generation)?; + Ok(projection) + } +} /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { @@ -48,8 +88,7 @@ pub struct DockerBoundaryProvisioning { impl DockerBoundarySpec { /// Produce both sides of the common protocol from the same immutable /// Docker coordinates so attach cannot bind a different container. - #[must_use] - pub fn provision(self) -> DockerBoundaryProvisioning { + pub fn provision(self) -> Result { let mut resource_claims = BTreeMap::from([ ("docker.container_id".to_string(), self.container_id), ("docker.image_identity".to_string(), self.image_identity), @@ -57,12 +96,14 @@ impl DockerBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let driver_fence = DriverFenceEvidence::Docker { - container_id: resource_claims["docker.container_id"].clone(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - DockerBoundaryProvisioning { + let unexpected_networks = Vec::new(); + let outer_fence = DockerOuterFenceEvidence { + container_id: &resource_claims["docker.container_id"], + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&self.generation)?; + Ok(DockerBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -78,7 +119,7 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,9 +133,9 @@ impl DockerBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -102,6 +143,30 @@ impl DockerBoundarySpec { mod tests { use super::*; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["bridge".to_string()]; + for evidence in [ + DockerOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + #[test] fn provisioning_binds_container_and_image_claims() { let session_id = openshell_core::SandboxSessionId::new(); @@ -143,7 +208,8 @@ mod tests { .unwrap(), child_env: HashMap::new(), } - .provision(); + .provision() + .unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -158,14 +224,14 @@ mod tests { "true" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index bdc1b27a6..92d685cf4 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -293,7 +293,6 @@ struct DockerDriverRuntimeConfig { supervisor_grpc_endpoint: String, ssh_socket_path: String, guest_tls: Option, - daemon_version: String, gpu: DockerGpuRuntimeCapabilities, sandbox_pids_limit: Option, enable_bind_mounts: bool, @@ -835,7 +834,7 @@ impl DockerComputeDriver { .map_err(|err| { Error::execution(format!("failed to create Docker client: {err}")) })?; - let version = docker.version().await.map_err(|err| { + docker.version().await.map_err(|err| { Error::execution(format!("failed to query Docker daemon version: {err}")) })?; let info = docker.info().await.map_err(|err| { @@ -919,7 +918,6 @@ impl DockerComputeDriver { supervisor_grpc_endpoint, ssh_socket_path: docker_config.ssh_socket_path.clone(), guest_tls, - daemon_version: version.version.unwrap_or_else(|| "unknown".to_string()), gpu, sandbox_pids_limit: docker_config.sandbox_pids_limit, enable_bind_mounts: docker_config.enable_bind_mounts, @@ -960,7 +958,7 @@ impl DockerComputeDriver { fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { driver_name: "docker".to_string(), - driver_version: self.config.daemon_version.clone(), + driver_version: openshell_core::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, @@ -979,6 +977,12 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/docker", + openshell_core::VERSION, + [], + )), } } @@ -3002,9 +3006,17 @@ impl ComputeDriver for DockerComputeDriver { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(Response::new(self.capabilities())) + let capabilities = self.capabilities(); + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "docker", + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) } async fn validate_sandbox_create( @@ -4349,7 +4361,8 @@ async fn prepare_docker_boundary_files( workload_identity: workload_identity.clone(), child_env: docker_child_environment(sandbox), } - .provision(); + .provision() + .map_err(|error| Status::failed_precondition(error.to_string()))?; let boundary_config = provisioning .boundary_config .encode() diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 5b767979c..4b0c9cef9 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -132,7 +132,6 @@ fn runtime_config() -> DockerDriverRuntimeConfig { cert: PathBuf::from("/tmp/tls.crt"), key: PathBuf::from("/tmp/tls.key"), }), - daemon_version: "28.0.0".to_string(), gpu: DockerGpuRuntimeCapabilities { cdi_supported: false, wsl_all_gpu_fallback_enabled: false, @@ -221,6 +220,23 @@ fn capabilities_report_static_resource_support() { assert!(gpu.count_selection_supported); } +#[tokio::test] +async fn capabilities_reject_missing_gateway_metadata() { + let driver = test_driver_with_config(runtime_config()); + + let error = + ComputeDriver::get_capabilities(&driver, Request::new(GetCapabilitiesRequest::default())) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!( + error + .message() + .contains("gateway did not provide protocol metadata") + ); +} + type TestDriverClient = openshell_core::proto::compute::v1::compute_driver_client::ComputeDriverClient< tonic::transport::Channel, @@ -283,7 +299,11 @@ async fn tracing_standalone_rpc_layer_propagates_context_and_records_errors() { let (mut client, shutdown, server) = standalone_traced_client().await; client - .get_capabilities(request_with_traceparent(GetCapabilitiesRequest {})) + .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + })) .await .expect("capabilities should succeed"); client @@ -436,9 +456,16 @@ async fn tracing_in_process_service_preserves_the_driver_rpc_server_boundary() { otel.name = "openshell.compute.v1.ComputeDriver/GetCapabilities", otel.kind = "client" ); - ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) - .instrument(gateway_span) - .await?; + ComputeDriver::get_capabilities( + &service, + Request::new(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + }), + ) + .instrument(gateway_span) + .await?; let unrelated = tracing::info_span!( target: "openshell_driver_kubernetes::compute", diff --git a/crates/openshell-driver-kubernetes-secrets/src/lib.rs b/crates/openshell-driver-kubernetes-secrets/src/lib.rs index aadc0821f..e0250bbfc 100644 --- a/crates/openshell-driver-kubernetes-secrets/src/lib.rs +++ b/crates/openshell-driver-kubernetes-secrets/src/lib.rs @@ -461,15 +461,29 @@ impl Clone for KubernetesSecretsCredentialDriver { impl CredentialDriver for CredentialDriverService { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(Response::new(GetCredentialDriverCapabilitiesResponse { + let capabilities = GetCredentialDriverCapabilitiesResponse { driver_name: KubernetesSecretsCredentialDriver::NAME.to_string(), driver_version: VERSION.to_string(), backend_kind: KubernetesSecretsCredentialDriver::NAME.to_string(), supports_list: false, supports_expires_at: false, - })) + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Credentials, + "openshell/kubernetes-secrets", + VERSION, + [], + )), + }; + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Credentials, + KubernetesSecretsCredentialDriver::NAME, + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) } async fn store_credential( diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index cbdd5bcc7..cec9b20c5 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -767,6 +767,12 @@ impl KubernetesComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/kubernetes", + openshell_core::VERSION, + [], + )), }) } @@ -2374,7 +2380,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() @@ -2612,7 +2619,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() diff --git a/crates/openshell-driver-kubernetes/src/grpc.rs b/crates/openshell-driver-kubernetes/src/grpc.rs index 65c6a8b85..3427b81f3 100644 --- a/crates/openshell-driver-kubernetes/src/grpc.rs +++ b/crates/openshell-driver-kubernetes/src/grpc.rs @@ -70,14 +70,19 @@ impl ComputeDriver for ComputeDriverService { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { self.rpc_tracer .trace(openshell_otel::rpc::GET_CAPABILITIES, async { - self.driver - .capabilities() - .map(Response::new) - .map_err(Status::internal) + let capabilities = self.driver.capabilities().map_err(Status::internal)?; + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "kubernetes", + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) }) .await } @@ -359,9 +364,16 @@ mod tests { otel.name = "openshell.compute.v1.ComputeDriver/GetCapabilities", otel.kind = "client" ); - ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) - .instrument(gateway_span) - .await?; + ComputeDriver::get_capabilities( + &service, + Request::new(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + }), + ) + .instrument(gateway_span) + .await?; ComputeDriver::validate_sandbox_create( &service, diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index bb9ae8ff6..6a94e6aba 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -20,11 +20,53 @@ use k8s_openapi::api::networking::v1::{ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct KubernetesOuterFenceEvidence<'a> { + network_policy_uid: &'a str, + network_policy_resource_version: &'a str, + ingress_isolated: bool, + egress_isolated: bool, + egress_rule_count: u32, +} + +impl KubernetesOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.network_policy_uid.is_empty() || self.network_policy_resource_version.is_empty() { + return Err(BackendError::Descriptor( + "Kubernetes outer fence evidence is incomplete".to_string(), + )); + } + let mut established = Vec::new(); + if self.ingress_isolated && self.egress_isolated && self.egress_rule_count == 0 { + // A persisted policy selecting both directions with no egress rule + // continues to deny direct egress after revocation or controller loss. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.egress_isolated && self.egress_rule_count == 0 { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) + })?; + let projection = + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; + projection.validate(generation)?; + Ok(projection) + } +} /// Isolation backend implemented by the `OpenShell` sandbox runtime. pub const BACKEND_NAME: &str = openshell_sandbox_backend::BACKEND_NAME; @@ -181,8 +223,7 @@ pub struct KubernetesSandboxRuntimeBoundaryProvisioning { impl KubernetesSandboxRuntimeBoundarySpec { /// Produce both sides of the common protocol from one observed Kubernetes /// resource set so a stale or recreated object cannot be attached. - #[must_use] - pub fn provision(self) -> KubernetesSandboxRuntimeBoundaryProvisioning { + pub fn provision(self) -> Result { let resource_claims = BTreeMap::from([ ("kubernetes.namespace_uid".to_string(), self.namespace_uid), ( @@ -206,15 +247,16 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.egress_policy_resource_version, ), ]); - let driver_fence = DriverFenceEvidence::Kubernetes { - network_policy_uid: resource_claims["kubernetes.egress_policy_uid"].clone(), - network_policy_resource_version: - resource_claims["kubernetes.egress_policy_resource_version"].clone(), + let outer_fence = KubernetesOuterFenceEvidence { + network_policy_uid: &resource_claims["kubernetes.egress_policy_uid"], + network_policy_resource_version: &resource_claims + ["kubernetes.egress_policy_resource_version"], ingress_isolated: true, egress_isolated: true, egress_rule_count: 0, - }; - KubernetesSandboxRuntimeBoundaryProvisioning { + } + .project(&self.generation)?; + Ok(KubernetesSandboxRuntimeBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -233,7 +275,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -248,9 +290,9 @@ impl KubernetesSandboxRuntimeBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -258,6 +300,49 @@ impl KubernetesSandboxRuntimeBoundarySpec { mod tests { use super::*; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + for evidence in [ + KubernetesOuterFenceEvidence { + network_policy_uid: "", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: false, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: false, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 1, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + fn spec() -> KubernetesSandboxRuntimeBoundarySpec { KubernetesSandboxRuntimeBoundarySpec { boundary_id: "sandbox-1".to_string(), @@ -303,7 +388,7 @@ mod tests { #[test] fn provisioning_binds_identical_kubernetes_resource_claims() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -318,21 +403,21 @@ mod tests { "1945" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } #[test] fn provisioning_uses_one_shared_tcp_protocol_across_pods() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.listener, diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 5907e92a8..895d7c8d4 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -15,6 +15,7 @@ name = "openshell_driver_mxc" [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-policy = { path = "../openshell-policy" } # OCSF builders + emit target used by the Windows ETW audit consumer. OS-agnostic # crate (no windows deps), so safe to depend on from all targets; only the # windows-gated `etw_consumer` module actually uses it. @@ -39,9 +40,6 @@ windows = { workspace = true } tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" -# Used by Windows-only integration tests to parse policy YAML into the typed -# proto. Inert on non-Windows. -openshell-policy = { path = "../openshell-policy" } # Needed by the real-wxc integration test (wxc_exec_real.rs) which builds # --config-base64 payloads without going through the async WxcExecInvoker. # base64 and serde_json are already [dependencies] but dev-dependency resolution diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 3b427fe3b..23491533b 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -462,6 +462,12 @@ impl MxcComputeBackend { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/mxc", + openshell_core::VERSION, + [], + )), } } diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs index 004fca381..7ddff4dff 100644 --- a/crates/openshell-driver-mxc/src/grpc.rs +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -36,9 +36,17 @@ impl ComputeDriverService { impl ComputeDriver for ComputeDriverService { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(Response::new(self.backend.capabilities())) + let capabilities = self.backend.capabilities(); + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "mxc", + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) } async fn authenticate_sandbox( @@ -67,14 +75,14 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let req = request.into_inner(); - if req.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); } let sandbox = self .backend - .get_sandbox(&req.sandbox_name) + .get_sandbox(&req.name) .await - .ok_or_else(|| Status::not_found(format!("sandbox {} not found", req.sandbox_name)))?; + .ok_or_else(|| Status::not_found(format!("sandbox {} not found", req.name)))?; if !req.sandbox_id.is_empty() && req.sandbox_id != sandbox.id { return Err(Status::failed_precondition( "sandbox_id did not match the fetched sandbox", @@ -110,10 +118,10 @@ impl ComputeDriver for ComputeDriverService { request: Request, ) -> Result, Status> { let req = request.into_inner(); - if req.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); } - self.backend.stop_sandbox(&req.sandbox_name).await?; + self.backend.stop_sandbox(&req.name).await?; Ok(Response::new(StopSandboxResponse {})) } @@ -134,12 +142,12 @@ impl ComputeDriver for ComputeDriverService { if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } - if req.sandbox_name.is_empty() { - return Err(Status::invalid_argument("sandbox_name is required")); + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); } let deleted = self .backend - .delete_sandbox(&req.sandbox_id, &req.sandbox_name) + .delete_sandbox(&req.sandbox_id, &req.name) .await?; Ok(Response::new(DeleteSandboxResponse { deleted })) } diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 7cb04b102..c1b3af94f 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -524,10 +524,7 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut items, &format!("{path}.tls"), severity, - &format!( - "MXC has no OpenShell TLS inspection mode equivalent for '{}'.", - tls - ), + &format!("MXC has no OpenShell TLS inspection mode equivalent for '{tls}'."), "TLS inspection mode", "MXC network policy is host-level only.", ); @@ -563,7 +560,7 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut items, &format!("{path}.access"), "error", - &format!("MXC has no access preset equivalent for '{}'.", access), + &format!("MXC has no access preset equivalent for '{access}'."), "REST/WebSocket/GraphQL access preset", "MXC cannot enforce method or operation-level access.", ); diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index d80dfab06..6dda646bd 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -452,6 +452,7 @@ fn b_tls_skip_warning() { /// endpoint.tls = "full" (any non-skip) → "error". #[test] +#[allow(deprecated)] fn b_tls_non_skip_error() { let policy = net_policy( "r", diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index bacc58769..de3bf3c95 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -543,6 +543,12 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/podman", + openshell_core::VERSION, + [], + )), }) } diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index ef336f4dc..a25b6b7e4 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -66,14 +66,19 @@ impl ComputeDriver for ComputeDriverService { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { self.rpc_tracer .trace(openshell_otel::rpc::GET_CAPABILITIES, async { - self.driver - .capabilities() - .map(Response::new) - .map_err(Status::from) + let capabilities = self.driver.capabilities().map_err(Status::from)?; + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "podman", + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) }) .await } @@ -358,7 +363,14 @@ mod tests { async { let gateway_span = tracing::info_span!(target: "openshell_server::compute", "driver", otel.name = "openshell.compute.v1.ComputeDriver/GetCapabilities", otel.kind = "client"); - ComputeDriver::get_capabilities(&service, Request::new(GetCapabilitiesRequest {})) + ComputeDriver::get_capabilities( + &service, + Request::new(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + }), + ) .instrument(gateway_span) .await } @@ -421,7 +433,11 @@ mod tests { let (mut client, shutdown, server) = standalone_traced_client().await; client - .get_capabilities(request_with_traceparent(GetCapabilitiesRequest {})) + .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + })) .await .expect("capabilities should succeed"); client diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa647..69679661f 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,7 +10,9 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -27,6 +29,40 @@ pub const AUTH_BUNDLE_PATH: &str = "/.openshell/supervisor/auth.json"; pub const RESTART_METADATA_PATH: &str = "/.openshell/supervisor/restart-metadata.json"; const SOCKET_PATH: &str = "/.openshell/channel/sandbox/control.sock"; +#[derive(Serialize)] +struct PodmanOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl PodmanOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() { + return Err(invalid("Podman outer fence evidence is incomplete")); + } + let mut established = Vec::new(); + if self.network_mode == "none" { + // With no container network namespace attachment, workload egress + // remains denied both after revocation and if the supervisor exits. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.unexpected_networks.is_empty() { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } + let encoded = serde_json::to_vec(self).map_err(invalid)?; + let projection = + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded) + .map_err(invalid)?; + projection.validate(generation).map_err(invalid)?; + Ok(projection) + } +} + pub fn supervisor_name(id: &str) -> String { format!("openshell-supervisor-{id}") } @@ -159,15 +195,17 @@ pub fn bootstrap_archives( identity.resource_digest.clone(), ), ]); - let driver_fence = DriverFenceEvidence::Podman { - container_id: container_id.into(), - network_mode: "none".into(), - unexpected_networks: Vec::new(), - }; let runtime_generation = launch_authentication .supervisor .runtime_generation .to_string(); + let unexpected_networks = Vec::new(); + let outer_fence = PodmanOuterFenceEvidence { + container_id, + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&runtime_generation)?; let verification_keys = launch_authentication .verification_keys .iter() @@ -198,7 +236,7 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: child_env.clone(), }; let runtime_descriptor = SandboxRuntimeDescriptor { @@ -215,7 +253,7 @@ pub fn bootstrap_archives( host_gateway_ip: None, resource_claims, workload_identity: identity.clone(), - driver_fence, + outer_fence, }; // Libpod resolves the requested upload destination once for a stopped // container. Archive entries must be relative to the selected named volume, @@ -327,6 +365,30 @@ mod tests { SupervisorAuthBundle, }; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["podman".to_string()]; + for evidence in [ + PodmanOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + PodmanOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + PodmanOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + fn authentication() -> SandboxLaunchAuthentication { SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { @@ -440,9 +502,12 @@ mod tests { .unwrap(); assert_eq!(config.boundary_id, runtime_descriptor.boundary_id); assert_eq!(config.session_id, runtime_descriptor.session_id); - assert_eq!(config.driver_fence, runtime_descriptor.driver_fence); + assert_eq!(config.outer_fence, runtime_descriptor.outer_fence); assert_eq!(config.workload_identity, identity); - runtime_descriptor.driver_fence.validate().unwrap(); + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation) + .unwrap(); let restart_metadata: RestartMetadata = serde_json::from_slice( supervisor .get(&PathBuf::from( diff --git a/crates/openshell-driver-vault/src/lib.rs b/crates/openshell-driver-vault/src/lib.rs index 471bb4a03..b2738e360 100644 --- a/crates/openshell-driver-vault/src/lib.rs +++ b/crates/openshell-driver-vault/src/lib.rs @@ -501,15 +501,29 @@ impl Clone for VaultCredentialDriver { impl CredentialDriver for CredentialDriverService { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(Response::new(GetCredentialDriverCapabilitiesResponse { + let capabilities = GetCredentialDriverCapabilitiesResponse { driver_name: VaultCredentialDriver::NAME.to_string(), driver_version: VERSION.to_string(), backend_kind: VaultCredentialDriver::NAME.to_string(), supports_list: false, supports_expires_at: false, - })) + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Credentials, + "openshell/vault", + VERSION, + [], + )), + }; + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Credentials, + VaultCredentialDriver::NAME, + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) } async fn store_credential( @@ -1004,6 +1018,38 @@ mod tests { file } + #[tokio::test] + async fn capabilities_reject_missing_gateway_metadata() { + let token = token_file("dev-token"); + let driver = VaultCredentialDriver::from_config(&table(&[ + ( + "address", + toml::Value::String("http://127.0.0.1:8200".to_string()), + ), + ("auth_method", toml::Value::String("token_file".to_string())), + ( + "token_path", + toml::Value::String(token.path().display().to_string()), + ), + ])) + .unwrap(); + let service = CredentialDriverService::new(driver); + + let error = CredentialDriver::get_capabilities( + &service, + Request::new(GetCredentialDriverCapabilitiesRequest::default()), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!( + error + .message() + .contains("gateway did not provide protocol metadata") + ); + } + fn test_ca() -> (rcgen::Certificate, KeyPair) { let mut params = CertificateParams::new(Vec::::new()).unwrap(); params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 0dc41498c..f38fe901d 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -47,6 +47,7 @@ First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw By default `mise run gateway:vm`: - Listens on plaintext HTTP at `127.0.0.1:18081`. +- Uses `nvcr.io/nvidia/base/ubuntu:24.04` as the sandbox and bootstrap image. - Configures the gateway installation name as `vm-dev` and registers the same name with the CLI by writing `~/.config/openshell/gateways/vm-dev/metadata.json`. It does not modify the @@ -198,7 +199,9 @@ payload to a temporary bootstrap VM, and guest init runs `umoci raw unpack` onto Linux-owned ext4 storage. The resulting disk is cached under `/images//rootfs.ext4` and attached read-only to later sandboxes. Local Docker images are still exported as rootfs tar archives and -prepared inside the bootstrap VM. Set `OPENSHELL_VM_IMAGE_PULL_CONCURRENCY` to +prepared inside the bootstrap VM. The driver checks that a prepared disk +contains the unpacked rootfs before caching it; on failure it caches nothing +and reports the image-prep console tail. Set `OPENSHELL_VM_IMAGE_PULL_CONCURRENCY` to tune registry layer download parallelism (default `4`, maximum `16`). Both caches are scoped by source image identity and OpenShell version, so an OpenShell upgrade builds a fresh guest rootfs instead of reusing one with an old diff --git a/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig b/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig index 4249e7112..d4493abd7 100644 --- a/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig +++ b/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig @@ -35,3 +35,11 @@ CONFIG_SECURITY_LANDLOCK=y CONFIG_LSM="landlock,lockdown,yama,loadpin,safesetid,ipe,bpf" CONFIG_SECCOMP=y CONFIG_SECCOMP_FILTER=y + +# Kernel memory hardening. Keep init-on-free disabled because its runtime cost +# is too high for sandbox workloads. +CONFIG_HARDENED_USERCOPY=y +CONFIG_INIT_STACK_ALL_ZERO=y +CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y +# CONFIG_INIT_ON_FREE_DEFAULT_ON is not set +CONFIG_SLAB_FREELIST_RANDOM=y diff --git a/crates/openshell-driver-vm/runtime/pins.env b/crates/openshell-driver-vm/runtime/pins.env index 5595394b5..35f52678a 100644 --- a/crates/openshell-driver-vm/runtime/pins.env +++ b/crates/openshell-driver-vm/runtime/pins.env @@ -4,8 +4,7 @@ # Pinned dependency versions for the openshell-driver-vm runtime. # # This file is sourced by VM runtime packaging scripts. It centralises version -# pins and content-addressed digests so that builds are reproducible and -# auditable. +# pins so that builds are reproducible and auditable. # # Environment variables override these defaults — CI and local dev workflows # can still set the runtime dependency versions below. @@ -15,30 +14,20 @@ # 2. Run the relevant build script to verify. # 3. Commit pins.env alongside any script changes. -# ── Base Docker image (digest-pinned) ────────────────────────────────── -# Tag: nvcr.io/nvidia/base/ubuntu:noble-20251013 -VM_BASE_IMAGE="${VM_BASE_IMAGE:-nvcr.io/nvidia/base/ubuntu@sha256:43fa5063e80fbbc533892af3ccca190868ce48db5a8928b19d7815c40436af8e}" - -# ── Container images for rootfs pre-loading (digest-pinned) ──────────── -# Tag: registry.k8s.io/agent-sandbox/agent-sandbox-controller:v0.1.0 -AGENT_SANDBOX_IMAGE="${AGENT_SANDBOX_IMAGE:-registry.k8s.io/agent-sandbox/agent-sandbox-controller@sha256:ba71ea40ae0872791197badf2ab84f3f482df3902f1fce7ca9e076b1de9b57f6}" -# Tag: ghcr.io/nvidia/openshell-community/sandboxes/base:latest -COMMUNITY_SANDBOX_IMAGE="${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:d446c17105e7448e602238a8a5a4ddd0233c071082406522f81c31f8b1309525}" - # SERVER_IMAGE is intentionally NOT pinned here — it changes frequently # during local development. Override via IMAGE_REPO_BASE and IMAGE_TAG # environment variables (defaults: openshell/gateway:dev). -# ── umoci (guest OCI unpacker) ────────────────────────────────────────── +# ── umoci (guest OCI unpacker) ─────────────────────────────────────── # Repo: https://github.com/opencontainers/umoci UMOCI_VERSION="${UMOCI_VERSION:-v0.6.0}" -# ── libkrunfw upstream (commit-pinned) ───────────────────────────────── +# ── libkrunfw upstream (commit-pinned) ──────────────────────────────── # Repo: https://github.com/libkrun/libkrunfw -# Pinned: v5.6.1 (2026-09-16) +# Pinned: v5.6.1 (Linux 6.12.109, 2026-09-16) LIBKRUNFW_REF="${LIBKRUNFW_REF:-617938cf2ae9a3a0e5696d23a5d6252cd7a1ef4f}" -# ── libkrun upstream (commit-pinned) ────────────────────────────────── +# ── libkrun upstream (commit-pinned) ───────────────────────────────── # Repo: https://github.com/libkrun/libkrun # Pinned: v1.19.4 (2026-09-16) LIBKRUN_REF="${LIBKRUN_REF:-728df8125077d0db44265f6e997c72b81b65c015}" diff --git a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh index 0f6331619..7975f60cd 100644 --- a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh +++ b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh @@ -162,25 +162,30 @@ prepare_guest_image_rootfs() { rm -rf "$image_root" "$partial_root" + # Build the rootfs under $partial_root and rename it into place last. The + # host only caches a prepared disk that has $image_root, and guest exit + # codes do not reach the host, so $image_root must not exist until every + # step has succeeded. case "$source" in local-docker) - mkdir -p "$image_root" - tar -xpf "$payload_dir/source-rootfs.tar" -C "$image_root" + mkdir -p "$partial_root" + tar -xpf "$payload_dir/source-rootfs.tar" -C "$partial_root" ;; oci-layout) if [ ! -x /opt/openshell/bin/umoci ]; then ts "FATAL: umoci not found in VM bootstrap image" exit 1 fi + # `umoci raw unpack` extracts the image filesystem directly into + # the target directory; unlike `umoci unpack`, it does not create + # a bundle with a rootfs/ subdirectory. /opt/openshell/bin/umoci raw unpack \ --image "$payload_dir/oci:openshell" \ "$partial_root" - if [ ! -d "$partial_root/rootfs" ]; then - ts "FATAL: umoci unpack did not produce rootfs directory" + if [ ! -d "$partial_root" ]; then + ts "FATAL: umoci unpack did not produce a rootfs directory" exit 1 fi - mv "$partial_root/rootfs" "$image_root" - rm -rf "$partial_root" ;; *) ts "FATAL: unknown guest image payload source: ${source:-missing}" @@ -188,11 +193,12 @@ prepare_guest_image_rootfs() { ;; esac - ensure_target_runtime "$image_root" + ensure_target_runtime "$partial_root" if [ -f "$payload_dir/identity" ]; then - cp "$payload_dir/identity" "$image_root/.openshell-rootfs-variant" + cp "$payload_dir/identity" "$partial_root/.openshell-rootfs-variant" fi rm -rf "$payload_dir" + mv "$partial_root" "$image_root" } exec_supervisor_in_newroot() { diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 14f1f9e45..fc337ea7c 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -12,9 +12,9 @@ use crate::lifecycle::{ }; use crate::rootfs::{ clone_or_copy_sparse_file, create_ext4_image_from_dir_with_size, create_rootfs_image_from_dir, - extract_host_supervisor, extract_rootfs_archive_to, prepare_sandbox_rootfs_from_image_root, - recover_rootfs_image, remove_rootfs_image_file, sandbox_guest_init_path, - sandbox_guest_runtime_identity, sandbox_guest_user_ids_from_image, + ext4_image_has_directory, extract_host_supervisor, extract_rootfs_archive_to, + prepare_sandbox_rootfs_from_image_root, recover_rootfs_image, remove_rootfs_image_file, + sandbox_guest_init_path, sandbox_guest_runtime_identity, sandbox_guest_user_ids_from_image, sandbox_guest_user_ids_from_overlay_image, set_rootfs_image_file_mode, validate_host_supervisor, write_rootfs_image_file, }; @@ -202,6 +202,10 @@ const PREPARED_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-prepared-rootfs-ext4- const IMAGE_IDENTITY_FILE: &str = "image-identity"; const IMAGE_REFERENCE_FILE: &str = "image-reference"; const IMAGE_PREP_INIT_MODE: &str = "image-prep"; +const IMAGE_PREP_CONSOLE_LOG: &str = "image-prep-console.log"; +/// Directory the guest image-prep init writes at the root of the prepared disk +/// once preparation succeeds (`image_root` in `openshell-vm-sandbox-init.sh`). +const PREPARED_IMAGE_ROOTFS_DIR: &str = "/image-rootfs"; static IMAGE_CACHE_BUILD_COUNTER: AtomicU64 = AtomicU64::new(0); static OWNER_STATE_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -1001,6 +1005,12 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/vm", + openshell_core::VERSION, + [], + )), } } @@ -3580,6 +3590,33 @@ impl VmDriver { return Err(err); } + // The prep VM exits successfully even when guest init fails, so check + // the disk itself. Caching a disk without the rootfs would break every + // later sandbox that uses this image. + let prepared_image_for_check = prepared_image.clone(); + let has_rootfs = tokio::task::spawn_blocking(move || { + ext4_image_has_directory(&prepared_image_for_check, PREPARED_IMAGE_ROOTFS_DIR) + }) + .await + .map_err(|err| Status::internal(format!("prepared image validation panicked: {err}")))?; + if !matches!(has_rootfs, Ok(true)) { + let mut message = format!( + "image-prep for \"{image_ref}\" did not produce {PREPARED_IMAGE_ROOTFS_DIR}" + ); + if let Err(err) = &has_rootfs { + write!(message, ": {err}").expect("writing to String cannot fail"); + } + if let Some(console) = read_vm_console_tail( + &staging_dir.join(IMAGE_PREP_CONSOLE_LOG), + VM_CONSOLE_DIAGNOSTIC_BYTES, + ) { + write!(message, "; guest console tail:\n{console}") + .expect("writing to String cannot fail"); + } + let _ = tokio::fs::remove_dir_all(staging_dir).await; + return Err(Status::failed_precondition(message)); + } + if tokio::fs::metadata(&image_path).await.is_ok() { let _ = tokio::fs::remove_dir_all(staging_dir).await; return Ok(()); @@ -3598,7 +3635,7 @@ impl VmDriver { prep_disk: &Path, run_dir: &Path, ) -> Result<(), Status> { - let console_output = run_dir.join("image-prep-console.log"); + let console_output = run_dir.join(IMAGE_PREP_CONSOLE_LOG); let mut command = Command::new(&self.launcher_bin); command.kill_on_drop(true); command.stdin(Stdio::null()); @@ -4236,9 +4273,17 @@ impl ComputeDriver for VmDriver { async fn get_capabilities( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(Response::new(self.capabilities())) + let capabilities = self.capabilities(); + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + DRIVER_NAME, + capabilities.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(capabilities)) } async fn validate_sandbox_create( @@ -6608,9 +6653,12 @@ fn prepared_image_disk_size_bytes( .map_err(|err| format!("stat {}: {err}", rootfs_archive.display()))? .len(), }; + // The payload and the unpacked rootfs coexist until the guest deletes the + // payload, and compressed layers commonly expand 2.5-3x. The disk file is + // sparse, so extra headroom costs no host disk space. let requested = payload_size - .saturating_mul(3) - .saturating_add(512 * 1024 * 1024); + .saturating_mul(4) + .saturating_add(1024 * 1024 * 1024); Ok(minimum_size_bytes.max(requested)) } @@ -7131,7 +7179,11 @@ mod tests { let mut client = traced_driver_client(driver).await; client - .get_capabilities(request_with_traceparent(GetCapabilitiesRequest {})) + .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + })) .await .unwrap(); client.shutdown().await; @@ -7163,7 +7215,11 @@ mod tests { let mut client = traced_driver_client(driver).await; client - .get_capabilities(request_with_traceparent(GetCapabilitiesRequest {})) + .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + })) .await .unwrap(); assert!( diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 49c113531..841323ab2 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,14 +9,50 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, DriverFenceEvidence, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; use std::collections::{BTreeMap, HashMap}; +#[derive(Serialize)] +struct VmOuterFenceEvidence<'a> { + generation: &'a str, + network_device_count: u32, +} + +impl VmOuterFenceEvidence<'_> { + fn project(&self) -> Result { + if self.generation.is_empty() { + return Err(BackendError::Descriptor( + "VM outer fence evidence is incomplete".to_string(), + )); + } + let established = (self.network_device_count == 0).then_some([ + // A guest with no NIC has no kernel network path. Closing the + // supervisor-owned channel revokes access, and controller loss + // cannot introduce a device. + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) + })?; + let projection = OuterFenceGuarantees::from_enforcement_evidence( + self.generation, + established.into_iter().flatten(), + &encoded, + )?; + projection.validate(self.generation)?; + Ok(projection) + } +} + /// Driver-owned inputs that bind one VM generation to one supervisor boundary. pub struct VmBoundarySpec { pub boundary_id: String, @@ -57,10 +93,11 @@ impl VmBoundarySpec { ("vm.generation".to_string(), self.generation.clone()), ("vm.image_identity".to_string(), self.image_identity), ]); - let driver_fence = DriverFenceEvidence::Vm { - generation: self.generation.clone(), + let outer_fence = VmOuterFenceEvidence { + generation: &self.generation, network_device_count: 0, - }; + } + .project()?; Ok(VmBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), @@ -77,7 +114,7 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,7 +129,7 @@ impl VmBoundarySpec { // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims, - driver_fence, + outer_fence, }, }) } @@ -106,6 +143,26 @@ mod tests { generate_sandbox_tls_material, }; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + assert!( + VmOuterFenceEvidence { + generation: "", + network_device_count: 0, + } + .project() + .is_err() + ); + assert!( + VmOuterFenceEvidence { + generation: "generation-1", + network_device_count: 1, + } + .project() + .is_err() + ); + } + #[test] fn provisioning_binds_identical_resource_claims() { let session_id = openshell_core::SandboxSessionId::new(); @@ -155,14 +212,14 @@ mod tests { Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-driver-vm/src/rootfs.rs b/crates/openshell-driver-vm/src/rootfs.rs index c86804934..e1a09bcd9 100644 --- a/crates/openshell-driver-vm/src/rootfs.rs +++ b/crates/openshell-driver-vm/src/rootfs.rs @@ -879,6 +879,60 @@ pub fn sandbox_guest_user_ids_from_overlay_image( sandbox_guest_user_ids_from_image_path(image_path, "/upper/etc/passwd") } +/// Check, without mounting, whether an ext4 image has a directory at +/// `guest_path`. Symlinks are not followed. +pub fn ext4_image_has_directory(image_path: &Path, guest_path: &str) -> Result { + let quoted_path = debugfs_quote_absolute_path(guest_path) + .ok_or_else(|| format!("invalid debugfs guest path '{guest_path}'"))?; + let command = format!("stat {quoted_path}"); + let mut last_error = None; + + for candidate in e2fs_tool_candidates("debugfs") { + let label = candidate.display().to_string(); + match Command::new(&candidate) + .arg("-R") + .arg(&command) + .arg(image_path) + .output() + { + Ok(output) if output.status.success() => { + // debugfs exits 0 whether or not the path exists; the answer + // is only in its output. + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if stdout.contains("Type: directory") { + return Ok(true); + } + if stdout.contains("Type: ") || stderr.contains("File not found") { + return Ok(false); + } + return Err(format!( + "debugfs command '{command}' produced unrecognized output for {}\nstdout: {stdout}\nstderr: {stderr}", + image_path.display() + )); + } + Ok(output) => { + last_error = Some(format!( + "{label} failed with status {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + last_error = Some(format!("{label} not found")); + } + Err(error) => last_error = Some(format!("run {label}: {error}")), + } + } + + Err(format!( + "debugfs command '{command}' failed for {}: {}. Install e2fsprogs (debugfs) and retry", + image_path.display(), + last_error.unwrap_or_else(|| "debugfs not found".to_string()) + )) +} + fn sandbox_guest_user_ids_from_image_path( image_path: &Path, guest_path: &str, @@ -1524,6 +1578,35 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn ext4_image_has_directory_distinguishes_directories_files_and_missing_paths() { + if !e2fs_tool_candidates("debugfs") + .iter() + .any(|candidate| Command::new(candidate).arg("-V").output().is_ok()) + { + return; + } + + let dir = unique_temp_dir(); + let source = dir.join("source"); + let image = dir.join("prepared.ext4"); + fs::create_dir_all(source.join("image-rootfs/bin")).expect("create image rootfs"); + fs::write(source.join("regular"), "file\n").expect("write regular file"); + create_ext4_image_from_dir_with_size(&source, &image, 64 * 1024 * 1024) + .expect("create ext4 image"); + + assert!(ext4_image_has_directory(&image, "/image-rootfs").expect("stat directory")); + assert!(!ext4_image_has_directory(&image, "/regular").expect("stat regular file")); + assert!(!ext4_image_has_directory(&image, "/missing").expect("stat missing path")); + assert!(ext4_image_has_directory(&image, "relative").is_err()); + + let not_ext4 = dir.join("not-ext4.img"); + fs::write(¬_ext4, vec![0_u8; 64 * 1024]).expect("write non-ext4 image"); + assert!(ext4_image_has_directory(¬_ext4, "/image-rootfs").is_err()); + + let _ = fs::remove_dir_all(&dir); + } + #[test] fn sandbox_guest_user_ids_reads_existing_sandbox_user() { let dir = unique_temp_dir(); diff --git a/crates/openshell-gateway-interceptors/src/plan.rs b/crates/openshell-gateway-interceptors/src/plan.rs index 332834824..cec071187 100644 --- a/crates/openshell-gateway-interceptors/src/plan.rs +++ b/crates/openshell-gateway-interceptors/src/plan.rs @@ -10,6 +10,9 @@ use openshell_core::config::{ GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, }; +use openshell_core::extension_protocol::{ + ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate, +}; use openshell_core::proto::gateway_interceptor::v1::{ DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, gateway_interceptor_client::GatewayInterceptorClient, @@ -158,6 +161,7 @@ pub struct ExecutionPlan { bindings: BTreeMap<(RpcSelector, Phase), Vec>, profile_sources: BTreeMap, routes: OpenShellRouteIndex, + negotiated_extensions: Vec, } impl ExecutionPlan { @@ -167,6 +171,7 @@ impl ExecutionPlan { bindings: BTreeMap::new(), profile_sources: BTreeMap::new(), routes, + negotiated_extensions: Vec::new(), } } @@ -181,6 +186,7 @@ impl ExecutionPlan { let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); let mut profile_sources = BTreeMap::new(); + let mut negotiated_extensions = Vec::new(); for config in configs { let channel = connect_endpoint(&config).await?; @@ -208,22 +214,33 @@ impl ExecutionPlan { .max_response_bytes .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), ); - let manifest = - tokio::time::timeout(timeout, client.describe(Request::new(DescribeRequest {}))) - .await - .map_err(|_| { - InterceptorError::Transport(format!( - "Describe timed out for '{}'", - config.name - )) - })? - .map_err(|status| { - InterceptorError::Transport(format!( - "Describe failed for '{}': {status}", - config.name - )) - })? - .into_inner(); + let gateway = gateway_metadata(ExtensionFamily::GatewayInterceptor); + let manifest = tokio::time::timeout( + timeout, + client.describe(Request::new(DescribeRequest { + gateway: Some(gateway.clone()), + })), + ) + .await + .map_err(|_| { + InterceptorError::Transport(format!("Describe timed out for '{}'", config.name)) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); + negotiated_extensions.push( + negotiate( + ExtensionFamily::GatewayInterceptor, + &config.name, + &gateway, + manifest.extension.clone(), + ) + .map_err(|error| InterceptorError::Config(error.to_string()))?, + ); validate_expected_audience( &config, &manifest.expected_audience, @@ -326,6 +343,7 @@ impl ExecutionPlan { bindings, profile_sources, routes, + negotiated_extensions, }) } @@ -336,6 +354,10 @@ impl ExecutionPlan { self.profile_sources.get(interceptor_name).cloned() } + pub(crate) fn negotiated_extensions(&self) -> &[NegotiatedExtension] { + &self.negotiated_extensions + } + pub(crate) fn is_empty(&self) -> bool { self.bindings.is_empty() && self.profile_sources.is_empty() } diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index 4bd15f7af..a67ae30c5 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -321,6 +321,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }; let bytes = request.encode_to_vec(); let json = codec diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index 61f88c8a8..21de44fb9 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -10,6 +10,7 @@ use std::time::Instant; use json_patch::{PatchOperation, patch}; use metrics::{counter, histogram}; use openshell_core::config::GatewayInterceptorConfig; +use openshell_core::extension_protocol::NegotiatedExtension; use openshell_core::proto::gateway_interceptor::v1::{ InterceptorEvaluation, InterceptorResult, JsonPatch, ModifyOperationEvaluation, PostCommitEvaluation, ValidateEvaluation, interceptor_evaluation, @@ -104,6 +105,11 @@ impl GatewayInterceptorRuntime { self.plan.is_empty() } + #[must_use] + pub fn negotiated_extensions(&self) -> &[NegotiatedExtension] { + self.plan.negotiated_extensions() + } + #[must_use] pub fn should_intercept_path(&self, path: &str) -> bool { let Some(selector) = RpcSelector::from_grpc_path(path) else { @@ -1087,6 +1093,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }; let bytes = request.encode_to_vec(); diff --git a/crates/openshell-gateway/src/vm.rs b/crates/openshell-gateway/src/vm.rs index 07735c7a9..ce8e28e6d 100644 --- a/crates/openshell-gateway/src/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -58,6 +58,7 @@ use tower::service_fn; const DRIVER_BIN_NAME: &str = "openshell-driver-vm"; const COMPUTE_DRIVER_SOCKET_RUN_DIR: &str = "run"; const COMPUTE_DRIVER_SOCKET_NAME: &str = "compute-driver.sock"; +const DEFAULT_VM_SANDBOX_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; /// Configuration for launching and talking to the VM compute driver. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -233,7 +234,7 @@ impl Default for VmComputeConfig { Self { state_dir: Self::default_state_dir(), driver_dir: None, - default_image: openshell_core::image::default_sandbox_image(), + default_image: DEFAULT_VM_SANDBOX_IMAGE.to_string(), grpc_endpoint: String::new(), bootstrap_image: String::new(), krun_log_level: Self::default_krun_log_level(), @@ -718,7 +719,11 @@ async fn wait_for_compute_driver( let mut client = ComputeDriverClient::with_interceptor(channel.clone(), TraceContextInterceptor); match client - .get_capabilities(tonic::Request::new(GetCapabilitiesRequest {})) + .get_capabilities(tonic::Request::new(GetCapabilitiesRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + )), + })) .await { Ok(_) => return Ok(channel), @@ -759,7 +764,7 @@ async fn connect_compute_driver(socket_path: &Path) -> Result { #[cfg(all(test, unix))] mod tests { use super::{ - VmComputeConfig, append_otlp_args, append_vm_identity_args, + DEFAULT_VM_SANDBOX_IMAGE, VmComputeConfig, append_otlp_args, append_vm_identity_args, append_vm_proxy_and_spiffe_args, append_vm_rootfs_tar_args, compute_driver_guest_tls_paths, compute_driver_socket_path, current_euid, prepare_compute_driver_socket_path, prepare_vm_state_dir, resolve_compute_driver_bin, resolve_driver_search_dirs, @@ -772,6 +777,14 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn vm_uses_nvidia_ubuntu_default_image() { + assert_eq!( + VmComputeConfig::default().default_image, + DEFAULT_VM_SANDBOX_IMAGE + ); + } + #[test] fn vm_driver_command_includes_gateway_otlp_configuration() { let mut command = tokio::process::Command::new("openshell-driver-vm"); diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 94220dda7..0426a9011 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -14,6 +14,8 @@ repository.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8e68d48c7..163cb9100 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -16,7 +16,8 @@ //! //! Each transition consumes the prior state by value (`self: Box`). //! Trusted backend implementations construct confirmation through a validating -//! constructor; the supervisor cannot obtain a ready boundary without evidence. +//! constructor; the supervisor cannot obtain a ready boundary without confirmed +//! backend-neutral enforcement properties. //! The supervisor holds no `match`/downcast on concrete backends: the //! registry is the only lookup by `backend_name`, and everything past it is a //! `Box` / `Arc`. @@ -30,7 +31,7 @@ //! The contract is transport-neutral. Compute drivers keep runtime placement //! and coordination details behind these interfaces. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -385,190 +386,165 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Capability masks measured from `/proc//status`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityEvidence { - pub inheritable: u64, - pub permitted: u64, - pub effective: u64, - pub bounding: u64, - pub ambient: u64, +/// Backend-neutral guarantees established by the component that owns the outer +/// network fence. +/// +/// The enforcement owner may be a compute driver or a delegated isolation +/// backend. It owns its native evidence schema and the code that validates it. +/// After validation, it projects that evidence into these guarantees and +/// supplies a digest that binds the original evidence to this generation. The +/// common runtime only validates and compares this projection; it never +/// interprets backend- or runtime-specific fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OuterFenceGuarantee { + /// No workload packet can leave without an explicit mediated decision. + DefaultDenyEgress, + /// The enforcement owner found no network path outside the mediated boundary. + NoUnmanagedEgressPath, + /// Previously granted access can be revoked by the enforcement owner. + RevocationVerified, + /// Loss of the fence's controller does not open network access. + ControllerLossFailsClosed, } -impl CapabilityEvidence { - /// True only when every Linux capability set is empty. - #[must_use] - pub const fn is_empty(self) -> bool { - self.inheritable == 0 - && self.permitted == 0 - && self.effective == 0 - && self.bounding == 0 - && self.ambient == 0 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OuterFenceGuarantees { + /// Sandbox generation for which the evidence was collected. + pub generation: String, + /// Complete set of normalized guarantees established by the enforcement owner. + pub established: BTreeSet, + /// Commitment to the enforcement owner's native evidence. + pub evidence_digest: Sha256Digest, +} + +impl OuterFenceGuarantees { + /// Bind guarantees explicitly established by validated enforcement evidence. + /// + /// This constructor deliberately does not infer guarantees from the mere + /// presence of evidence. The enforcement owner must inspect its native + /// state and project each established guarantee before calling this + /// function. + pub fn from_enforcement_evidence( + generation: impl Into, + established: impl IntoIterator, + native_evidence: &[u8], + ) -> Result { + let generation = generation.into(); + if generation.is_empty() || native_evidence.is_empty() { + return Err(BackendError::Descriptor( + "outer fence generation and native evidence are required".to_string(), + )); + } + let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len()); + binding.extend_from_slice(&(generation.len() as u64).to_be_bytes()); + binding.extend_from_slice(generation.as_bytes()); + binding.extend_from_slice(native_evidence); + Ok(Self { + generation, + established: established.into_iter().collect(), + evidence_digest: Sha256Digest::compute(&binding), + }) + } + + /// Validate the common guarantees against the admitted generation. + pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { + let required = BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let complete = !self.generation.is_empty() + && self.generation == expected_generation + && self.established == required; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "outer fence guarantees are incomplete or bound to another generation".to_string(), + )) + } } } -/// Active seccomp notification and socket-broker evidence. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "each independently measured kernel operation is reported explicitly" -)] -pub struct SeccompEvidence { - pub new_listener: bool, - pub notification_round_trip: bool, - pub id_validation: bool, - pub addfd_send: bool, - pub retained_socket_operation: bool, - pub proc_fd_identity: bool, - pub task_memory_read: bool, - pub task_memory_write: bool, - pub cancellation: bool, -} - -/// Driver-owned evidence that the mandatory outer network fence is installed. +/// A backend-neutral security property established before agent launch. /// -/// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device -/// model directly. Drivers therefore bind the exact fence they validated into -/// both protected bootstrap halves. The sandbox reports that value back during -/// confirmation, and the supervisor rejects any mismatch before agent launch. +/// `mechanism` is diagnostic and audit metadata. It never authorizes launch; +/// the registered backend is responsible for validating its mechanism-specific +/// evidence before setting `enforced`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)] -pub enum DriverFenceEvidence { - Docker { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Podman { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Kubernetes { - network_policy_uid: String, - network_policy_resource_version: String, - ingress_isolated: bool, - egress_isolated: bool, - egress_rule_count: u32, - }, - Vm { - generation: String, - network_device_count: u32, - }, +pub struct EnforcedProperty { + pub enforced: bool, + pub mechanism: String, } -impl DriverFenceEvidence { +impl EnforcedProperty { #[must_use] - pub const fn driver_name(&self) -> &'static str { - match self { - Self::Docker { .. } => "docker", - Self::Podman { .. } => "podman", - Self::Kubernetes { .. } => "kubernetes", - Self::Vm { .. } => "vm", + pub fn new(enforced: bool, mechanism: impl Into) -> Self { + Self { + enforced, + mechanism: mechanism.into(), } } - /// Validate the concrete outer-fence properties reported by the compute driver. - pub fn validate(&self) -> Result<(), BackendError> { - let valid = match self { - Self::Docker { - container_id, - network_mode, - unexpected_networks, - } - | Self::Podman { - container_id, - network_mode, - unexpected_networks, - } => { - !container_id.is_empty() && network_mode == "none" && unexpected_networks.is_empty() - } - Self::Kubernetes { - network_policy_uid, - network_policy_resource_version, - ingress_isolated, - egress_isolated, - egress_rule_count, - } => { - !network_policy_uid.is_empty() - && !network_policy_resource_version.is_empty() - && *ingress_isolated - && *egress_isolated - && *egress_rule_count == 0 - } - Self::Vm { - generation, - network_device_count, - } => !generation.is_empty() && *network_device_count == 0, - }; - if valid { + fn validate(&self, name: &str) -> Result<(), BackendError> { + if self.enforced && !self.mechanism.trim().is_empty() { Ok(()) } else { Err(BackendError::Confirm(format!( - "{} driver fence evidence is incomplete", - self.driver_name() + "{name} is not enforced or has no declared mechanism" ))) } } } -/// Measured sandbox-owned evidence produced before agent launch. +/// Security properties every isolation backend establishes before launch. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "confirmation preserves independently measured security results" -)] -pub struct SandboxConfirmEvidence { +pub struct BoundaryProperties { + pub filesystem_confinement: EnforcedProperty, + pub egress_interception: EnforcedProperty, + pub request_attribution: EnforcedProperty, + pub privilege_floor: EnforcedProperty, +} + +impl BoundaryProperties { + fn validate(&self) -> Result<(), BackendError> { + self.filesystem_confinement + .validate("filesystem confinement")?; + self.egress_interception.validate("egress interception")?; + self.request_attribution.validate("request attribution")?; + self.privilege_floor.validate("privilege floor") + } +} + +/// Per-boundary confirmation produced before agent launch. +/// +/// Common validation binds the confirmation to the admitted workload and +/// checks backend-neutral properties. `backend_audit` remains opaque to this +/// crate; the registered backend owns its schema and validates it before +/// constructing [`ConfirmedBoundary`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundaryConfirmation { pub generation: String, pub identity: ResolvedWorkloadIdentity, - pub capabilities: CapabilityEvidence, - pub no_new_privileges: bool, - pub sandbox_dumpable: bool, - pub child_dumpable: bool, - pub core_limit_zero: bool, - pub native_architecture: String, - pub kernel_release: String, - pub seccomp: SeccompEvidence, - pub landlock_abi: u32, - pub landlock_allow_deny: bool, - pub udp_dns_round_trip: bool, - pub tcp_dns_round_trip: bool, - pub tcp_allow_round_trip: bool, - pub tcp_deny_round_trip: bool, + pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, - pub driver_fence: DriverFenceEvidence, - /// The driver-owned containment primitive terminates the workload when its + pub outer_fence: OuterFenceGuarantees, + /// The backend-owned containment primitive terminates the workload when its /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, + pub backend_audit: serde_json::Value, } -impl SandboxConfirmEvidence { - /// Validate the security-critical evidence required before launch. +impl BoundaryConfirmation { + /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { - self.driver_fence.validate()?; + self.outer_fence.validate(&self.generation)?; + self.properties.validate()?; let complete = &self.identity == expected - && self.capabilities.is_empty() - && self.no_new_privileges - && !self.sandbox_dumpable - && self.child_dumpable - && self.core_limit_zero - && self.seccomp.new_listener - && self.seccomp.notification_round_trip - && self.seccomp.id_validation - && self.seccomp.addfd_send - && self.seccomp.retained_socket_operation - && self.seccomp.proc_fd_identity - && self.seccomp.task_memory_read - && self.seccomp.task_memory_write - && self.seccomp.cancellation - && self.landlock_abi >= 3 - && self.landlock_allow_deny - && self.udp_dns_round_trip - && self.tcp_dns_round_trip - && self.tcp_allow_round_trip - && self.tcp_deny_round_trip && self.authenticated_supervisor && self.runtime_exit_terminates_workload && !self.generation.is_empty(); @@ -576,41 +552,45 @@ impl SandboxConfirmEvidence { Ok(()) } else { Err(BackendError::Confirm( - "sandbox confirmation evidence is incomplete or mismatched".to_string(), + "boundary confirmation is incomplete or mismatched".to_string(), )) } } } -/// Ready boundary paired with the evidence measured by `confirm`. +/// Ready boundary paired with the confirmation established by `confirm`. pub struct ConfirmedBoundary { boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, } impl ConfirmedBoundary { - /// Construct confirmation after checking measured evidence against the - /// immutable identity admitted at attach time. + /// Construct confirmation after checking backend-neutral properties and + /// immutable identity binding. /// - /// Backend implementations are trusted to collect this evidence and bind - /// it to their resource. This constructor enforces the common requirements - /// without requiring those implementations to live in the interface crate. + /// Backend implementations are trusted to validate their audit evidence and + /// bind this confirmation to their resource. This constructor enforces the + /// common requirements without requiring those implementations to live in + /// the interface crate. /// /// # Errors /// - /// Returns an error if evidence is incomplete or the identity does not match. + /// Returns an error if confirmation is incomplete or the identity does not match. pub fn try_new( boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, expected: &ResolvedWorkloadIdentity, ) -> Result { - evidence.validate(expected)?; - Ok(Self { boundary, evidence }) + confirmation.validate(expected)?; + Ok(Self { + boundary, + confirmation, + }) } - /// Return the measured evidence carried by this confirmed state. - pub fn evidence(&self) -> &SandboxConfirmEvidence { - &self.evidence + /// Return the record carried by this confirmed state. + pub fn confirmation(&self) -> &BoundaryConfirmation { + &self.confirmation } /// Consume confirmation and advance to the sole launch-capable state. @@ -889,6 +869,12 @@ impl From for String { } impl Sha256Digest { + fn compute(bytes: &[u8]) -> Self { + use sha2::{Digest as _, Sha256}; + + Self(Sha256::digest(bytes).into()) + } + /// Return the raw digest bytes. #[must_use] pub fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index a7740d8a7..b09320dc9 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -21,8 +21,10 @@ //! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it //! is confirmed ready. This is enforced *by construction*: each transition //! consumes the prior state by value. Trusted backends construct confirmation -//! through [`contract::ConfirmedBoundary::try_new`], which checks common evidence -//! before the supervisor can obtain a [`contract::ReadyBoundary`]. +//! through [`contract::ConfirmedBoundary::try_new`], which checks common +//! enforcement properties and immutable launch binding before the supervisor +//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays +//! owned by the backend that can interpret it. //! //! [`AgentSpec`] is shared between the workload definition the supervisor //! submits and the [`contract::SandboxContext`] that `attach` binds to a diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203..37abe4cd7 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -239,7 +239,7 @@ impl BoundBoundary for MockBound { async fn confirm(self: Box) -> Result { ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &workload_identity(), ) } @@ -368,80 +368,73 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } -fn confirmation_evidence() -> SandboxConfirmEvidence { - SandboxConfirmEvidence { +fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarantees { + OuterFenceGuarantees::from_enforcement_evidence( + generation, + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + evidence, + ) + .unwrap() +} + +fn confirmation() -> BoundaryConfirmation { + BoundaryConfirmation { generation: "generation-1".to_string(), identity: workload_identity(), - capabilities: CapabilityEvidence { - inheritable: 0, - permitted: 0, - effective: 0, - bounding: 0, - ambient: 0, + properties: BoundaryProperties { + filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"), + egress_interception: EnforcedProperty::new(true, "mock-egress"), + request_attribution: EnforcedProperty::new(true, "mock-attribution"), + privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"), }, - no_new_privileges: true, - sandbox_dumpable: false, - child_dumpable: true, - core_limit_zero: true, - native_architecture: std::env::consts::ARCH.to_string(), - kernel_release: "test".to_string(), - seccomp: SeccompEvidence { - new_listener: true, - notification_round_trip: true, - id_validation: true, - addfd_send: true, - retained_socket_operation: true, - proc_fd_identity: true, - task_memory_read: true, - task_memory_write: true, - cancellation: true, - }, - landlock_abi: 3, - landlock_allow_deny: true, - udp_dns_round_trip: true, - tcp_dns_round_trip: true, - tcp_allow_round_trip: true, - tcp_deny_round_trip: true, authenticated_supervisor: true, session_id: SandboxSessionId::new(), - driver_fence: DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }, + outer_fence: complete_outer_fence("generation-1", b"mock-fence-evidence"), runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), + backend_audit: serde_json::json!({"backend": "mock"}), } } #[test] -fn driver_fence_evidence_is_backend_specific_and_fail_closed() { - let docker = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - let kubernetes = DriverFenceEvidence::Kubernetes { - network_policy_uid: "policy-uid".to_string(), - network_policy_resource_version: "42".to_string(), - ingress_isolated: true, - egress_isolated: true, - egress_rule_count: 0, - }; - let vm = DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }; +fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { + let fence = complete_outer_fence("generation-1", b"native-driver-evidence"); + assert!(fence.validate("generation-1").is_ok()); + assert_ne!( + fence.evidence_digest, + complete_outer_fence("generation-2", b"native-driver-evidence").evidence_digest + ); - assert!(docker.validate().is_ok()); - assert!(kubernetes.validate().is_ok()); - assert!(vm.validate().is_ok()); + let mut wrong_generation = fence.clone(); + wrong_generation.generation = "generation-2".to_string(); + assert!(wrong_generation.validate("generation-1").is_err()); - let drifted = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "bridge".to_string(), - unexpected_networks: vec!["bridge".to_string()], - }; - assert!(drifted.validate().is_err()); + for guarantee in [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ] { + let mut incomplete = fence.clone(); + incomplete.established.remove(&guarantee); + assert!(incomplete.validate("generation-1").is_err()); + } + + assert!(OuterFenceGuarantees::from_enforcement_evidence("", [], b"evidence").is_err()); + assert!(OuterFenceGuarantees::from_enforcement_evidence("generation-1", [], b"").is_err()); + + let unproven = OuterFenceGuarantees::from_enforcement_evidence( + "generation-1", + [OuterFenceGuarantee::DefaultDenyEgress], + b"native-driver-evidence", + ) + .unwrap(); + assert!(unproven.validate("generation-1").is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: @@ -458,7 +451,7 @@ async fn drive( let _ingress = bound.network_mediation_source(); assert_eq!(bound.host_gateway_ip(), None); let confirmed = bound.confirm().await?; - confirmed.evidence().validate(&sandbox_ctx().identity)?; + confirmed.confirmation().validate(&sandbox_ctx().identity)?; confirmed.into_boundary().start_agent().await } @@ -536,12 +529,12 @@ async fn one_driver_runs_both_backends() { } #[test] -fn confirmation_constructor_rejects_incomplete_evidence() { - let mut evidence = confirmation_evidence(); - evidence.seccomp.cancellation = false; +fn confirmation_constructor_rejects_unenforced_property() { + let mut confirmation = confirmation(); + confirmation.properties.egress_interception.enforced = false; let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - evidence, + confirmation, &workload_identity(), ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -559,7 +552,7 @@ fn confirmation_constructor_rejects_another_workload_identity() { .unwrap(); let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &expected, ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -842,16 +835,16 @@ fn workload_identity_rejects_root_and_normalizes_groups() { } #[test] -fn confirmation_evidence_rejects_identity_or_posture_drift() { +fn confirmation_rejects_identity_or_property_drift() { let expected = workload_identity(); - let evidence = confirmation_evidence(); - evidence.validate(&expected).unwrap(); + let baseline = confirmation(); + baseline.validate(&expected).unwrap(); - let mut drifted = confirmation_evidence(); - drifted.capabilities.effective = 1; + let mut drifted = confirmation(); + drifted.properties.privilege_floor.enforced = false; assert!(drifted.validate(&expected).is_err()); - let mut unmanaged = confirmation_evidence(); + let mut unmanaged = confirmation(); unmanaged.runtime_exit_terminates_workload = false; assert!(unmanaged.validate(&expected).is_err()); @@ -863,7 +856,7 @@ fn confirmation_evidence_rejects_identity_or_posture_drift() { "sha256:test".into(), ) .unwrap(); - assert!(evidence.validate(&different).is_err()); + assert!(baseline.validate(&different).is_err()); } // --------------------------------------------------------------------------- diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index bb8be28e6..bf041e554 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -134,20 +134,6 @@ macro_rules! impl_src_endpoint_addr_setter { }; } -/// Generate the `firewall_rule` setter shared by network and HTTP builders. -macro_rules! impl_firewall_rule_setter { - ($builder:ident) => { - impl<'a> $builder<'a> { - /// Set the firewall rule that matched this event. - #[must_use] - pub fn firewall_rule(mut self, name: &str, rule_type: &str) -> Self { - self.firewall_rule = Some($crate::objects::FirewallRule::new(name, rule_type)); - self - } - } - }; -} - mod api_activity; mod base; mod config; diff --git a/crates/openshell-ocsf/src/builders/network.rs b/crates/openshell-ocsf/src/builders/network.rs index 8f405aba4..001100abc 100644 --- a/crates/openshell-ocsf/src/builders/network.rs +++ b/crates/openshell-ocsf/src/builders/network.rs @@ -10,7 +10,7 @@ use crate::events::{NetworkActivityEvent, OcsfEvent}; use crate::objects::{Actor, ConnectionInfo, Endpoint, FirewallRule}; /// Builder for Network Activity [4001] events. -pub struct NetworkActivityBuilder<'a> { +pub struct NetworkActivityBuilder<'a, EndpointState = MissingNetworkEndpoint> { ctx: &'a EventContext, activity: ActivityId, activity_name: Option, @@ -28,10 +28,35 @@ pub struct NetworkActivityBuilder<'a> { status_detail: Option, unmapped: Option>, log_source: Option, + endpoint_state: std::marker::PhantomData, } -impl<'a> NetworkActivityBuilder<'a> { +/// Marker for a Network Activity builder without an endpoint. +pub struct MissingNetworkEndpoint; + +/// Marker for a Network Activity builder with a source or destination endpoint. +pub struct HasNetworkEndpoint; + +impl<'a> NetworkActivityBuilder<'a, MissingNetworkEndpoint> { /// Start building a Network Activity event. + /// + /// A Network Activity must include a source or destination endpoint before + /// it can be built. + /// + /// ```compile_fail + /// use openshell_ocsf::{EventContext, NetworkActivityBuilder}; + /// + /// let ctx = EventContext { + /// sandbox_id: String::new(), + /// sandbox_name: String::new(), + /// container_image: String::new(), + /// hostname: String::new(), + /// product_version: String::new(), + /// proxy_ip: "127.0.0.1".parse().unwrap(), + /// proxy_port: 3128, + /// }; + /// NetworkActivityBuilder::new(&ctx).build(); + /// ``` #[must_use] pub fn new(ctx: &'a EventContext) -> Self { Self { @@ -52,6 +77,92 @@ impl<'a> NetworkActivityBuilder<'a> { status_detail: None, unmapped: None, log_source: None, + endpoint_state: std::marker::PhantomData, + } + } + + #[must_use] + pub fn src_endpoint_addr( + self, + ip: std::net::IpAddr, + port: u16, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + self.with_src_endpoint(Endpoint::from_ip(ip, port)) + } + + #[must_use] + pub fn dst_endpoint( + self, + endpoint: Endpoint, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + self.with_dst_endpoint(endpoint) + } +} + +impl NetworkActivityBuilder<'_, HasNetworkEndpoint> { + #[must_use] + pub fn src_endpoint_addr(mut self, ip: std::net::IpAddr, port: u16) -> Self { + self.src_endpoint = Some(Endpoint::from_ip(ip, port)); + self + } + + #[must_use] + pub fn dst_endpoint(mut self, endpoint: Endpoint) -> Self { + self.dst_endpoint = Some(endpoint); + self + } +} + +impl<'a, EndpointState> NetworkActivityBuilder<'a, EndpointState> { + fn with_src_endpoint( + self, + endpoint: Endpoint, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + NetworkActivityBuilder { + ctx: self.ctx, + activity: self.activity, + activity_name: self.activity_name, + action: self.action, + disposition: self.disposition, + severity: self.severity, + status: self.status, + src_endpoint: Some(endpoint), + dst_endpoint: self.dst_endpoint, + actor: self.actor, + firewall_rule: self.firewall_rule, + connection_info: self.connection_info, + observation_point_id: self.observation_point_id, + message: self.message, + status_detail: self.status_detail, + unmapped: self.unmapped, + log_source: self.log_source, + endpoint_state: std::marker::PhantomData, + } + } + + fn with_dst_endpoint( + self, + endpoint: Endpoint, + ) -> NetworkActivityBuilder<'a, HasNetworkEndpoint> { + NetworkActivityBuilder { + ctx: self.ctx, + activity: self.activity, + activity_name: self.activity_name, + action: self.action, + disposition: self.disposition, + severity: self.severity, + status: self.status, + src_endpoint: self.src_endpoint, + dst_endpoint: Some(endpoint), + actor: self.actor, + firewall_rule: self.firewall_rule, + connection_info: self.connection_info, + observation_point_id: self.observation_point_id, + message: self.message, + status_detail: self.status_detail, + unmapped: self.unmapped, + log_source: self.log_source, + endpoint_state: std::marker::PhantomData, } } @@ -90,6 +201,56 @@ impl<'a> NetworkActivityBuilder<'a> { self } + #[must_use] + pub fn activity(mut self, id: ActivityId) -> Self { + self.activity = id; + self + } + + #[must_use] + pub fn action(mut self, id: ActionId) -> Self { + self.action = Some(id); + self + } + + #[must_use] + pub fn disposition(mut self, id: DispositionId) -> Self { + self.disposition = Some(id); + self + } + + #[must_use] + pub fn actor_process(mut self, process: crate::objects::Process) -> Self { + self.actor = Some(Actor { process }); + self + } + + #[must_use] + pub fn firewall_rule(mut self, name: &str, rule_type: &str) -> Self { + self.firewall_rule = Some(FirewallRule::new(name, rule_type)); + self + } + + #[must_use] + pub fn severity(mut self, id: SeverityId) -> Self { + self.severity = id; + self + } + + #[must_use] + pub fn status(mut self, id: StatusId) -> Self { + self.status = Some(id); + self + } + + #[must_use] + pub fn message(mut self, msg: impl Into) -> Self { + self.message = Some(msg.into()); + self + } +} + +impl NetworkActivityBuilder<'_, HasNetworkEndpoint> { /// Finalize and return the `OcsfEvent`. #[must_use] pub fn build(self) -> OcsfEvent { @@ -139,14 +300,6 @@ impl<'a> NetworkActivityBuilder<'a> { } } -impl_activity_setter!(NetworkActivityBuilder); -impl_action_disposition_setters!(NetworkActivityBuilder); -impl_actor_process_setter!(NetworkActivityBuilder); -impl_dst_endpoint_setter!(NetworkActivityBuilder); -impl_src_endpoint_addr_setter!(NetworkActivityBuilder); -impl_firewall_rule_setter!(NetworkActivityBuilder); -impl_builder_setters!(NetworkActivityBuilder); - #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-ocsf/tests/event_identity.rs b/crates/openshell-ocsf/tests/event_identity.rs index 34a3868ca..6b5ad32e0 100644 --- a/crates/openshell-ocsf/tests/event_identity.rs +++ b/crates/openshell-ocsf/tests/event_identity.rs @@ -5,7 +5,9 @@ use std::net::{IpAddr, Ipv4Addr}; -use openshell_ocsf::{ActivityId, EventContext, NetworkActivityBuilder, OcsfEvent, SeverityId}; +use openshell_ocsf::{ + ActivityId, Endpoint, EventContext, NetworkActivityBuilder, OcsfEvent, SeverityId, +}; fn sandbox_ctx(container_image: &str) -> EventContext { EventContext { @@ -23,6 +25,7 @@ fn event(ctx: &EventContext) -> OcsfEvent { NetworkActivityBuilder::new(ctx) .activity(ActivityId::Open) .severity(SeverityId::Medium) + .dst_endpoint(Endpoint::from_domain("api.example.com", 443)) .message("CONNECT api.example.com:443") .build() } diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 05542fb2b..c70605662 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -23,8 +23,9 @@ use openshell_core::policy::{ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal, - DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, + BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, + BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, OuterFenceGuarantees, + ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -42,6 +43,145 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Capability masks measured from `/proc//status` by the `OpenShell` +/// co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityEvidence { + pub inheritable: u64, + pub permitted: u64, + pub effective: u64, + pub bounding: u64, + pub ambient: u64, +} + +impl CapabilityEvidence { + #[must_use] + pub const fn is_empty(self) -> bool { + self.inheritable == 0 + && self.permitted == 0 + && self.effective == 0 + && self.bounding == 0 + && self.ambient == 0 + } +} + +/// Active seccomp notification and socket-broker measurements specific to the +/// `OpenShell` co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "each independently measured kernel operation is reported explicitly" +)] +pub struct SeccompEvidence { + pub new_listener: bool, + pub notification_round_trip: bool, + pub id_validation: bool, + pub addfd_send: bool, + pub retained_socket_operation: bool, + pub proc_fd_identity: bool, + pub task_memory_read: bool, + pub task_memory_write: bool, + pub cancellation: bool, +} + +/// Mechanism-specific audit evidence for the native Linux sandbox adapter. +/// +/// This schema belongs to this backend rather than the generic isolation +/// interface. The host-side backend validates it before constructing a +/// backend-neutral `ConfirmedBoundary`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct NativeLinuxSandboxAuditEvidence { + pub capabilities: CapabilityEvidence, + pub no_new_privileges: bool, + pub sandbox_dumpable: bool, + pub child_dumpable: bool, + pub core_limit_zero: bool, + pub native_architecture: String, + pub kernel_release: String, + pub seccomp: SeccompEvidence, + pub landlock_abi: u32, + pub landlock_allow_deny: bool, + pub udp_dns_round_trip: bool, + pub tcp_dns_round_trip: bool, + pub tcp_allow_round_trip: bool, + pub tcp_deny_round_trip: bool, +} + +impl NativeLinuxSandboxAuditEvidence { + /// Validate the complete mechanism-specific posture required by this backend. + pub fn validate(&self) -> Result<(), BackendError> { + let complete = self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero + && !self.native_architecture.is_empty() + && !self.kernel_release.is_empty() + && self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.id_validation + && self.seccomp.addfd_send + && self.seccomp.retained_socket_operation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write + && self.seccomp.cancellation + && self.landlock_abi >= 3 + && self.landlock_allow_deny + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "native Linux sandbox audit evidence is incomplete".to_string(), + )) + } + } + + /// Project backend measurements into the common property contract. + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.landlock_abi >= 3 && self.landlock_allow_deny, + format!("landlock-v{}", self.landlock_abi), + ), + egress_interception: EnforcedProperty::new( + self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.addfd_send + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip, + "seccomp-notify", + ), + request_attribution: EnforcedProperty::new( + self.seccomp.id_validation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write, + "seccomp-notify-procfs", + ), + privilege_floor: EnforcedProperty::new( + self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero, + "linux-capability-free", + ), + } + } +} + /// Ephemeral identity of the supervisor process that owns one sandbox runtime. /// /// The supervisor generates this value in memory and presents it on every @@ -286,8 +426,8 @@ pub struct SandboxRuntimeDescriptor { /// example pod UID, VM generation, or container ID). #[serde(default)] pub resource_claims: std::collections::BTreeMap, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the validated outer network fence. + pub outer_fence: OuterFenceGuarantees, } impl fmt::Debug for SandboxRuntimeDescriptor { @@ -301,7 +441,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) .field("resource_claims", &self.resource_claims) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .finish() } } @@ -354,8 +494,8 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the validated outer network fence. + pub outer_fence: OuterFenceGuarantees, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -383,7 +523,7 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } @@ -712,8 +852,9 @@ pub enum Response { snapshot: SessionSnapshotWire, }, Confirmed { - /// Measured capability-free posture produced before workload launch. - evidence: Box, + /// Backend-neutral properties and backend-owned audit evidence produced + /// before workload launch. + confirmation: Box, }, Started { process_id: String, @@ -1196,6 +1337,61 @@ pub enum FrameError { mod tests { use super::*; + fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { + NativeLinuxSandboxAuditEvidence { + capabilities: CapabilityEvidence { + inheritable: 0, + permitted: 0, + effective: 0, + bounding: 0, + ambient: 0, + }, + no_new_privileges: true, + sandbox_dumpable: false, + child_dumpable: true, + core_limit_zero: true, + native_architecture: "x86_64".to_string(), + kernel_release: "6.12.0".to_string(), + seccomp: SeccompEvidence { + new_listener: true, + notification_round_trip: true, + id_validation: true, + addfd_send: true, + retained_socket_operation: true, + proc_fd_identity: true, + task_memory_read: true, + task_memory_write: true, + cancellation: true, + }, + landlock_abi: 6, + landlock_allow_deny: true, + udp_dns_round_trip: true, + tcp_dns_round_trip: true, + tcp_allow_round_trip: true, + tcp_deny_round_trip: true, + } + } + + #[test] + fn native_linux_audit_evidence_projects_backend_neutral_properties() { + let audit = complete_audit_evidence(); + audit.validate().unwrap(); + let properties = audit.properties(); + assert!(properties.filesystem_confinement.enforced); + assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6"); + assert!(properties.egress_interception.enforced); + assert!(properties.request_attribution.enforced); + assert!(properties.privilege_floor.enforced); + } + + #[test] + fn native_linux_audit_evidence_rejects_mechanism_failure() { + let mut audit = complete_audit_evidence(); + audit.seccomp.addfd_send = false; + assert!(audit.validate().is_err()); + assert!(!audit.properties().egress_interception.enforced); + } + #[test] fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() { for encoded in [ diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 1b56d6df9..16840ca57 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -116,7 +116,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; - let driver_fence = runtime_descriptor.driver_fence.clone(); + let outer_fence = runtime_descriptor.outer_fence.clone(); let client = Arc::new(BoundaryClient::new( runtime_descriptor, self.sandbox_bearer.clone(), @@ -149,7 +149,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { generation, session_id, resource_claims, - driver_fence, + outer_fence, })) } } @@ -181,7 +181,9 @@ fn validate_runtime_descriptor( )); } validate_resource_claims(&runtime_descriptor.resource_claims)?; - runtime_descriptor.driver_fence.validate()?; + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation)?; match &runtime_descriptor.transport { SandboxTransport::Unix { socket_path } => { validate_socket_path(socket_path)?; @@ -292,7 +294,7 @@ struct RemoteBound { generation: String, session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, - driver_fence: openshell_isolation_interface::contract::DriverFenceEvidence, + outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, } #[async_trait] @@ -307,21 +309,34 @@ impl BoundBoundary for RemoteBound { async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; - let Response::Confirmed { evidence } = response else { - return Err(unexpected_response("confirmed_with_evidence", &response)); + let Response::Confirmed { confirmation } = response else { + return Err(unexpected_response("confirmed", &response)); }; - if evidence.generation != self.generation - || evidence.session_id != self.session_id - || evidence.resource_claims != self.resource_claims - || evidence.driver_fence != self.driver_fence + if confirmation.generation != self.generation + || confirmation.session_id != self.session_id + || confirmation.resource_claims != self.resource_claims + || confirmation.outer_fence != self.outer_fence { return Err(BackendError::Confirm( - "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" + "sandbox confirmation generation, session, resource claims, or outer fence do not match runtime descriptor" .to_string(), )); } - self.client.start_credential_monitor(); - ConfirmedBoundary::try_new( + let audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { + BackendError::Confirm(format!( + "decode native Linux sandbox audit evidence: {error}" + )) + })?; + audit.validate()?; + if confirmation.properties != audit.properties() { + return Err(BackendError::Confirm( + "sandbox confirmation properties do not match native Linux audit evidence" + .to_string(), + )); + } + let client = self.client.clone(); + let confirmed = ConfirmedBoundary::try_new( Box::new(RemoteReady { client: self.client, agent: self.agent, @@ -330,9 +345,11 @@ impl BoundBoundary for RemoteBound { ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, }), - *evidence, + *confirmation, &self.identity, - ) + )?; + client.start_credential_monitor(); + Ok(confirmed) } } @@ -1924,11 +1941,20 @@ mod tests { FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "test-generation".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + use openshell_isolation_interface::contract::OuterFenceGuarantee; + + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + "test-generation", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + b"test-vm-fence", + ) + .unwrap() } #[tokio::test] @@ -1952,6 +1978,7 @@ mod tests { mediation_failures: Arc, mediation_ready: bool, provider_environment_generation: u64, + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, } type TestGrpcStream = Pin< @@ -1981,6 +2008,7 @@ mod tests { let requests = self.requests.clone(); let mediation_ready = self.mediation_ready; let provider_environment_generation = self.provider_environment_generation; + let confirmation = self.confirmation.clone(); let (outbound, outbound_rx) = tokio::sync::mpsc::channel(1); tokio::spawn(async move { let mut frame = Vec::new(); @@ -2024,7 +2052,7 @@ mod tests { }, }, Request::Confirm => Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(confirmation), }, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { @@ -2129,6 +2157,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -2193,6 +2222,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; tokio::spawn(async move { tonic::transport::Server::builder() @@ -2286,6 +2316,7 @@ mod tests { mediation_failures: server_failures.clone(), mediation_ready: true, provider_environment_generation: 0, + confirmation: test_confirmation(), }; tokio::spawn(async move { tonic::transport::Server::builder() @@ -2340,6 +2371,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -2467,7 +2499,7 @@ mod tests { tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), } } @@ -2500,12 +2532,25 @@ mod tests { else { return; }; - serve_test_grpc(Box::new(stream), expected_token).await; + serve_test_grpc_with_confirmation( + Box::new(stream), + expected_token, + test_confirmation(), + ) + .await; }); (address, task) } async fn serve_test_grpc(stream: BoundaryDuplexStream, expected_token: String) { + serve_test_grpc_with_confirmation(stream, expected_token, test_confirmation()).await; + } + + async fn serve_test_grpc_with_confirmation( + stream: BoundaryDuplexStream, + expected_token: String, + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + ) { let service = TestGrpcBoundary { wait_for_half_close: false, expected_token, @@ -2513,6 +2558,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation, }; tonic::transport::Server::builder() .add_service(IsolationBoundaryServer::new(service)) @@ -2552,12 +2598,9 @@ mod tests { } } - fn test_confirmation_evidence() - -> openshell_isolation_interface::contract::SandboxConfirmEvidence { - openshell_isolation_interface::contract::SandboxConfirmEvidence { - generation: "test-generation".to_string(), - identity: sandbox().identity, - capabilities: openshell_isolation_interface::contract::CapabilityEvidence { + fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { + let audit = crate::boundary_protocol::NativeLinuxSandboxAuditEvidence { + capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, effective: 0, @@ -2570,7 +2613,7 @@ mod tests { core_limit_zero: true, native_architecture: std::env::consts::ARCH.to_string(), kernel_release: "test".to_string(), - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: crate::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -2587,14 +2630,124 @@ mod tests { tcp_dns_round_trip: true, tcp_allow_round_trip: true, tcp_deny_round_trip: true, + }; + openshell_isolation_interface::contract::BoundaryConfirmation { + generation: "test-generation".to_string(), + identity: sandbox().identity, + properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), + backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), } } + async fn assert_remote_confirmation_rejected( + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + ) { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind confirmation test server"); + let address = listener.local_addr().expect("confirmation test address"); + let expected_token = "a".repeat(32); + let server_config = certificate.server_config; + let server_token = expected_token.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept confirmation client"); + let stream = tokio_rustls::TlsAcceptor::from(server_config) + .accept(stream) + .await + .expect("accept confirmation TLS"); + serve_test_grpc_with_confirmation(Box::new(stream), server_token, confirmation).await; + }); + + let descriptor = tls_runtime_descriptor(address, certificate.client_tls); + let outer_fence = descriptor.outer_fence.clone(); + let context = sandbox(); + let client = Arc::new(BoundaryClient::new( + descriptor, + test_bearer(&expected_token), + )); + let bound = RemoteBound { + client: client.clone(), + agent: context.agent, + policy: context.policy, + sandbox_id: context.sandbox_id, + mediation: Arc::new(RemoteNetworkMediation { + client: client.clone(), + }), + host_gateway_ip: None, + ca_file_paths: Arc::new(std::sync::Mutex::new(None)), + provider_credentials: + openshell_core::provider_credentials::ProviderCredentialState::from_environment( + 0, + HashMap::new(), + HashMap::new(), + HashMap::new(), + ), + identity: context.identity, + generation: "test-generation".to_string(), + session_id: test_session_id(), + resource_claims: std::collections::BTreeMap::new(), + outer_fence, + }; + + assert!(matches!( + Box::new(bound).confirm().await, + Err(BackendError::Confirm(_)) + )); + assert!( + !client.credential_monitor_started.load(Ordering::Acquire), + "credential monitoring must start only after confirmation succeeds" + ); + server.abort(); + } + + #[tokio::test] + async fn remote_confirm_rejects_invalid_native_audit_before_monitoring() { + let mut confirmation = test_confirmation(); + let mut audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).expect("decode test audit"); + audit.seccomp.notification_round_trip = false; + confirmation.backend_audit = serde_json::to_value(audit).expect("encode test audit"); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_property_projection_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + confirmation.properties.egress_interception.mechanism = "untrusted projection".to_string(); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_outer_fence_generation_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + confirmation.outer_fence.generation = "other-generation".to_string(); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_outer_fence_digest_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + let different_fence = + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + "test-generation", + confirmation.outer_fence.established.iter().copied(), + b"different evidence", + ) + .expect("construct different test evidence"); + confirmation.outer_fence.evidence_digest = different_fence.evidence_digest; + + assert_remote_confirmation_rejected(confirmation).await; + } + #[test] fn runtime_descriptor_debug_redacts_trust_anchor() { let certificate = test_certificate(); @@ -2609,7 +2762,7 @@ mod tests { tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; let debug = format!("{runtime_descriptor:?}"); assert!(debug.contains("")); @@ -2629,7 +2782,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2651,7 +2804,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2673,7 +2826,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; validate_runtime_descriptor(&runtime_descriptor, &sandbox()) .expect("TCP runtime descriptor should be valid"); @@ -2709,7 +2862,7 @@ mod tests { .await .expect("TLS request"), Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } ); server.abort(); @@ -2833,6 +2986,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { loop { @@ -2867,7 +3021,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), )); @@ -2904,6 +3058,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 50, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -3058,7 +3213,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), ); diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs index 9e82bfe0e..df40832d4 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs @@ -66,6 +66,7 @@ async fn renewing_boundary_client( mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }, expected_token: Arc::new(std::sync::RwLock::new("a".repeat(32))), failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs index d131c3ffa..0ad9c7b44 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -119,7 +119,7 @@ impl NetworkPeer { assert!(self.attached.load(Ordering::Acquire)); *self.state.active.lock().unwrap() = Some(self.connection); Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } } Request::AcceptNetwork => { diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 5452ea6ef..f4bb815f9 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -36,9 +36,8 @@ mod linux { }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, - CapabilityEvidence, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, - SandboxConfirmEvidence, + BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, + BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::mediation::{ @@ -60,11 +59,11 @@ mod linux { use openshell_sandbox_backend::boundary_protocol::{ AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire, - ExitStatusWire, MediationTimingWire, OutputWindowWire, ProcessKindWire, - ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, - STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, - SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, read_frame, - read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, + ExitStatusWire, MediationTimingWire, NativeLinuxSandboxAuditEvidence, OutputWindowWire, + ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, + STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, + STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, + read_frame, read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, }; const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30); @@ -304,8 +303,8 @@ mod linux { } validate_resource_claims(&config.resource_claims).map_err(|error| error.to_string())?; config - .driver_fence - .validate() + .outer_fence + .validate(&config.generation) .map_err(|error| error.to_string())?; for (claim, path) in &config.resource_claim_files { if !config.resource_claims.contains_key(claim) { @@ -2262,20 +2261,20 @@ mod linux { if let Err(error) = prepared.confirm(&self.process_runtime) { return guest_error(BoundaryErrorKind::Process, error); } - let evidence = match self.measure_confirmation_evidence() { - Ok(evidence) => evidence, + let confirmation = match self.measure_confirmation() { + Ok(confirmation) => confirmation, Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; *state = RuntimeState::Ready(prepared.clone()); Response::Confirmed { - evidence: Box::new(evidence), + confirmation: Box::new(confirmation), } } RuntimeState::Ready(_) | RuntimeState::Running(_) => { - self.measure_confirmation_evidence().map_or_else( + self.measure_confirmation().map_or_else( |error| guest_error(BoundaryErrorKind::Process, error), - |evidence| Response::Confirmed { - evidence: Box::new(evidence), + |confirmation| Response::Confirmed { + confirmation: Box::new(confirmation), }, ) } @@ -2286,7 +2285,7 @@ mod linux { } } - fn measure_confirmation_evidence(&self) -> Result { + fn measure_confirmation(&self) -> Result { validate_running_identity( &self.config.workload_identity, allows_runtime_supplementary_groups(&self.config), @@ -2299,7 +2298,7 @@ mod linux { } let status = std::fs::read_to_string("/proc/self/status") .map_err(|error| format!("read sandbox process status: {error}"))?; - let capabilities = CapabilityEvidence { + let capabilities = openshell_sandbox_backend::boundary_protocol::CapabilityEvidence { inheritable: parse_status_hex(&status, "CapInh")?, permitted: parse_status_hex(&status, "CapPrm")?, effective: parse_status_hex(&status, "CapEff")?, @@ -2320,9 +2319,7 @@ mod linux { // SAFETY: successful getrlimit initialized the value. let core_limit = unsafe { core_limit.assume_init() }; let (native_architecture, kernel_release) = uname_values()?; - Ok(SandboxConfirmEvidence { - generation: self.config.generation.clone(), - identity: self.config.workload_identity.clone(), + let audit = NativeLinuxSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, @@ -2337,11 +2334,24 @@ mod linux { tcp_dns_round_trip: self.qualification.tcp_dns_round_trip, tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, + }; + // The boundary reports mechanism evidence; the authenticated host + // backend validates it before constructing a ConfirmedBoundary. + // Keeping that decision at the verifier also lets lifecycle tests + // exercise the protocol without claiming host-kernel enforcement. + let properties = audit.properties(); + let backend_audit = serde_json::to_value(audit) + .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, authenticated_supervisor: true, session_id: self.config.session_id, - driver_fence: self.config.driver_fence.clone(), + outer_fence: self.config.outer_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), + backend_audit, }) } @@ -3705,7 +3715,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; let debug = format!("{config:?}"); @@ -3856,7 +3866,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4258,16 +4268,25 @@ mod linux { .unwrap() } - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + use openshell_isolation_interface::contract::OuterFenceGuarantee; + + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + "generation-1", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + b"test-vm-fence", + ) + .unwrap() } fn test_runtime_qualification() -> crate::RuntimeQualification { crate::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -4410,7 +4429,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4445,7 +4464,7 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4485,7 +4504,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4660,7 +4679,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -4978,7 +4997,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4928fe6f4..4166d8d2c 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -34,7 +34,7 @@ pub mod sandbox; reason = "qualification preserves independently exercised security results" )] pub struct RuntimeQualification { - pub seccomp: openshell_isolation_interface::contract::SeccompEvidence, + pub seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence, pub landlock_abi: u32, pub landlock_allow_deny: bool, pub udp_dns_round_trip: bool, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab1..dbdc8733e 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -239,7 +239,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi wait_killable_recv: notification.wait_killable_recv, }; let qualification = openshell_sandbox::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: notification.notification_round_trip(), notification_round_trip: notification.notification_round_trip(), id_validation: notification.notification_round_trip(), diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index f3da87aee..81d0b1630 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -25,7 +25,7 @@ //! The risk is contained by existing sandbox layers: //! - **Privilege drop**: `CAP_NET_ADMIN` is not granted, so all write operations //! (add/delete routes, addresses, interfaces) fail with `EPERM` regardless. -//! - **Driver outer fence**: direct workload egress is rejected outside this +//! - **Outer network fence**: direct workload egress is rejected outside this //! process by Docker network-none, Kubernetes `NetworkPolicy`, or a NIC-less //! VM. //! diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 0d6dd04a6..2ec0fd70b 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -55,6 +55,11 @@ integers where practical. Reusable template resources are exposed as portable workload shape and driver config. Failures map to a typed `SdkError` with a discriminable kind. +Set `SandboxSpec::service_exposures` to register named or unnamed loopback HTTP +services during creation. Each `ServiceExposure` contains a service name and a +target port; an empty name selects the unnamed endpoint. The returned +`SandboxRef::service_urls` map contains each routed URL under the same name. + Curated calls without a workspace argument explicitly select the `default` workspace. Cross-workspace listing uses the separate `*_all_workspaces` methods and requires Platform Admin access. diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index b9c98683b..b019491d6 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -144,7 +144,7 @@ impl OpenShellClient { async move { grpc.create_sandbox(request).await } }) .await?; - sandbox_from_response(response.sandbox) + sandbox_from_create_response(response) } /// Create a new sandbox from a workspace-scoped workload template name. @@ -159,7 +159,7 @@ impl OpenShellClient { async move { grpc.create_sandbox(request).await } }) .await?; - sandbox_from_response(response.sandbox) + sandbox_from_create_response(response) } /// Create a reusable sandbox template in the default workspace. @@ -774,7 +774,7 @@ impl WorkspaceScopedClient { async move { grpc.create_sandbox(request).await } }) .await?; - sandbox_from_response(response.sandbox) + sandbox_from_create_response(response) } /// Create a new sandbox from a template in this workspace. @@ -791,7 +791,7 @@ impl WorkspaceScopedClient { async move { grpc.create_sandbox(request).await } }) .await?; - sandbox_from_response(response.sandbox) + sandbox_from_create_response(response) } /// Create a reusable sandbox template in this workspace. @@ -1178,6 +1178,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { gpu, command, tty, + service_exposures, } = spec; let template = image.map(|image| proto::SandboxTemplate { image, @@ -1203,6 +1204,13 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { workspace_scope: Some(proto::workspace_selector("default")), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: service_exposures + .into_iter() + .map(|exposure| proto::SandboxServiceExposure { + service: exposure.service, + target_port: u32::from(exposure.target_port), + }) + .collect(), } } @@ -1217,6 +1225,7 @@ fn create_sandbox_from_template_request( command, tty, policy, + service_exposures, } = spec; proto::CreateSandboxRequest { request_id: String::new(), @@ -1233,6 +1242,13 @@ fn create_sandbox_from_template_request( workspace_scope: Some(proto::workspace_selector("default")), workload_template: template_name, await_main_process_attachment: false, + service_exposures: service_exposures + .into_iter() + .map(|exposure| proto::SandboxServiceExposure { + service: exposure.service, + target_port: u32::from(exposure.target_port), + }) + .collect(), } } @@ -1242,6 +1258,12 @@ fn sandbox_from_response(sandbox: Option) -> Result .ok_or_else(|| SdkError::invalid_config("sandbox missing from gateway response")) } +fn sandbox_from_create_response(response: proto::SandboxResponse) -> Result { + let mut sandbox = sandbox_from_response(response.sandbox)?; + sandbox.service_urls = response.service_urls; + Ok(sandbox) +} + fn sandbox_template_from_response( template: Option, ) -> Result { diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index 1082ff108..d054e4e7a 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -56,5 +56,5 @@ pub use types::{ SandboxPhase, SandboxRef, SandboxResources, SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec, SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, - ServiceStatus, WorkspaceRef, + ServiceExposure, ServiceStatus, WorkspaceRef, }; diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 3937462d0..d09eaabf1 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -161,6 +161,17 @@ pub struct SandboxSpec { pub command: Vec, /// Allocate a retained pseudo-terminal for the canonical command. pub tty: bool, + /// Loopback HTTP services to expose when the sandbox is created. + pub service_exposures: Vec, +} + +/// A loopback HTTP service to expose during sandbox creation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ServiceExposure { + /// Service name. Empty selects the sandbox's unnamed endpoint. + pub service: String, + /// Loopback TCP port inside the sandbox. + pub target_port: u16, } /// Caller intent for creating a sandbox from a named workload template. @@ -178,6 +189,8 @@ pub struct SandboxTemplateCreateSpec { pub command: Vec, /// Allocate a retained pseudo-terminal for the canonical command. pub tty: bool, + /// Loopback HTTP services to expose when the sandbox is created. + pub service_exposures: Vec, /// Create-time sandbox policy. The named workload template supplies runtime /// workload fields; policy remains part of the sandbox's governance spec. pub policy: Option, @@ -227,6 +240,9 @@ pub struct SandboxRef { pub resource_version: u64, pub exit_code: Option, pub created_from_workload_template: Option, + /// Service URLs returned by sandbox creation, keyed by service name. The + /// empty key identifies the unnamed service. Non-create reads leave this empty. + pub service_urls: HashMap, } /// Reusable workload template revision used to create a sandbox. @@ -258,6 +274,7 @@ impl SandboxRef { resource_version: meta.resource_version, exit_code, created_from_workload_template, + service_urls: HashMap::new(), } } } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 4eab8e231..4051cd513 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -13,7 +13,7 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_sdk::{ AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError, RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec, - SandboxTemplateListOptions, ServiceStatus as SdkServiceStatus, + SandboxTemplateListOptions, ServiceExposure, ServiceStatus as SdkServiceStatus, }; use std::collections::HashMap; use std::sync::Arc; @@ -161,6 +161,27 @@ fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloa #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -224,6 +245,7 @@ impl OpenShell for TestOpenShell { status: proto::ServiceStatus::Healthy.into(), gateway_version: "test-1.2.3".to_string(), compute_drivers: Vec::new(), + extensions: Vec::new(), })) } @@ -246,9 +268,20 @@ impl OpenShell for TestOpenShell { } else { req.name.clone() }; + let service_urls = req + .service_exposures + .iter() + .map(|exposure| { + ( + exposure.service.clone(), + format!("https://{}.example.test/", exposure.service), + ) + }) + .collect(); *self.state.last_create.lock().await = Some(req); Ok(Response::new(proto::SandboxResponse { sandbox: Some(sandbox_with_phase(&name, proto::SandboxPhase::Provisioning)), + service_urls, })) } @@ -318,6 +351,7 @@ impl OpenShell for TestOpenShell { *self.state.last_stop.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -334,6 +368,7 @@ impl OpenShell for TestOpenShell { *self.state.last_start.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -374,6 +409,7 @@ impl OpenShell for TestOpenShell { } Ok(Response::new(proto::SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -549,6 +585,16 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("unused")) } + type PeerRelayStream = + tokio_stream::wrappers::ReceiverStream>; + + async fn peer_relay( + &self, + _: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + type ExecSandboxInteractiveStream = tokio_stream::wrappers::ReceiverStream>; @@ -983,17 +1029,28 @@ async fn create_sandbox_passes_spec_through() { image: Some("ghcr.io/foo:bar".to_string()), labels: labels.clone(), gpu: true, + service_exposures: vec![ServiceExposure { + service: "web".to_string(), + target_port: 8080, + }], ..Default::default() }; let result = client.create_sandbox(spec).await.unwrap(); assert_eq!(result.name, "my-box"); assert_eq!(result.phase, SandboxPhase::Provisioning); + assert_eq!( + result.service_urls.get("web").map(String::as_str), + Some("https://web.example.test/") + ); let observed = state.last_create.lock().await.clone().unwrap(); assert_eq!(observed.name, "my-box"); assert_eq!(observed.labels, labels); assert!(observed.annotations.is_empty()); + assert_eq!(observed.service_exposures.len(), 1); + assert_eq!(observed.service_exposures[0].service, "web"); + assert_eq!(observed.service_exposures[0].target_port, 8080); let observed_spec = observed.spec.unwrap(); assert!( observed_spec diff --git a/crates/openshell-server/src/auth/descriptor_authz.rs b/crates/openshell-server/src/auth/descriptor_authz.rs index baea879bb..7558d14b1 100644 --- a/crates/openshell-server/src/auth/descriptor_authz.rs +++ b/crates/openshell-server/src/auth/descriptor_authz.rs @@ -123,6 +123,7 @@ impl DescriptorAuthTable { "sandbox" => AuthMode::Sandbox, "bearer" => AuthMode::Bearer, "dual" => AuthMode::Dual, + "peer" => AuthMode::Peer, other => { return Err(format!("method {path}: unknown auth_mode '{other}'")); } diff --git a/crates/openshell-server/src/auth/guard.rs b/crates/openshell-server/src/auth/guard.rs index edcd6bc01..a3d55eac7 100644 --- a/crates/openshell-server/src/auth/guard.rs +++ b/crates/openshell-server/src/auth/guard.rs @@ -30,6 +30,9 @@ use tracing::info; pub fn ensure_sandbox_scope(principal: &Principal, claimed_sandbox_id: &str) -> Result<(), Status> { match principal { Principal::User(_) => Ok(()), + Principal::Peer(_) => Err(Status::permission_denied( + "gateway peer principals may not call sandbox-scoped methods", + )), Principal::Sandbox(p) => { if p.sandbox_id == claimed_sandbox_id { Ok(()) @@ -84,7 +87,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::Peer(_) => Err(Status::permission_denied( "supervisor RPCs require a sandbox principal", )), Principal::Anonymous => Err(Status::unauthenticated( diff --git a/crates/openshell-server/src/auth/method_authz.rs b/crates/openshell-server/src/auth/method_authz.rs index 92d3b0a56..06f6784cc 100644 --- a/crates/openshell-server/src/auth/method_authz.rs +++ b/crates/openshell-server/src/auth/method_authz.rs @@ -22,6 +22,8 @@ pub enum AuthMode { /// Either sandbox principal or Bearer; scope and role apply on /// the Bearer path only. Dual, + /// Only callable by a gateway peer principal. + Peer, } /// Coarse role mapping. Maps to the configured `admin_role` / @@ -86,11 +88,17 @@ 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::Unauthenticated | AuthMode::Peer) => false, Some(AuthMode::Bearer | AuthMode::Dual) | None => true, } } +/// `true` if the method is callable by a gateway peer. +#[must_use] +pub fn is_peer_callable(method: &str) -> bool { + matches!(lookup(method).map(|m| m.auth_mode), Some(AuthMode::Peer)) +} + #[cfg(test)] mod tests { use super::*; @@ -142,6 +150,15 @@ mod tests { "/openshell.v1.OpenShell/ConnectSupervisor" )); assert!(!is_user_callable("/openshell.v1.OpenShell/RelayStream")); + for path in [ + "/openshell.v1.OpenShell/PeerRelay", + "/openshell.v1.OpenShell/PeerReportProviderReadiness", + "/openshell.v1.OpenShell/PeerReportEndpointStatus", + "/openshell.v1.OpenShell/PeerGetSandboxProviderStatus", + ] { + assert!(!is_user_callable(path)); + assert!(is_peer_callable(path)); + } // Unauthenticated methods are not "user callable" — they're // intercepted before principal evaluation. assert!(!is_user_callable("/openshell.v1.OpenShell/Health")); diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index d3433a557..3994ee537 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -18,6 +18,7 @@ mod http; pub mod identity; pub mod method_authz; pub mod oidc; +pub mod peer; pub mod principal; pub mod sandbox_jwt; pub mod sandbox_methods; diff --git a/crates/openshell-server/src/auth/peer.rs b/crates/openshell-server/src/auth/peer.rs new file mode 100644 index 000000000..e9053649a --- /dev/null +++ b/crates/openshell-server/src/auth/peer.rs @@ -0,0 +1,863 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway peer authentication for internal replica-to-replica RPCs. +//! +//! Peer calls use Kubernetes projected `ServiceAccount` tokens, not an +//! OpenShell-managed shared secret. The caller presents its pod-bound gateway +//! `ServiceAccount` token with the peer audience; the receiver validates it with +//! the apiserver `TokenReview` API, checks the live pod UID and required labels, +//! and only then produces a [`Principal::Peer`]. + +use super::authenticator::Authenticator; +use super::principal::{PeerPrincipal, Principal}; +use async_trait::async_trait; +use k8s_openapi::api::{ + authentication::v1::{TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo}, + core::v1::Pod, +}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, PostParams}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tonic::Status; +use tracing::{debug, info, warn}; + +/// gRPC path for internal gateway relay forwarding. +#[cfg(test)] +pub const PEER_RELAY_PATH: &str = "/openshell.v1.OpenShell/PeerRelay"; +/// Audience used for gateway-to-gateway projected `ServiceAccount` tokens. +pub const DEFAULT_PEER_TOKEN_AUDIENCE: &str = "openshell-gateway-peer"; +/// Environment variable overriding the expected peer token audience. +pub const PEER_TOKEN_AUDIENCE_ENV: &str = "OPENSHELL_PEER_TOKEN_AUDIENCE"; +/// Environment variable carrying the projected peer `ServiceAccount` token path. +/// Uses the `*_TOKEN_FILE` convention from sandbox token env vars. +pub const PEER_SA_TOKEN_FILE_ENV: &str = "OPENSHELL_PEER_SERVICE_ACCOUNT_TOKEN_FILE"; +/// Default mount path for the projected peer `ServiceAccount` token. +pub const DEFAULT_PEER_SA_TOKEN_FILE: &str = "/var/run/secrets/openshell-peer/token"; +/// Environment variable with comma-separated `key=value` pod labels required +/// on authenticated gateway peer pods. +pub const PEER_REQUIRED_POD_LABELS_ENV: &str = "OPENSHELL_PEER_POD_LABELS"; +/// Environment variable overriding how long a verified peer token is cached. +pub const PEER_TOKEN_CACHE_TTL_SECS_ENV: &str = "OPENSHELL_PEER_TOKEN_CACHE_TTL_SECS"; +/// Default lifetime of a cached peer token verification. +pub const DEFAULT_PEER_TOKEN_CACHE_TTL: Duration = Duration::from_mins(1); +const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name"; +const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid"; + +#[derive(Debug, Clone)] +pub struct ResolvedGatewayPeerIdentity { + pub pod_name: String, + pub pod_uid: String, +} + +#[async_trait] +pub trait GatewayPeerIdentityResolver: Send + Sync + 'static { + async fn resolve(&self, token: &str) -> Result, Status>; +} + +#[derive(Debug)] +struct PeerTokenReviewIdentity { + pod_name: String, + pod_uid: String, +} + +pub struct PeerServiceAccountAuthenticator { + resolver: Arc, +} + +impl std::fmt::Debug for PeerServiceAccountAuthenticator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PeerServiceAccountAuthenticator") + .finish_non_exhaustive() + } +} + +impl PeerServiceAccountAuthenticator { + pub fn new(resolver: Arc) -> Self { + Self { resolver } + } +} + +#[async_trait] +impl Authenticator for PeerServiceAccountAuthenticator { + async fn authenticate( + &self, + headers: &http::HeaderMap, + path: &str, + ) -> Result, Status> { + if !crate::auth::method_authz::is_peer_callable(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(resolved) = self.resolver.resolve(token).await? else { + debug!("K8s gateway peer token did not authenticate; falling through"); + return Ok(None); + }; + + if let Some(claimed_replica) = headers + .get("x-openshell-peer-replica") + .and_then(|v| v.to_str().ok()) + .filter(|v| !v.is_empty()) + && claimed_replica != resolved.pod_name.as_str() + { + warn!( + claimed_replica, + pod_name = %resolved.pod_name, + "gateway peer replica header does not match authenticated pod" + ); + return Err(Status::permission_denied( + "gateway peer replica does not match authenticated pod", + )); + } + + Ok(Some(Principal::Peer(PeerPrincipal { + replica_id: resolved.pod_name, + pod_uid: resolved.pod_uid, + }))) + } +} + +/// Resolver backed by Kubernetes `TokenReview` and a live Pod lookup. +pub struct LiveGatewayPeerResolver { + token_reviews_api: Api, + pods_api: Api, + expected_audience: String, + namespace: String, + expected_service_account: String, + required_pod_labels: Vec<(String, String)>, +} + +impl LiveGatewayPeerResolver { + pub fn new( + client: kube::Client, + namespace: &str, + expected_audience: String, + expected_service_account: String, + required_pod_labels: Vec<(String, String)>, + ) -> Self { + let token_reviews_api: Api = Api::all(client.clone()); + let pods_api: Api = Api::namespaced(client, namespace); + Self { + token_reviews_api, + pods_api, + expected_audience, + namespace: namespace.to_string(), + expected_service_account, + required_pod_labels, + } + } +} + +#[async_trait] +impl GatewayPeerIdentityResolver for LiveGatewayPeerResolver { + 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(|err| { + warn!(error = %err, "K8s TokenReview failed for gateway peer"); + Status::internal(format!("peer tokenreview failed: {err}")) + })?; + let status = review + .status + .ok_or_else(|| Status::internal("TokenReview response missing status"))?; + let Some(identity) = peer_token_review_identity( + &status, + &self.expected_audience, + &self.namespace, + &self.expected_service_account, + )? + else { + return Ok(None); + }; + + let pod = self + .pods_api + .get_opt(&identity.pod_name) + .await + .map_err(|err| { + warn!( + pod = %identity.pod_name, + error = %err, + "failed to fetch gateway peer pod" + ); + Status::internal(format!("gateway peer pod GET failed: {err}")) + })?; + let Some(pod) = pod else { + warn!( + pod = %identity.pod_name, + "gateway peer pod referenced by SA token not found" + ); + return Err(Status::not_found("gateway peer pod not found")); + }; + + 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, + "gateway peer SA token pod UID does not match live pod" + ); + return Err(Status::permission_denied( + "gateway peer SA token pod UID mismatch", + )); + } + + let actual_service_account = pod + .spec + .as_ref() + .and_then(|spec| spec.service_account_name.as_deref()) + .unwrap_or("default"); + if actual_service_account != self.expected_service_account { + warn!( + pod = %identity.pod_name, + service_account = %actual_service_account, + expected_service_account = %self.expected_service_account, + "gateway peer pod service account does not match TokenReview principal" + ); + return Err(Status::permission_denied( + "gateway peer pod service account mismatch", + )); + } + + validate_required_pod_labels(&pod, &self.required_pod_labels)?; + + info!( + pod_name = %identity.pod_name, + pod_uid = %identity.pod_uid, + service_account = %self.expected_service_account, + "validated gateway peer ServiceAccount token via TokenReview" + ); + + Ok(Some(ResolvedGatewayPeerIdentity { + pod_name: identity.pod_name, + pod_uid: identity.pod_uid, + })) + } +} + +struct CachedPeerIdentity { + identity: ResolvedGatewayPeerIdentity, + expires_at: Instant, +} + +/// Caches verified peer identities so a burst of relays from the same replica +/// costs one `TokenReview` plus one Pod GET instead of one of each per relay. +/// +/// Only successful verifications are cached. An entry lives for `ttl` or until +/// the token itself expires, whichever comes first. +pub struct CachingGatewayPeerResolver { + inner: Arc, + ttl: Duration, + entries: Mutex>, +} + +impl CachingGatewayPeerResolver { + pub fn new(inner: Arc, ttl: Duration) -> Self { + Self { + inner, + ttl, + entries: Mutex::new(HashMap::new()), + } + } + + fn cached(&self, key: &str) -> Option { + let now = Instant::now(); + let mut entries = self.entries.lock().unwrap(); + let entry = entries.get(key)?; + if entry.expires_at <= now { + entries.remove(key); + return None; + } + Some(entry.identity.clone()) + } + + fn store(&self, key: &str, identity: &ResolvedGatewayPeerIdentity, token: &str) { + let Some(lifetime) = self.entry_lifetime(token) else { + return; + }; + let now = Instant::now(); + let mut entries = self.entries.lock().unwrap(); + entries.retain(|_, entry| entry.expires_at > now); + entries.insert( + key.to_string(), + CachedPeerIdentity { + identity: identity.clone(), + expires_at: now + lifetime, + }, + ); + } + + /// Never outlive the token. A token expiring in 5s is cached for 5s even + /// when `ttl` is longer, so a cache hit can't accept an expired token. + fn entry_lifetime(&self, token: &str) -> Option { + let Some(expires_in) = jwt_expires_in(token) else { + return Some(self.ttl); + }; + if expires_in.is_zero() { + return None; + } + Some(self.ttl.min(expires_in)) + } +} + +#[async_trait] +impl GatewayPeerIdentityResolver for CachingGatewayPeerResolver { + async fn resolve(&self, token: &str) -> Result, Status> { + let key = token_cache_key(token); + if let Some(identity) = self.cached(&key) { + return Ok(Some(identity)); + } + + let resolved = self.inner.resolve(token).await?; + if let Some(identity) = resolved.as_ref() { + self.store(&key, identity, token); + } + Ok(resolved) + } +} + +/// Hashed so long-lived map keys never hold raw credentials. +fn token_cache_key(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Reads `exp` from the JWT payload without verifying the signature — +/// `TokenReview` already did that, this only bounds the cache entry. +fn jwt_expires_in(token: &str) -> Option { + use base64::Engine as _; + + let mut parts = token.split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + let exp = claims.get("exp")?.as_i64()?; + let now_secs = openshell_core::time::now_ms() / 1_000; + Some(Duration::from_secs( + u64::try_from(exp.saturating_sub(now_secs)).unwrap_or(0), + )) +} + +pub fn peer_token_cache_ttl_from_env() -> Duration { + std::env::var(PEER_TOKEN_CACHE_TTL_SECS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .map_or(DEFAULT_PEER_TOKEN_CACHE_TTL, Duration::from_secs) +} + +#[allow(clippy::result_large_err)] +fn peer_token_review_identity( + status: &TokenReviewStatus, + expected_audience: &str, + namespace: &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 gateway peer token" + ); + return Ok(None); + } + + let audiences = status.audiences.as_deref().unwrap_or_default(); + if !audiences.iter().any(|aud| aud == expected_audience) { + warn!( + expected_audience, + audiences = ?audiences, + "K8s TokenReview authenticated gateway peer token without expected audience" + ); + return Err(Status::unauthenticated( + "gateway peer 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 expected_username = format!("system:serviceaccount:{namespace}:{expected_service_account}"); + if username != expected_username { + warn!( + username, + namespace, + service_account = %expected_service_account, + "K8s TokenReview principal is not the configured gateway service account" + ); + return Err(Status::permission_denied( + "gateway peer token is not from the configured service account", + )); + } + + let pod_name = user_extra_one(user, POD_NAME_EXTRA)?; + let pod_uid = user_extra_one(user, POD_UID_EXTRA)?; + Ok(Some(PeerTokenReviewIdentity { 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( + "gateway peer token is not pod-bound", + )); + }; + if values.len() != 1 || values[0].is_empty() { + return Err(Status::permission_denied( + "gateway peer token has invalid pod binding", + )); + } + Ok(values[0].clone()) +} + +#[allow(clippy::result_large_err)] +fn validate_required_pod_labels( + pod: &Pod, + required_labels: &[(String, String)], +) -> Result<(), Status> { + let labels = pod.metadata.labels.as_ref(); + for (key, expected) in required_labels { + let actual = labels + .and_then(|labels| labels.get(key)) + .map(String::as_str) + .unwrap_or_default(); + if actual != expected { + warn!( + pod = %pod.metadata.name.as_deref().unwrap_or_default(), + label = %key, + expected, + actual, + "gateway peer pod missing required label" + ); + return Err(Status::permission_denied( + "gateway peer pod labels do not match", + )); + } + } + Ok(()) +} + +pub fn peer_token_audience_from_env() -> String { + std::env::var(PEER_TOKEN_AUDIENCE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_PEER_TOKEN_AUDIENCE.to_string()) +} + +pub fn peer_service_account_token_file_from_env() -> Option { + if let Ok(path) = std::env::var(PEER_SA_TOKEN_FILE_ENV) + && !path.trim().is_empty() + { + return Some(PathBuf::from(path.trim())); + } + + let default_path = PathBuf::from(DEFAULT_PEER_SA_TOKEN_FILE); + default_path.exists().then_some(default_path) +} + +pub fn load_peer_service_account_token_from_env() -> Result, String> { + let Some(path) = peer_service_account_token_file_from_env() else { + return Ok(None); + }; + + let contents = std::fs::read_to_string(&path) + .map_err(|err| format!("failed to read {}: {err}", path.display()))?; + let token = contents.trim(); + if token.is_empty() { + return Err(format!( + "peer ServiceAccount token file {} is empty", + path.display() + )); + } + + Ok(Some(token.to_string())) +} + +pub fn required_pod_labels_from_env() -> Result, String> { + let raw = std::env::var(PEER_REQUIRED_POD_LABELS_ENV).unwrap_or_default(); + parse_required_pod_labels(&raw) +} + +fn parse_required_pod_labels(raw: &str) -> Result, String> { + let mut labels = Vec::new(); + for entry in raw + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + { + let Some((key, value)) = entry.split_once('=') else { + return Err(format!( + "{PEER_REQUIRED_POD_LABELS_ENV} entry {entry:?} must be key=value" + )); + }; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(format!( + "{PEER_REQUIRED_POD_LABELS_ENV} entry {entry:?} must have non-empty key and value" + )); + } + labels.push((key.to_string(), value.to_string())); + } + Ok(labels) +} + +#[cfg(test)] +pub mod test_support { + use super::*; + use std::sync::Mutex; + + pub struct FakeGatewayPeerResolver { + pub outcome: Result, Status>, + pub seen_tokens: Mutex>, + } + + impl FakeGatewayPeerResolver { + pub fn returning(outcome: Result, Status>) -> Self { + Self { + outcome, + seen_tokens: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl GatewayPeerIdentityResolver for FakeGatewayPeerResolver { + 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(status) => Err(Status::new(status.code(), status.message())), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::FakeGatewayPeerResolver; + use super::*; + use std::collections::BTreeMap; + + 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 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(|(key, value)| (key.to_string(), vec![value.to_string()])) + .collect(), + ), + }), + } + } + + #[test] + fn peer_token_review_identity_extracts_pod_binding() { + let status = token_review_status( + true, + vec![DEFAULT_PEER_TOKEN_AUDIENCE], + "system:serviceaccount:openshell:openshell", + vec![(POD_NAME_EXTRA, "openshell-0"), (POD_UID_EXTRA, "uid-a")], + ); + + let identity = peer_token_review_identity( + &status, + DEFAULT_PEER_TOKEN_AUDIENCE, + "openshell", + "openshell", + ) + .unwrap() + .expect("authenticated token should resolve"); + + assert_eq!(identity.pod_name, "openshell-0"); + assert_eq!(identity.pod_uid, "uid-a"); + } + + #[test] + fn peer_token_review_identity_rejects_wrong_service_account() { + let status = token_review_status( + true, + vec![DEFAULT_PEER_TOKEN_AUDIENCE], + "system:serviceaccount:openshell:default", + vec![(POD_NAME_EXTRA, "openshell-0"), (POD_UID_EXTRA, "uid-a")], + ); + + let err = peer_token_review_identity( + &status, + DEFAULT_PEER_TOKEN_AUDIENCE, + "openshell", + "openshell", + ) + .expect_err("wrong service account must fail closed"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[test] + fn validate_required_pod_labels_rejects_mismatch() { + let pod = Pod { + metadata: ObjectMeta { + name: Some("openshell-0".to_string()), + labels: Some(BTreeMap::from([( + "app.kubernetes.io/name".to_string(), + "openshell".to_string(), + )])), + ..Default::default() + }, + ..Default::default() + }; + + let err = validate_required_pod_labels( + &pod, + &[( + "app.kubernetes.io/instance".to_string(), + "release-a".to_string(), + )], + ) + .expect_err("missing required label must fail"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn authenticator_uses_resolved_pod_name_as_replica() { + let resolver = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some( + ResolvedGatewayPeerIdentity { + pod_name: "openshell-0".to_string(), + pod_uid: "uid-a".to_string(), + }, + )))); + let auth = PeerServiceAccountAuthenticator::new(resolver); + + let principal = auth + .authenticate(&bearer_headers("token-a"), PEER_RELAY_PATH) + .await + .unwrap() + .expect("principal"); + + let Principal::Peer(peer) = principal else { + panic!("expected peer principal"); + }; + assert_eq!(peer.replica_id, "openshell-0"); + assert_eq!(peer.pod_uid, "uid-a"); + } + + #[tokio::test] + async fn authenticator_accepts_each_peer_rpc_and_ignores_non_peer_rpcs() { + let resolver = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some( + ResolvedGatewayPeerIdentity { + pod_name: "openshell-0".to_string(), + pod_uid: "uid-a".to_string(), + }, + )))); + let auth = PeerServiceAccountAuthenticator::new(resolver.clone()); + let headers = bearer_headers("token-a"); + + for path in [ + PEER_RELAY_PATH, + "/openshell.v1.OpenShell/PeerReportProviderReadiness", + "/openshell.v1.OpenShell/PeerReportEndpointStatus", + "/openshell.v1.OpenShell/PeerGetSandboxProviderStatus", + ] { + assert!(matches!( + auth.authenticate(&headers, path).await.unwrap(), + Some(Principal::Peer(_)) + )); + } + assert!( + auth.authenticate(&headers, "/openshell.v1.OpenShell/ListSandboxes") + .await + .unwrap() + .is_none() + ); + assert_eq!(resolver.seen_tokens.lock().unwrap().len(), 4); + } + + fn identity() -> ResolvedGatewayPeerIdentity { + ResolvedGatewayPeerIdentity { + pod_name: "openshell-0".to_string(), + pod_uid: "uid-a".to_string(), + } + } + + /// Builds an unsigned JWT whose payload carries only `exp`. The cache reads + /// `exp` without verifying the signature, so the other segments are filler. + fn token_expiring_in(secs: i64) -> String { + use base64::Engine as _; + let exp = (openshell_core::time::now_ms() / 1_000) + secs; + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); + format!("header.{payload}.signature") + } + + #[tokio::test] + async fn caching_resolver_reuses_a_verified_token() { + let inner = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some(identity())))); + let resolver = CachingGatewayPeerResolver::new(inner.clone(), Duration::from_mins(1)); + let token = token_expiring_in(3600); + + for _ in 0..5 { + let peer = resolver.resolve(&token).await.unwrap(); + assert_eq!(peer.unwrap().pod_name, "openshell-0"); + } + + assert_eq!( + inner.seen_tokens.lock().unwrap().len(), + 1, + "five relays with the same token must cost one TokenReview" + ); + } + + #[tokio::test] + async fn caching_resolver_verifies_each_distinct_token() { + let inner = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some(identity())))); + let resolver = CachingGatewayPeerResolver::new(inner.clone(), Duration::from_mins(1)); + + resolver.resolve(&token_expiring_in(3600)).await.unwrap(); + resolver.resolve(&token_expiring_in(1800)).await.unwrap(); + + assert_eq!(inner.seen_tokens.lock().unwrap().len(), 2); + } + + #[tokio::test] + async fn caching_resolver_does_not_cache_a_rejected_token() { + let inner = Arc::new(FakeGatewayPeerResolver::returning(Ok(None))); + let resolver = CachingGatewayPeerResolver::new(inner.clone(), Duration::from_mins(1)); + let token = token_expiring_in(3600); + + assert!(resolver.resolve(&token).await.unwrap().is_none()); + assert!(resolver.resolve(&token).await.unwrap().is_none()); + + assert_eq!( + inner.seen_tokens.lock().unwrap().len(), + 2, + "a rejection must be revalidated, never served from cache" + ); + } + + #[tokio::test] + async fn caching_resolver_does_not_cache_an_expired_token() { + let inner = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some(identity())))); + let resolver = CachingGatewayPeerResolver::new(inner.clone(), Duration::from_mins(1)); + let token = token_expiring_in(-1); + + resolver.resolve(&token).await.unwrap(); + resolver.resolve(&token).await.unwrap(); + + assert_eq!(inner.seen_tokens.lock().unwrap().len(), 2); + } + + #[test] + fn entry_lifetime_never_outlives_the_token() { + let inner = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some(identity())))); + let resolver = CachingGatewayPeerResolver::new(inner, Duration::from_mins(1)); + + let short = resolver + .entry_lifetime(&token_expiring_in(5)) + .expect("a live token should be cacheable"); + assert!(short <= Duration::from_secs(5)); + + let capped = resolver + .entry_lifetime(&token_expiring_in(3600)) + .expect("a long-lived token should be cacheable"); + assert_eq!(capped, Duration::from_mins(1)); + + assert!(resolver.entry_lifetime(&token_expiring_in(-1)).is_none()); + } + + #[test] + fn opaque_tokens_fall_back_to_the_configured_ttl() { + let inner = Arc::new(FakeGatewayPeerResolver::returning(Ok(Some(identity())))); + let resolver = CachingGatewayPeerResolver::new(inner, Duration::from_secs(30)); + + assert_eq!( + resolver.entry_lifetime("not-a-jwt"), + Some(Duration::from_secs(30)) + ); + } + + #[test] + fn token_cache_key_does_not_contain_the_token() { + let key = token_cache_key("super-secret-token"); + + assert!(!key.contains("super-secret-token")); + assert_eq!(key.len(), 64); + assert_eq!(key, token_cache_key("super-secret-token")); + assert_ne!(key, token_cache_key("another-token")); + } + + #[test] + fn parse_required_pod_labels_accepts_comma_list() { + let labels = parse_required_pod_labels( + "app.kubernetes.io/name=openshell,app.kubernetes.io/instance=dev", + ) + .unwrap(); + assert_eq!( + labels, + vec![ + ( + "app.kubernetes.io/name".to_string(), + "openshell".to_string() + ), + ("app.kubernetes.io/instance".to_string(), "dev".to_string()) + ] + ); + } +} diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 9567cc62d..7d1cc0004 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -28,6 +28,8 @@ 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), + /// Gateway replica authenticated for internal peer RPCs. + Peer(PeerPrincipal), /// Truly unauthenticated caller (health probes, reflection). Sandbox-class /// and user-class methods reject this variant. #[allow(dead_code)] @@ -57,6 +59,15 @@ pub struct SandboxPrincipal { pub trust_domain: Option, } +/// Gateway peer caller. +#[derive(Debug, Clone)] +pub struct PeerPrincipal { + /// Peer replica id supplied by the authenticated caller. + pub replica_id: String, + /// UID of the authenticated Kubernetes pod. + pub pod_uid: String, +} + /// How a [`SandboxPrincipal`] was authenticated. /// /// Variant fields are populated by the producing authenticator and consumed diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index f368298dd..29e56563c 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -26,6 +26,7 @@ mod tests { "/openshell.v1.OpenShell/ConnectSupervisor" )); assert!(is_sandbox_callable("/openshell.v1.OpenShell/RelayStream")); + assert!(!is_sandbox_callable("/openshell.v1.OpenShell/PeerRelay")); assert!(is_sandbox_callable( "/openshell.v1.OpenShell/GetSandboxConfig" )); diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index 00b48288f..0aa38fe0b 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -190,6 +190,9 @@ pub async fn authorize_workspace( workspace, grant: AuthGrant::Sandbox, }), + Principal::Peer(_) => Err(Status::permission_denied( + "gateway peer principals cannot perform workspace operations", + )), Principal::Anonymous => Err(Status::unauthenticated("authentication required")), } } @@ -223,6 +226,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::Peer(_) => Err(Status::permission_denied( + "gateway peer 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 8e2b5cb46..a0219ab1b 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -16,11 +16,15 @@ use crate::persistence::{ }; use crate::sandbox_index::SandboxIndex; use crate::sandbox_watch::SandboxWatchBus; +use crate::supervisor_owner::{OWNER_TTL, SupervisorOwnerIndex}; use crate::supervisor_session::SupervisorSessionRegistry; use crate::tracing_bus::TracingLogBus; use futures::{Stream, StreamExt}; #[cfg(unix)] use hyper_util::rt::TokioIo; +use openshell_core::extension_protocol::{ + ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate, +}; use openshell_core::proto::compute::v1::{ AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, @@ -171,6 +175,7 @@ mod traced_driver { } const DELETE_PHASE_CAS_RETRY_LIMIT: usize = 3; +const START_PHASE_CAS_RETRY_LIMIT: usize = 3; const SUPERVISOR_SESSION_CAS_RETRY_LIMIT: usize = 3; /// Serializes request-side lifecycle mutations for the same stable sandbox ID. @@ -276,6 +281,8 @@ pub struct ComputeDriverInfoSnapshot { pub driver_name: String, /// Driver-reported implementation version from the startup capability snapshot. pub driver_version: String, + /// Common extension protocol negotiation result. + pub negotiated_extension: NegotiatedExtension, /// Whether the driver asks the gateway to reconcile compute across restarts. pub gateway_manages_lifecycle: bool, /// Whether the driver authenticates driver-native sandbox credentials. @@ -599,6 +606,13 @@ pub struct ComputeRuntime { rootfs_tar_staging: Arc, } +pub struct SandboxSyncGuard { + // Drop the database guard before the local mutex so another local waiter + // cannot race ahead while this replica still owns the cluster-wide lock. + _distributed: crate::persistence::DistributedMutationGuard, + _local: tokio::sync::OwnedMutexGuard<()>, +} + impl fmt::Debug for ComputeRuntime { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ComputeRuntime").finish_non_exhaustive() @@ -626,14 +640,24 @@ impl ComputeRuntime { tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, ) -> Result { + let gateway = gateway_metadata(ExtensionFamily::Compute); let capabilities = driver - .get_capabilities(Request::new(GetCapabilitiesRequest {})) + .get_capabilities(Request::new(GetCapabilitiesRequest { + gateway: Some(gateway.clone()), + })) .await .map_err(|status| { tracing::Span::current().record("otel.status_code", "ERROR"); compute_error_from_status(status) })? .into_inner(); + let negotiated_extension = negotiate( + ExtensionFamily::Compute, + &driver_name, + &gateway, + capabilities.extension.clone(), + ) + .map_err(|error| ComputeError::Message(error.to_string()))?; info!( configured_driver = %driver_name, advertised_driver = %capabilities.driver_name, @@ -643,6 +667,7 @@ impl ComputeRuntime { name: driver_name.clone(), driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + negotiated_extension, gateway_manages_lifecycle: capabilities.gateway_manages_lifecycle, supports_sandbox_authentication: capabilities.supports_sandbox_authentication, driver_reports_runtime_readiness: capabilities.driver_reports_runtime_readiness, @@ -676,13 +701,19 @@ impl ComputeRuntime { } /// Serializes sandbox/provider-profile invariant checks and object writes - /// within this gateway process. + /// across gateway replicas. /// - /// This is a temporary single-gateway guard for cross-object invariants. - /// It is not HA-safe; replace it with DB-backed CAS/resource-version writes - /// tracked by #1255 before enabling multiple gateway writers. - pub(crate) async fn sandbox_sync_guard(&self) -> tokio::sync::OwnedMutexGuard<()> { - self.sync_lock.clone().lock_owned().await + /// The local mutex preserves lock ordering within one process. `PostgreSQL` + /// deployments also hold a session-level advisory lock for the duration. + pub(crate) async fn sandbox_sync_guard( + &self, + ) -> crate::persistence::PersistenceResult { + let local = self.sync_lock.clone().lock_owned().await; + let distributed = self.store.acquire_distributed_mutation_guard().await?; + Ok(SandboxSyncGuard { + _distributed: distributed, + _local: local, + }) } /// Acquires the process-wide lock for code that already holds the @@ -1161,7 +1192,7 @@ impl ComputeRuntime { workspace: &str, name: &str, ) -> Result { - self.start_sandbox_authenticated(workspace, name, Vec::new()) + self.start_sandbox_authenticated(workspace, name, None) .await } @@ -1169,7 +1200,7 @@ impl ComputeRuntime { &self, workspace: &str, name: &str, - launch_authentication: Vec, + authority: Option<&crate::auth::sandbox_jwt::SandboxSessionJwtAuthority>, ) -> Result { let candidate = self .store @@ -1192,7 +1223,7 @@ impl ComputeRuntime { let sandbox_name = candidate.object_name().to_string(); let lifecycle_guard = self.lifecycle_gates.lock_for(&sandbox_id).await; let global_guard = self.lock_global_for_lifecycle(&lifecycle_guard).await; - let current = self + let mut current = self .store .get_message::(&sandbox_id) .await @@ -1204,57 +1235,120 @@ impl ComputeRuntime { )); } - let phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Ready { - return Ok(current); - } - let provisioning_timeout = - phase == SandboxPhase::Error && provisioning_deadline::timed_out(¤t); - if provisioning_timeout - && current - .status - .as_ref() - .and_then(|status| status.provisioning.as_ref()) - .is_some_and(|record| record.cleanup_completed_time.is_none()) - { - return Err(Status::failed_precondition( - "provisioning timeout cleanup is still pending; retry start after compute is reclaimed", - )); - } - if !matches!( - phase, - SandboxPhase::Stopped | SandboxPhase::Completed | SandboxPhase::Starting - ) && !is_failed_main_process_result(¤t) - && !provisioning_timeout - { - return Err(Status::failed_precondition(format!( - "sandbox must be Stopped, Completed, or a failed main-process Error to start (current phase: {phase:?})" - ))); - } + let mut attempts = 0; + let (previous, starting, launch_authentication) = loop { + let phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); + if phase == SandboxPhase::Ready { + return Ok(current); + } + let provisioning_timeout = + phase == SandboxPhase::Error && provisioning_deadline::timed_out(¤t); + if provisioning_timeout + && current + .status + .as_ref() + .and_then(|status| status.provisioning.as_ref()) + .is_some_and(|record| record.cleanup_completed_time.is_none()) + { + return Err(Status::failed_precondition( + "provisioning timeout cleanup is still pending; retry start after compute is reclaimed", + )); + } + if !matches!( + phase, + SandboxPhase::Stopped | SandboxPhase::Completed | SandboxPhase::Starting + ) && !is_failed_main_process_result(¤t) + && !provisioning_timeout + { + return Err(Status::failed_precondition(format!( + "sandbox must be Stopped, Completed, or a failed main-process Error to start (current phase: {phase:?})" + ))); + } - if phase == SandboxPhase::Completed || is_failed_main_process_result(¤t) { - self.cleanup_stopped_sandbox_sessions(¤t) - .await - .map_err(Status::internal)?; - } + if phase == SandboxPhase::Completed || is_failed_main_process_result(¤t) { + self.cleanup_stopped_sandbox_sessions(¤t) + .await + .map_err(Status::internal)?; + } + + if phase == SandboxPhase::Starting { + // Acquiring the lifecycle gate proves that no local worker still + // owns this transition. Retry the idempotent driver operation + // with the identity committed by the original transition. + let authentication = + serialize_persisted_launch_authentication(authority, ¤t)?; + break (current.clone(), current, authentication); + } - let (previous, starting) = if phase == SandboxPhase::Starting { - // Acquiring the lifecycle gate proves that no local worker still - // owns this transition. Retry the idempotent driver operation. - (current.clone(), current) - } else { let previous = current.clone(); - let starting = self - .write_lifecycle_phase( - ¤t, - SandboxPhase::Starting, - "Starting", - "Sandbox start requested", + let next_identity = if authority.is_some() + && matches!(phase, SandboxPhase::Stopped | SandboxPhase::Completed) + { + Some(next_runtime_identity(¤t)?) + } else { + None + }; + let launch_authentication = + if let (Some(authority), Some(identity)) = (authority, next_identity.as_ref()) { + serialize_launch_authentication( + authority.mint_persisted_launch(current.object_id(), identity)?, + )? + } else { + serialize_persisted_launch_authentication(authority, ¤t)? + }; + let expected_resource_version = sandbox_resource_version(¤t); + let next_identity_for_update = next_identity.clone(); + match self + .store + .update_message_cas::( + &sandbox_id, + expected_resource_version, + move |sandbox| { + if let Some(identity) = &next_identity_for_update + && let Some(metadata) = sandbox.metadata.as_mut() + { + identity.write(&mut metadata.annotations); + } + apply_lifecycle_phase( + sandbox, + SandboxPhase::Starting, + "Starting", + "Sandbox start requested", + ); + }, ) - .await?; - self.sandbox_index.update_from_sandbox(&starting); - self.sandbox_watch_bus.notify(&sandbox_id); - (previous, starting) + .await + { + Ok(starting) => { + self.sandbox_index.update_from_sandbox(&starting); + self.sandbox_watch_bus.notify(&sandbox_id); + break (previous, starting, launch_authentication); + } + Err(crate::persistence::PersistenceError::Conflict { .. }) + if attempts < START_PHASE_CAS_RETRY_LIMIT => + { + attempts += 1; + current = self + .store + .get_message::(&sandbox_id) + .await + .map_err(|error| { + Status::internal(format!("fetch sandbox after start conflict: {error}")) + })? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + if current.object_name() != sandbox_name { + return Err(Status::aborted( + "sandbox name changed during start; retry explicitly", + )); + } + } + Err(error) => { + return Err(crate::grpc::persistence_error_to_status( + error, + "update sandbox lifecycle", + )); + } + } }; drop(global_guard); @@ -1478,7 +1572,13 @@ impl ComputeRuntime { ) -> Option { let sandbox_id = transition.object_id().to_string(); let expected_resource_version = sandbox_resource_version(transition); - let session_connected = self.supervisor_sessions.has_session(&sandbox_id); + let session_connected = self + .supervisor_session_ready(&sandbox_id) + .await + .unwrap_or_else(|error| { + warn!(sandbox_id, %error, "Failed to resolve supervisor owner during lifecycle reconciliation"); + self.supervisor_sessions.has_session(&sandbox_id) + }); match self .store .update_message_cas::(&sandbox_id, expected_resource_version, |sandbox| { @@ -1523,37 +1623,7 @@ impl ComputeRuntime { &sandbox_id, expected_resource_version, move |sandbox| { - sandbox.set_phase(phase as i32); - if matches!(phase, SandboxPhase::Stopping | SandboxPhase::Starting) { - let status = sandbox.status.get_or_insert_with(Default::default); - // Retain the previous instance id as a tombstone until - // the restarted supervisor registers its new id. - status.exit_code = None; - if phase == SandboxPhase::Starting { - status.provisioning = Some(provisioning_deadline::new_record( - openshell_core::time::now_ms(), - )); - status.configuration_admission = - Some(openshell_core::proto::SandboxConfigurationAdmission { - // Fence delayed registrations from the previous runtime. - instance_id: uuid::Uuid::new_v4().to_string(), - state: - openshell_core::proto::ConfigurationAdmissionState::Pending - .into(), - ..Default::default() - }); - } - } - upsert_ready_condition( - &mut sandbox.status, - SandboxCondition { - r#type: "Ready".to_string(), - status: "False".to_string(), - reason: reason.clone(), - message: message.clone(), - transition_time: None, - }, - ); + apply_lifecycle_phase(sandbox, phase, &reason, &message); }, ) .await @@ -1618,6 +1688,25 @@ impl ComputeRuntime { sandbox_id: candidate.object_id().to_string(), sandbox_name: candidate.object_name().to_string(), }; + self.delete_sandbox_target(target).await + } + + pub(crate) async fn delete_sandbox_by_id( + &self, + sandbox_id: &str, + sandbox_name: &str, + ) -> Result { + self.delete_sandbox_target(SandboxDeleteTarget { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + }) + .await + } + + async fn delete_sandbox_target( + &self, + target: SandboxDeleteTarget, + ) -> Result { let delete_guard = self.lifecycle_gates.lock_for(&target.sandbox_id).await; let global_guard = self.lock_global_for_lifecycle(&delete_guard).await; @@ -1968,7 +2057,13 @@ impl ComputeRuntime { match observed { Ok(Some(snapshot)) if snapshot.id == sandbox_id && snapshot.status.is_some() => { - let session_connected = self.supervisor_sessions.has_session(sandbox_id); + let session_connected = self + .supervisor_session_ready(sandbox_id) + .await + .unwrap_or_else(|error| { + warn!(sandbox_id, %error, "Failed to resolve supervisor owner during delete recovery"); + self.supervisor_sessions.has_session(sandbox_id) + }); self.write_delete_recovery_with_retry( sandbox_id, deleting_resource_version, @@ -3180,7 +3275,7 @@ impl ComputeRuntime { expected_resource_version: u64, existing_phase: SandboxPhase, ) -> Result<(), String> { - let session_connected = self.supervisor_sessions.has_session(&incoming.id); + let session_connected = self.supervisor_session_ready(&incoming.id).await?; let sandbox = self .store .update_message_cas::( @@ -3231,8 +3326,77 @@ impl ComputeRuntime { sandbox_id: &str, terminal_delivery_finalized: bool, ) -> Result<(), String> { - self.set_supervisor_session_state(sandbox_id, false, None, terminal_delivery_finalized) + let _guard = self.sync_lock.lock().await; + + // A replacement session may already belong to another gateway. Do not + // let cleanup from this replica overwrite the replacement's Ready state. + if self.supervisor_session_ready(sandbox_id).await? { + return Ok(()); + } + + let existing = self + .store + .get_message::(sandbox_id) .await + .map_err(|error| error.to_string())?; + self.set_supervisor_session_state_from_snapshot( + sandbox_id, + false, + None, + terminal_delivery_finalized, + existing, + ) + .await?; + + // Ownership can move while the CAS above is in flight. Restore Ready + // from the new owner's supervisor instance before releasing the local + // synchronization boundary. + if let Some(instance_id) = self + .supervisor_session_owner_instance_id(sandbox_id) + .await? + { + let existing = self + .store + .get_message::(sandbox_id) + .await + .map_err(|error| error.to_string())?; + self.set_supervisor_session_state_from_snapshot( + sandbox_id, + true, + Some(&instance_id), + false, + existing, + ) + .await?; + } + Ok(()) + } + + async fn supervisor_session_ready(&self, sandbox_id: &str) -> Result { + if self.supervisor_sessions.has_session(sandbox_id) { + return Ok(true); + } + Ok(self + .supervisor_session_owner_instance_id(sandbox_id) + .await? + .is_some()) + } + + async fn supervisor_session_owner_instance_id( + &self, + sandbox_id: &str, + ) -> Result, String> { + let owner_index = SupervisorOwnerIndex::new(self.store.clone(), OWNER_TTL); + let Some(owner) = owner_index + .read(sandbox_id) + .await + .map_err(|error| error.to_string())? + else { + return Ok(None); + }; + Ok(owner + .is_fresh(OWNER_TTL) + .then_some(owner.supervisor_instance_id)) } async fn set_supervisor_session_state( @@ -3880,8 +4044,9 @@ impl ComputeRuntime { } let sandbox = decode_sandbox_record(¤t_record)?; - let age_ms = - openshell_core::time::now_ms().saturating_sub(current_record.created_at_ms); + let age_ms = openshell_core::time::now_ms() + .saturating_sub(current_record.created_at_ms) + .max(0); if age_ms < grace_ms { return Ok(()); } @@ -5089,6 +5254,88 @@ fn is_recoverable_error_reason(sandbox: &Sandbox) -> bool { .is_some_and(|c| c.reason == CONDITION_RUNTIME_RESTART || c.reason == CONDITION_STOPPED) } +fn apply_lifecycle_phase(sandbox: &mut Sandbox, phase: SandboxPhase, reason: &str, message: &str) { + sandbox.set_phase(phase as i32); + if matches!(phase, SandboxPhase::Stopping | SandboxPhase::Starting) { + let status = sandbox.status.get_or_insert_with(Default::default); + // Retain the previous instance id as a tombstone until the restarted + // supervisor registers its new id. + status.exit_code = None; + if phase == SandboxPhase::Starting { + status.provisioning = Some(provisioning_deadline::new_record( + openshell_core::time::now_ms(), + )); + status.configuration_admission = + Some(openshell_core::proto::SandboxConfigurationAdmission { + // Fence delayed registrations from the previous runtime. + instance_id: uuid::Uuid::new_v4().to_string(), + state: openshell_core::proto::ConfigurationAdmissionState::Pending.into(), + ..Default::default() + }); + } + } + upsert_ready_condition( + &mut sandbox.status, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: reason.to_string(), + message: message.to_string(), + transition_time: None, + }, + ); +} + +fn next_runtime_identity( + sandbox: &Sandbox, +) -> Result { + let metadata = sandbox + .metadata + .as_ref() + .ok_or_else(|| Status::failed_precondition("sandbox metadata is missing"))?; + let current = + crate::auth::sandbox_session::PersistedSandboxIdentity::read(&metadata.annotations) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + let next_epoch = current + .auth_epoch + .get() + .checked_add(1) + .and_then(|epoch| openshell_core::jwt::CredentialEpoch::new(epoch).ok()) + .ok_or_else(|| Status::internal("sandbox authorization epoch overflow"))?; + Ok(crate::auth::sandbox_session::PersistedSandboxIdentity { + runtime_generation: current.runtime_generation, + auth_epoch: next_epoch, + gateway_token_id: uuid::Uuid::new_v4(), + refresh_replay: None, + }) +} + +fn serialize_persisted_launch_authentication( + authority: Option<&crate::auth::sandbox_jwt::SandboxSessionJwtAuthority>, + sandbox: &Sandbox, +) -> Result, Status> { + let Some(authority) = authority else { + return Ok(Vec::new()); + }; + let metadata = sandbox + .metadata + .as_ref() + .ok_or_else(|| Status::failed_precondition("sandbox metadata is missing"))?; + let identity = + crate::auth::sandbox_session::PersistedSandboxIdentity::read(&metadata.annotations) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + serialize_launch_authentication( + authority.mint_persisted_launch(sandbox.object_id(), &identity)?, + ) +} + +fn serialize_launch_authentication( + authentication: openshell_core::jwt::SandboxLaunchAuthentication, +) -> Result, Status> { + serde_json::to_vec(&authentication) + .map_err(|error| Status::internal(format!("encode launch authentication: {error}"))) +} + fn is_terminal_failure_reason(reason: &str) -> bool { let reason = reason.to_ascii_lowercase(); let transient_reasons = [ @@ -5189,6 +5436,12 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::Compute, + "openshell/noop-test-driver", + "test", + [], + )), }, )) } @@ -5303,6 +5556,23 @@ pub async fn new_test_runtime(store: Arc) -> ComputeRuntime { new_test_runtime_for_driver(store, "test").await } +#[cfg(any(test, feature = "test-support"))] +fn test_compute_negotiation(name: &str) -> NegotiatedExtension { + let gateway = gateway_metadata(ExtensionFamily::Compute); + negotiate( + ExtensionFamily::Compute, + name, + &gateway, + Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::Compute, + format!("openshell/{name}"), + "test", + [], + )), + ) + .unwrap() +} + #[cfg(any(test, feature = "test-support"))] pub async fn new_test_runtime_for_driver(store: Arc, driver_name: &str) -> ComputeRuntime { new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())) @@ -5321,6 +5591,7 @@ pub fn new_test_runtime_with_driver( name: driver_name.to_string(), driver_name: driver_name.to_string(), driver_version: "test".to_string(), + negotiated_extension: test_compute_negotiation(driver_name), gateway_manages_lifecycle: false, supports_sandbox_authentication, driver_reports_runtime_readiness: false, @@ -5707,6 +5978,7 @@ mod tests { listed_sandboxes: Vec, current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, + omit_protocol_metadata: bool, } #[tonic::async_trait] @@ -5739,6 +6011,14 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: (!self.omit_protocol_metadata).then(|| { + openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::Compute, + "openshell/test-driver", + "test", + [], + ) + }), })) } @@ -6083,6 +6363,12 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::Compute, + "openshell/controlled-test-driver", + "test", + [], + )), })) } @@ -6285,6 +6571,7 @@ mod tests { name: driver_name.to_string(), driver_name: driver_name.to_string(), driver_version: "test".to_string(), + negotiated_extension: test_compute_negotiation(driver_name), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, driver_reports_runtime_readiness: false, @@ -6356,6 +6643,18 @@ mod tests { sandbox } + fn test_session_authority() -> crate::auth::sandbox_jwt::SandboxSessionJwtAuthority { + let material = openshell_bootstrap::jwt::generate_jwt_key().expect("test jwt key"); + crate::auth::sandbox_jwt::SandboxSessionJwtAuthority::from_pem( + material.signing_key_pem.as_bytes(), + material.public_key_pem.as_bytes(), + material.kid, + "test-gateway", + Duration::from_mins(15), + ) + .expect("test session authority") + } + #[test] fn main_process_exit_zero_is_completed() { let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); @@ -8049,6 +8348,83 @@ mod tests { ); } + #[tokio::test] + async fn authenticated_start_commits_identity_with_starting_phase() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-auth-start", "sandbox-auth-start", SandboxPhase::Stopped); + runtime.store.put_message(&sandbox).await.unwrap(); + let before = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let authority = test_session_authority(); + + runtime + .start_sandbox_authenticated( + sandbox.object_workspace(), + sandbox.object_name(), + Some(&authority), + ) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let stored_identity = crate::auth::sandbox_session::PersistedSandboxIdentity::read( + &stored.metadata.as_ref().unwrap().annotations, + ) + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Starting as i32); + assert_eq!(stored_identity.auth_epoch.get(), 2); + assert_eq!( + sandbox_resource_version(&stored), + sandbox_resource_version(&before) + 1, + "identity rotation and Starting must be one CAS write" + ); + + runtime + .start_sandbox_authenticated( + sandbox.object_workspace(), + sandbox.object_name(), + Some(&authority), + ) + .await + .unwrap(); + + let authentications = driver.start_authentications(); + assert_eq!(authentications.len(), 2); + for encoded in authentications { + let authentication: openshell_core::jwt::SandboxLaunchAuthentication = + serde_json::from_slice(&encoded).unwrap(); + assert_eq!( + authentication.supervisor.auth_epoch, + stored_identity.auth_epoch + ); + assert_eq!( + authentication.supervisor.runtime_generation, + stored_identity.runtime_generation + ); + } + let after_retry = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + sandbox_resource_version(&after_retry), + sandbox_resource_version(&stored), + "idempotent Starting retry must reuse the persisted identity" + ); + } + #[tokio::test] async fn request_cancellation_does_not_cancel_start_worker() { let driver = ControlledDriver::new(); @@ -10385,6 +10761,42 @@ mod tests { assert_eq!(ready.message, "Supervisor session disconnected"); } + #[tokio::test] + async fn stale_session_disconnect_preserves_new_remote_owner_ready_state() { + let runtime = test_runtime(Arc::new(TestDriver::default())).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + sandbox.set_phase(SandboxPhase::Ready as i32); + runtime.store.put_message(&sandbox).await.unwrap(); + + SupervisorOwnerIndex::new(runtime.store.clone(), OWNER_TTL) + .publish( + "sb-1", + "replacement-session", + "supervisor-instance", + 2, + "gateway-b", + "http://gateway-b:8080", + ) + .await + .unwrap(); + + runtime + .supervisor_session_disconnected("sb-1", false) + .await + .unwrap(); + + let stored = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(stored.phase()).unwrap(), + SandboxPhase::Ready + ); + } + // --- Composition rule tests --- fn make_ready_driver_status() -> DriverSandboxStatus { @@ -10723,6 +11135,7 @@ mod tests { }), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10894,6 +11307,7 @@ mod tests { })), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -11732,6 +12146,32 @@ mod tests { test_exporter::assert_is_root(&initialization); } + #[tokio::test] + async fn compute_driver_initialization_rejects_missing_protocol_metadata() { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let error = ComputeRuntime::from_driver( + "legacy-driver".to_string(), + Arc::new(TestDriver { + omit_protocol_metadata: true, + ..Default::default() + }), + None, + store, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + ) + .await + .expect_err("legacy driver must fail negotiation"); + + assert!( + error + .to_string() + .contains("did not provide protocol metadata") + ); + } + #[tokio::test] #[cfg(unix)] async fn remote_compute_driver_interceptor_propagates_every_rpc() { @@ -11755,7 +12195,9 @@ mod tests { let traced = test_exporter::install_traced(); async { remote - .get_capabilities(Request::new(GetCapabilitiesRequest {})) + .get_capabilities(Request::new(GetCapabilitiesRequest { + gateway: Some(gateway_metadata(ExtensionFamily::Compute)), + })) .await .unwrap(); remote diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index 36d662bc7..ad1d5c6eb 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -25,6 +25,9 @@ use std::{ use async_trait::async_trait; #[cfg(unix)] use hyper_util::rt::TokioIo; +use openshell_core::extension_protocol::{ + ExtensionFamily, NegotiatedExtension, extension_metadata, gateway_metadata, negotiate, +}; use openshell_core::proto::credentials::v1::{ DeleteCredentialRequest, GetCredentialDriverCapabilitiesRequest, GetCredentialDriverCapabilitiesResponse, ResolveCredentialRequest, ResolveCredentialsRequest, @@ -122,6 +125,7 @@ pub struct CredentialRuntime { registry: CredentialDriverRegistry, drivers: BTreeMap>, _driver_processes: Vec>, + negotiated_extensions: Vec, } impl CredentialRuntime { @@ -154,10 +158,15 @@ impl CredentialRuntime { } } + let negotiated_extensions = drivers + .keys() + .map(|name| negotiate_builtin_credential_driver(name)) + .collect::>>()?; Ok(Self { registry, drivers, _driver_processes: Vec::new(), + negotiated_extensions, }) } @@ -184,6 +193,7 @@ impl CredentialRuntime { let registry = CredentialDriverRegistry::from_config(config)?; let mut drivers = BTreeMap::new(); let mut driver_processes = Vec::new(); + let mut negotiated_extensions = Vec::new(); let empty_config = toml::Table::new(); let default_store_config = config_file .and_then(|file| file.openshell.gateway.credential_storage.as_ref()) @@ -194,6 +204,11 @@ impl CredentialRuntime { default_store_config, registry.requires_default_store(), )?; + if drivers.contains_key(DbCredstoreCredentialDriver::NAME) { + negotiated_extensions.push(negotiate_builtin_credential_driver( + DbCredstoreCredentialDriver::NAME, + )?); + } for driver_name in registry.enabled_driver_names() { let driver_config = config_file @@ -205,12 +220,14 @@ impl CredentialRuntime { let built = build_configured_driver(driver_name, driver_config, store.clone()).await?; drivers.insert(driver_name.clone(), built.driver); + negotiated_extensions.push(built.negotiated_extension); if let Some(process) = built.process { driver_processes.push(process); } } else { let driver = build_default_in_tree_driver(driver_name, store.clone()).await?; drivers.insert(driver_name.clone(), driver); + negotiated_extensions.push(negotiate_builtin_credential_driver(driver_name)?); } } @@ -218,6 +235,7 @@ impl CredentialRuntime { registry, drivers, _driver_processes: driver_processes, + negotiated_extensions, }) } @@ -230,6 +248,11 @@ impl CredentialRuntime { self.drivers.contains_key(&driver_name) } + #[must_use] + pub fn negotiated_extensions(&self) -> &[NegotiatedExtension] { + &self.negotiated_extensions + } + pub fn storage_owns_handle(&self, handle: &CredentialHandle) -> bool { normalize_driver_name(&handle.driver) == self.registry.storage_owner_name() } @@ -1128,10 +1151,27 @@ fn connect_default_credential_store( Ok(()) } +fn negotiate_builtin_credential_driver(name: &str) -> CoreResult { + let gateway = gateway_metadata(ExtensionFamily::Credentials); + negotiate( + ExtensionFamily::Credentials, + name, + &gateway, + Some(extension_metadata( + ExtensionFamily::Credentials, + format!("openshell/{name}"), + openshell_core::VERSION, + [], + )), + ) + .map_err(|error| Error::config(error.to_string())) +} + #[derive(Debug)] struct BuiltCredentialDriver { driver: Arc, process: Option>, + negotiated_extension: NegotiatedExtension, } async fn build_configured_driver( @@ -1151,6 +1191,7 @@ async fn build_configured_driver( Ok(BuiltCredentialDriver { driver, process: None, + negotiated_extension: negotiate_builtin_credential_driver(driver_name)?, }) } CredentialDriverTransport::Uds => { @@ -1489,10 +1530,14 @@ async fn connect_uds_driver( if config.command.is_some() { spawn_uds_driver(driver_name, config, socket_path).await } else { - let channel = connect_ready_credential_driver(driver_name, socket_path).await?; + let (channel, negotiated_extension) = + connect_ready_credential_driver(driver_name, socket_path) + .await + .map_err(CredentialDriverReadinessError::into_error)?; Ok(BuiltCredentialDriver { driver: Arc::new(RemoteCredentialDriver::new(channel)), process: None, + negotiated_extension, }) } } @@ -1545,7 +1590,7 @@ async fn spawn_uds_driver( command_path.display() )) })?; - let channel = wait_for_launched_credential_driver( + let (channel, negotiated_extension) = wait_for_launched_credential_driver( driver_name, socket_path, &mut child, @@ -1559,6 +1604,7 @@ async fn spawn_uds_driver( Ok(BuiltCredentialDriver { driver: Arc::new(RemoteCredentialDriver::new(channel)), process: Some(process), + negotiated_extension, }) } @@ -1610,7 +1656,25 @@ async fn wait_for_launched_credential_driver( socket_path: &Path, child: &mut tokio::process::Child, timeout: Duration, -) -> CoreResult { +) -> CoreResult<(Channel, NegotiatedExtension)> { + wait_for_launched_credential_driver_with(driver_name, socket_path, child, timeout, || { + connect_ready_credential_driver(driver_name, socket_path) + }) + .await +} + +#[cfg(unix)] +async fn wait_for_launched_credential_driver_with( + driver_name: &str, + socket_path: &Path, + child: &mut tokio::process::Child, + timeout: Duration, + mut connect: F, +) -> CoreResult<(Channel, NegotiatedExtension)> +where + F: FnMut() -> Fut, + Fut: Future>, +{ let deadline = Instant::now() + timeout; let mut last_error: Option = None; @@ -1635,14 +1699,12 @@ async fn wait_for_launched_credential_driver( ))); } - match tokio::time::timeout( - remaining, - connect_ready_credential_driver(driver_name, socket_path), - ) - .await - { - Ok(Ok(channel)) => return Ok(channel), - Ok(Err(err)) => last_error = Some(err.to_string()), + match tokio::time::timeout(remaining, connect()).await { + Ok(Ok(connected)) => return Ok(connected), + Ok(Err(error)) if error.is_retryable() => { + last_error = Some(error.into_error().to_string()); + } + Ok(Err(error)) => return Err(error.into_error()), Err(_) => { return Err(Error::execution(format!( "timed out waiting for credential driver '{driver_name}' to respond to GetCapabilities" @@ -1662,19 +1724,81 @@ async fn wait_for_launched_credential_driver( } } +#[cfg(unix)] +#[derive(Debug)] +enum CredentialDriverReadinessError { + Retryable(Error), + RpcStatus { driver_name: String, status: Status }, + Terminal(Error), +} + +#[cfg(unix)] +impl CredentialDriverReadinessError { + fn rpc_status(driver_name: &str, status: Status) -> Self { + Self::RpcStatus { + driver_name: driver_name.to_string(), + status, + } + } + + fn is_retryable(&self) -> bool { + match self { + Self::Retryable(_) => true, + Self::RpcStatus { status, .. } => matches!( + status.code(), + tonic::Code::Unavailable + | tonic::Code::DeadlineExceeded + | tonic::Code::ResourceExhausted + | tonic::Code::Aborted + | tonic::Code::Internal + | tonic::Code::Unknown + ), + Self::Terminal(_) => false, + } + } + + fn into_error(self) -> Error { + match self { + Self::Retryable(error) | Self::Terminal(error) => error, + Self::RpcStatus { + driver_name, + status, + } => Error::config(format!( + "credential driver '{driver_name}' GetCapabilities failed: {status}" + )), + } + } +} + #[cfg(unix)] async fn connect_ready_credential_driver( driver_name: &str, socket_path: &Path, -) -> CoreResult { - let channel = connect_credential_driver_socket(driver_name, socket_path).await?; +) -> Result<(Channel, NegotiatedExtension), CredentialDriverReadinessError> { + let channel = connect_credential_driver_socket(driver_name, socket_path) + .await + .map_err(CredentialDriverReadinessError::Retryable)?; let mut client = CredentialDriverClient::new(channel.clone()); - let mut request = Request::new(GetCredentialDriverCapabilitiesRequest {}); + let gateway = gateway_metadata(ExtensionFamily::Credentials); + let mut request = Request::new(GetCredentialDriverCapabilitiesRequest { + gateway: Some(gateway.clone()), + }); let timeout = Duration::from_secs(DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS); request.set_timeout(timeout); - await_credential_driver_capabilities(driver_name, timeout, client.get_capabilities(request)) - .await?; - Ok(channel) + let capabilities = await_credential_driver_capabilities( + driver_name, + timeout, + client.get_capabilities(request), + ) + .await?; + let negotiated_extension = negotiate( + ExtensionFamily::Credentials, + driver_name, + &gateway, + capabilities.extension, + ) + .map_err(|error| CredentialDriverReadinessError::Terminal(Error::config(error.to_string())))?; + Ok((channel, negotiated_extension)) } #[cfg(unix)] @@ -1684,20 +1808,16 @@ async fn await_credential_driver_capabilities( response: impl Future< Output = Result, Status>, >, -) -> CoreResult<()> { +) -> Result { tokio::time::timeout(timeout, response) .await .map_err(|_| { - Error::config(format!( + CredentialDriverReadinessError::Retryable(Error::config(format!( "credential driver '{driver_name}' GetCapabilities timed out" - )) + ))) })? - .map_err(|status| { - Error::config(format!( - "credential driver '{driver_name}' GetCapabilities failed: {status}" - )) - })?; - Ok(()) + .map_err(|status| CredentialDriverReadinessError::rpc_status(driver_name, status)) + .map(tonic::Response::into_inner) } #[cfg(unix)] @@ -1957,6 +2077,23 @@ mod tests { ); } + #[test] + fn built_in_credential_driver_uses_common_negotiation_snapshot() { + let runtime = CredentialRuntime::from_config( + &Config::new(None).with_credential_drivers(["test-static"]), + ) + .unwrap(); + + let negotiated = runtime.negotiated_extensions(); + assert_eq!(negotiated.len(), 1); + assert_eq!(negotiated[0].family, ExtensionFamily::Credentials); + assert_eq!(negotiated[0].configured_name, "test-static"); + assert_eq!( + negotiated[0].protocol_major, + openshell_core::extension_protocol::PROTOCOL_MAJOR + ); + } + #[test] fn registry_allows_legacy_inline_credentials_with_default_driver() { let registry = CredentialDriverRegistry::from_config(&Config::new(None)).unwrap(); @@ -2447,11 +2584,85 @@ socket_path = {socket_path_toml} response, ) .await - .unwrap_err(); + .unwrap_err() + .into_error(); assert!(err.to_string().contains("GetCapabilities timed out")); } + #[cfg(unix)] + #[tokio::test] + async fn launched_driver_failed_precondition_is_terminal() { + let mut child = Command::new("sleep") + .arg("30") + .kill_on_drop(true) + .spawn() + .unwrap(); + let started = Instant::now(); + + let err = wait_for_launched_credential_driver_with( + "enterprise-secrets", + Path::new("/unused-test-socket"), + &mut child, + Duration::from_secs(30), + || async { + await_credential_driver_capabilities( + "enterprise-secrets", + Duration::from_secs(30), + std::future::ready(Err(Status::failed_precondition( + "credentials extension 'enterprise-secrets' uses unsupported protocol 2.0; gateway supports 1.0", + ))), + ) + .await?; + unreachable!("failed-precondition response cannot produce capabilities") + }, + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("unsupported protocol 2.0")); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[cfg(unix)] + #[test] + fn credential_driver_rpc_status_retries_only_transient_failures() { + for code in [ + Code::Unavailable, + Code::DeadlineExceeded, + Code::ResourceExhausted, + Code::Aborted, + Code::Internal, + Code::Unknown, + ] { + assert!( + CredentialDriverReadinessError::rpc_status( + "enterprise-secrets", + Status::new(code, "not ready"), + ) + .is_retryable(), + "{code:?} should be retried" + ); + } + + for code in [ + Code::InvalidArgument, + Code::FailedPrecondition, + Code::PermissionDenied, + Code::Unauthenticated, + Code::Unimplemented, + ] { + assert!( + !CredentialDriverReadinessError::rpc_status( + "enterprise-secrets", + Status::new(code, "incompatible"), + ) + .is_retryable(), + "{code:?} should fail immediately" + ); + } + } + #[test] fn parse_driver_table_preserves_backend_config_without_transport_fields() { let parsed = parse_driver_table( diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 9994208c3..9c7761be9 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -14,6 +14,7 @@ mod service; mod validation; pub mod workspace; +use openshell_core::extension_protocol::{ExtensionFamily, NegotiatedExtension}; use openshell_core::proto::{ AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, @@ -30,14 +31,14 @@ use openshell_core::proto::{ DeleteWorkspaceRequest, DeleteWorkspaceResponse, DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, - ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, FinalizeMainProcessExitRequest, - FinalizeMainProcessExitResponse, GatewayMessage, GetCurrentUserRequest, GetCurrentUserResponse, - GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetGatewayInfoRequest, - GetGatewayInfoResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, - GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, - GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, ExtensionKind, + FinalizeMainProcessExitRequest, FinalizeMainProcessExitResponse, GatewayMessage, + GetCurrentUserRequest, GetCurrentUserResponse, GetDraftHistoryRequest, GetDraftHistoryResponse, + GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetGatewayInfoRequest, GetGatewayInfoResponse, + GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, + GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxProviderStatusRequest, GetSandboxProviderStatusResponse, GetSandboxRequest, GetSandboxTemplateRequest, GetServiceRequest, GetWorkspaceRequest, GetWorkspaceResponse, @@ -49,20 +50,21 @@ use openshell_core::proto::{ ListSandboxProvidersResponse, ListSandboxTemplatesRequest, ListSandboxTemplatesResponse, ListSandboxesRequest, ListSandboxesResponse, ListServicesRequest, ListServicesResponse, ListWorkspaceMembersRequest, ListWorkspaceMembersResponse, ListWorkspacesRequest, - ListWorkspacesResponse, MemoryResourceCapabilities, ProviderProfileResponse, ProviderResponse, - PushSandboxLogsRequest, PushSandboxLogsResponse, RefreshSandboxTokenRequest, - RefreshSandboxTokenResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, - RemoveWorkspaceMemberRequest, RemoveWorkspaceMemberResponse, ReportEndpointStatusRequest, - ReportEndpointStatusResponse, ReportMainProcessExitRequest, ReportMainProcessExitResponse, - ReportPolicyStatusRequest, ReportPolicyStatusResponse, ReportProviderReadinessRequest, - ReportProviderReadinessResponse, ResourceCapabilities, RevokeSshSessionRequest, - RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, - SandboxResponse, SandboxTemplateResponse, ServiceEndpointResponse, ServiceStatus, - StartSandboxRequest, StopSandboxRequest, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, - UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, - WatchSandboxRequest, open_shell_server::OpenShell, + ListWorkspacesResponse, MemoryResourceCapabilities, NegotiatedExtensionInfo, PeerRelayFrame, + ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, + RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RejectDraftChunkRequest, + RejectDraftChunkResponse, RelayFrame, RemoveWorkspaceMemberRequest, + RemoveWorkspaceMemberResponse, ReportEndpointStatusRequest, ReportEndpointStatusResponse, + ReportMainProcessExitRequest, ReportMainProcessExitResponse, ReportPolicyStatusRequest, + ReportPolicyStatusResponse, ReportProviderReadinessRequest, ReportProviderReadinessResponse, + ResourceCapabilities, RevokeSshSessionRequest, RevokeSshSessionResponse, + RotateProviderCredentialRequest, RotateProviderCredentialResponse, SandboxResponse, + SandboxTemplateResponse, ServiceEndpointResponse, ServiceStatus, StartSandboxRequest, + StopSandboxRequest, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, + UpdateConfigRequest, UpdateConfigResponse, UpdateProviderProfilesRequest, + UpdateProviderProfilesResponse, UpdateProviderRequest, WatchSandboxRequest, + open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -249,6 +251,28 @@ impl OpenShell for OpenShellService { &self, _request: Request, ) -> Result, Status> { + let mut negotiated_extensions = self + .state + .compute + .driver_info_snapshots() + .iter() + .map(|driver| driver.negotiated_extension.clone()) + .collect::>(); + negotiated_extensions.extend_from_slice(self.state.credentials.negotiated_extensions()); + negotiated_extensions + .extend_from_slice(self.state.middleware_registry.negotiated_extensions()); + if let Some(interceptors) = self.state.gateway_interceptors.as_ref() { + negotiated_extensions.extend_from_slice(interceptors.negotiated_extensions()); + } + negotiated_extensions.sort_by(|left, right| { + left.family + .cmp(&right.family) + .then_with(|| left.configured_name.cmp(&right.configured_name)) + }); + let extensions = negotiated_extensions + .iter() + .map(public_extension_info) + .collect(); let compute_drivers = self .state .compute @@ -271,6 +295,7 @@ impl OpenShell for OpenShellService { status: ServiceStatus::Healthy.into(), gateway_version: openshell_core::VERSION.to_string(), compute_drivers, + extensions, })) } @@ -833,6 +858,56 @@ impl OpenShell for OpenShellService { ) -> Result, Status> { workspace::handle_list_workspace_members(&self.state, request).await } + + type PeerRelayStream = + Pin> + Send + 'static>>; + + async fn peer_relay( + &self, + request: Request>, + ) -> Result, Status> { + crate::supervisor_session::handle_peer_relay(&self.state, request).await + } + + async fn peer_report_provider_readiness( + &self, + request: Request, + ) -> Result, Status> { + provider_readiness::handle_peer_report_provider_readiness(&self.state, request).await + } + + async fn peer_report_endpoint_status( + &self, + request: Request, + ) -> Result, Status> { + policy::handle_peer_report_endpoint_status(&self.state, request).await + } + + async fn peer_get_sandbox_provider_status( + &self, + request: Request, + ) -> Result, Status> { + provider_readiness::handle_peer_get_sandbox_provider_status(&self.state, request).await + } +} + +fn public_extension_info(extension: &NegotiatedExtension) -> NegotiatedExtensionInfo { + let kind = match extension.family { + ExtensionFamily::Compute => ExtensionKind::ComputeDriver, + ExtensionFamily::Credentials => ExtensionKind::CredentialDriver, + ExtensionFamily::GatewayInterceptor => ExtensionKind::GatewayInterceptor, + ExtensionFamily::SupervisorMiddleware => ExtensionKind::SupervisorMiddleware, + }; + NegotiatedExtensionInfo { + kind: kind.into(), + configured_name: extension.configured_name.clone(), + implementation_name: extension.implementation_name.clone(), + implementation_version: extension.implementation_version.clone(), + protocol_major: extension.protocol_major, + protocol_minor: extension.protocol_minor, + supported_capabilities: extension.supported_capabilities.clone(), + required_capabilities: extension.required_capabilities.clone(), + } } fn public_resource_capabilities( @@ -1063,4 +1138,26 @@ mod tests { let absent: Option = None; assert!(absent.map(public_resource_capabilities).is_none()); } + + #[test] + fn public_extension_snapshot_contains_only_negotiated_metadata() { + let negotiated = NegotiatedExtension { + family: ExtensionFamily::Credentials, + configured_name: "vault".to_string(), + implementation_name: "openshell/vault".to_string(), + implementation_version: "1.2.3".to_string(), + protocol_major: 1, + protocol_minor: 2, + supported_capabilities: vec!["openshell.credentials.contract".to_string()], + required_capabilities: vec!["openshell.credentials.contract".to_string()], + }; + + let public = public_extension_info(&negotiated); + + assert_eq!(public.kind, i32::from(ExtensionKind::CredentialDriver)); + assert_eq!(public.configured_name, "vault"); + assert_eq!(public.implementation_name, "openshell/vault"); + assert_eq!(public.protocol_major, 1); + assert_eq!(public.protocol_minor, 2); + } } diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs index 2b09ed030..48c014638 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs @@ -154,6 +154,8 @@ pub(in crate::grpc) enum Outcome { Sandbox { id: String, changed: bool, + #[serde(default)] + service_urls: HashMap, }, SandboxDeletion { id: String, @@ -409,10 +411,15 @@ macro_rules! sandbox_scoped_mutation { }; } -fn sandbox_receipt(sandbox: Option<&Sandbox>, changed: bool) -> Result { +fn sandbox_receipt( + sandbox: Option<&Sandbox>, + changed: bool, + service_urls: HashMap, +) -> Result { Ok(Outcome::Sandbox { id: sandbox.ok_or_else(uncertain)?.object_id().into(), changed, + service_urls, }) } @@ -426,7 +433,8 @@ macro_rules! sandbox_mutation { User, |response: &Response| sandbox_receipt( response.get_ref().sandbox.as_ref(), - false + false, + HashMap::new(), ), async |store: &Store, outcome: Outcome| { let Outcome::Sandbox { id, .. } = outcome else { @@ -434,6 +442,7 @@ macro_rules! sandbox_mutation { }; Ok(SandboxResponse { sandbox: Some(live(store, &id).await?), + service_urls: HashMap::new(), }) } ); @@ -447,14 +456,19 @@ scoped_mutation!( User, |response: &Response| sandbox_receipt( response.get_ref().sandbox.as_ref(), - false + false, + response.get_ref().service_urls.clone(), ), async |store: &Store, outcome: Outcome| { - let Outcome::Sandbox { id, .. } = outcome else { + let Outcome::Sandbox { + id, service_urls, .. + } = outcome + else { return Err(replay_unavailable()); }; Ok(SandboxResponse { sandbox: Some(live(store, &id).await?), + service_urls, }) } ); diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index d2689e3be..fa1a54d7b 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -6,8 +6,8 @@ use crate::grpc::test_support::{authed_request, test_server_state}; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::open_shell_server::OpenShell; use openshell_core::proto::{ - SandboxWorkloadConfig, SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, WorkspaceMember, - WorkspaceRole, + CreateSandboxRequest, SandboxServiceExposure, SandboxSpec, SandboxWorkloadConfig, + SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, WorkspaceMember, WorkspaceRole, }; use openshell_core::rpc_error::StatusExt; use std::collections::HashMap; @@ -91,6 +91,48 @@ fn canonical_payload_ignores_map_order_and_id_but_preserves_presence() { assert_ne!(absent, fingerprint(&template).unwrap()); } +#[tokio::test] +async fn create_sandbox_replay_preserves_service_urls() { + let directory = tempfile::tempdir().unwrap(); + let key = directory.path().join("private-key"); + std::fs::write(&key, b"test-only stable private fingerprint material").unwrap(); + let mut state = test_server_state().await; + Arc::get_mut(&mut state).unwrap().config.gateway_jwt = + Some(openshell_core::config::GatewayJwtConfig { + signing_key_path: key, + public_key_path: directory.path().join("public"), + kid_path: directory.path().join("kid"), + gateway_id: "test".into(), + ttl_secs: None, + }); + let service = crate::grpc::OpenShellService::new(state); + let request = CreateSandboxRequest { + name: "replay-services".into(), + spec: Some(SandboxSpec::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + service_exposures: vec![SandboxServiceExposure { + service: "web".into(), + target_port: 8080, + }], + request_id: uuid::Uuid::new_v4().to_string(), + ..Default::default() + }; + + let original = service + .create_sandbox(authed_request(request.clone())) + .await + .unwrap() + .into_inner(); + let replay = service + .create_sandbox(authed_request(request)) + .await + .unwrap(); + + assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true"); + assert_eq!(replay.get_ref().service_urls, original.service_urls); + assert_eq!(replay.into_inner(), original); +} + async fn exercise_backend(url: &str) { let first = state_for(Store::connect(url).await.unwrap()).await; let second = state_for(Store::connect(url).await.unwrap()).await; diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index c9863813e..3324813ad 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -14,7 +14,9 @@ mod endpoint_status; mod provisioning_clock; pub use provisioning_clock::configuration_change; -pub(super) use endpoint_status::handle_report_endpoint_status; +pub(super) use endpoint_status::{ + handle_peer_report_endpoint_status, handle_report_endpoint_status, +}; pub use endpoint_status::{ invalidate_endpoint_status_on_startup, reset_endpoint_status_for_supervisor_session, retry_endpoint_status_after_supervisor_disconnect, @@ -2528,6 +2530,9 @@ async fn resolve_sandbox_by_name_for_principal( Ok(sandbox) } Principal::User(_) => sandbox.ok_or_else(|| Status::not_found("sandbox not found")), + Principal::Peer(_) => Err(Status::permission_denied( + "gateway peer principals may not resolve sandbox configuration", + )), Principal::Anonymous => Err(Status::unauthenticated( "sandbox-scoped methods require an authenticated caller", )), @@ -3598,7 +3603,9 @@ async fn handle_update_config_inner( )); } let _settings_guard = state.settings_mutex.lock().await; - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire policy mutation lock") + })?; if has_merge_ops { return Err(Status::invalid_argument( @@ -3787,7 +3794,9 @@ async fn handle_update_config_inner( if has_setting { let _settings_guard = state.settings_mutex.lock().await; - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire policy mutation lock") + })?; if key == POLICY_SETTING_KEY { return Err(Status::invalid_argument( @@ -3882,7 +3891,9 @@ async fn handle_update_config_inner( )); } - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire policy mutation lock") + })?; if has_merge_ops { let global_settings = load_global_settings(state.store.as_ref()).await?; if global_settings.settings.contains_key(POLICY_SETTING_KEY) { @@ -4413,7 +4424,9 @@ pub(super) async fn handle_report_sandbox_configuration( if reported == ConfigurationAdmissionState::Unspecified { return Err(Status::invalid_argument("admission state is required")); } - let _guard = state.compute.sandbox_sync_guard().await; + let _guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire configuration admission lock") + })?; let mut sandbox = state .store .get_message::(&sandbox_id) @@ -4613,7 +4626,9 @@ pub(super) async fn handle_report_policy_status( .supersede_older_policies(&req.sandbox_id, version) .await; - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire policy mutation lock") + })?; let sandbox = state .store .get_message::(&req.sandbox_id) diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs index 0081d58aa..462099198 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -13,6 +13,7 @@ use crate::ServerState; use crate::persistence::{ObjectId, ObjectWorkspace}; use crate::policy_store::PolicyStoreExt; use crate::provider_profile_sources::EffectiveProviderProfileCatalog; +use crate::supervisor_owner::{OWNER_TTL, SupervisorOwnerIndex}; use crate::supervisor_session::EndpointReportCursor; use openshell_core::GetResourceVersion; use openshell_core::endpoint_status::initial_endpoint_status; @@ -61,6 +62,37 @@ pub(in crate::grpc) async fn handle_report_endpoint_status( let sandbox_id = request.get_ref().sandbox_id.clone(); crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; let req = request.into_inner(); + if let Some(owner) = + crate::supervisor_session::remote_supervisor_owner(state, &sandbox_id).await? + { + let response = + crate::supervisor_session::forward_endpoint_status_to_owner(state, &owner, req).await?; + return Ok(Response::new(response)); + } + handle_report_endpoint_status_inner(state, req).await +} + +pub(in crate::grpc) async fn handle_peer_report_endpoint_status( + state: &Arc, + request: Request, +) -> Result, Status> { + if !matches!( + request + .extensions() + .get::(), + Some(crate::auth::principal::Principal::Peer(_)) + ) { + return Err(Status::permission_denied( + "gateway peer principal is required", + )); + } + handle_report_endpoint_status_inner(state, request.into_inner()).await +} + +async fn handle_report_endpoint_status_inner( + state: &Arc, + req: ReportEndpointStatusRequest, +) -> Result, Status> { if req.sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } @@ -79,7 +111,9 @@ pub(in crate::grpc) async fn handle_report_endpoint_status( // Session validation, configuration derivation, and persistence share the // sandbox mutation boundary. A newly registered supervisor can therefore // invalidate its predecessor before any stale report reaches the CAS. - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::super::persistence_error_to_status(error, "acquire endpoint status mutation lock") + })?; if !state .supervisor_sessions .is_endpoint_status_authority(&req.sandbox_id, &req.supervisor_session_id) @@ -328,7 +362,9 @@ pub async fn reset_endpoint_status_for_supervisor_session( sandbox_id: &str, supervisor_session_id: &str, ) -> Result<(), Status> { - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::super::persistence_error_to_status(error, "acquire endpoint status mutation lock") + })?; if !state .supervisor_sessions .is_current_session(sandbox_id, supervisor_session_id) @@ -373,7 +409,9 @@ pub async fn reset_endpoint_status_after_supervisor_disconnect( state: &Arc, sandbox_id: &str, ) -> Result<(), Status> { - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::super::persistence_error_to_status(error, "acquire endpoint status mutation lock") + })?; if state .supervisor_sessions .current_session_id(sandbox_id) @@ -381,6 +419,9 @@ pub async fn reset_endpoint_status_after_supervisor_disconnect( { return Ok(()); } + if has_fresh_shared_owner(state, sandbox_id).await? { + return Ok(()); + } let sandbox = state .store .get_message::(sandbox_id) @@ -441,9 +482,15 @@ pub async fn retry_endpoint_status_after_supervisor_disconnect( /// Invalidate endpoint results left by sessions from an earlier gateway process. /// /// Supervisor sessions are intentionally process-local. This reconciliation -/// runs before gateway listeners are bound, so persisted success can never be -/// served without a session in the current process that owns the observation. +/// runs before gateway listeners are bound. A fresh shared owner preserves its +/// evidence; records without one are reset so stale success is never served. pub async fn invalidate_endpoint_status_on_startup(state: &Arc) -> Result<(), Status> { + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::super::persistence_error_to_status( + error, + "acquire endpoint status startup reconciliation lock", + ) + })?; let mut offset = 0; loop { let sandboxes = state @@ -468,6 +515,9 @@ pub async fn invalidate_endpoint_status_on_startup(state: &Arc) -> continue; } let sandbox_id = sandbox.object_id(); + if has_fresh_shared_owner(state, sandbox_id).await? { + continue; + } let expected_resource_version = sandbox.get_resource_version(); let updated = state .store @@ -498,6 +548,17 @@ pub async fn invalidate_endpoint_status_on_startup(state: &Arc) -> } } +async fn has_fresh_shared_owner( + state: &Arc, + sandbox_id: &str, +) -> Result { + SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL) + .read(sandbox_id) + .await + .map(|owner| owner.is_some_and(|owner| owner.is_fresh(OWNER_TTL))) + .map_err(|error| Status::unavailable(format!("resolve supervisor owner failed: {error}"))) +} + fn invalidate_endpoint_status_without_session(sandbox: &mut Sandbox) { let Some(status) = sandbox.status.as_mut() else { return; diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs index cd3f863cf..4c951863a 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -408,7 +408,7 @@ async fn global_policy_update_waits_for_endpoint_report_guard() { let before = load_global_settings(state.store.as_ref()) .await .expect("read settings before update"); - let guard = state.compute.sandbox_sync_guard().await; + let guard = state.compute.sandbox_sync_guard().await.unwrap(); let mut pending = Box::pin(handle_update_config(&state, authed_request(update))); // Poll the actual writer while an endpoint report owns the mutation diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a2563babe..7cbc2749f 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2548,7 +2548,9 @@ pub(super) async fn handle_create_provider( )); } let provider_type = provider.r#type.clone(); - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire provider mutation lock") + })?; let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) @@ -2775,7 +2777,10 @@ pub(super) async fn handle_import_provider_profiles( .ensure_active()?; let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = + state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire provider mutation lock") + })?; let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) @@ -2867,7 +2872,10 @@ pub(super) async fn handle_update_provider_profiles( let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let target_id = normalize_profile_id_request(&request.id)?; - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = + state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire provider mutation lock") + })?; let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) @@ -3027,7 +3035,10 @@ pub(super) async fn handle_delete_provider_profile( .name; let id = req.id; let id = normalize_profile_id_request(&id)?; - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = + state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire provider mutation lock") + })?; let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) @@ -3831,7 +3842,9 @@ pub(super) async fn handle_update_provider( // Provider material contributes to the route-report configuration epoch. // Serialize its mutation with route-status validation so a report derived // from the prior revision cannot commit after this update. - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| { + super::persistence_error_to_status(error, "acquire provider mutation lock") + })?; let Some(mut provider) = req.provider else { emit_provider_lifecycle( "custom", @@ -4587,7 +4600,10 @@ pub(super) async fn handle_configure_provider_refresh( // configures of providers attached to the same sandbox could each pass // validation before either persisted and both reserve the same key (CWE-362). // This is the same guard sandbox create/attach and profile changes take. - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = + state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire provider mutation lock") + })?; let provider = state .store @@ -5549,7 +5565,7 @@ mod tests { #[tokio::test] async fn import_provider_profile_waits_for_sandbox_sync_guard() { let state = test_server_state().await; - let guard = state.compute.sandbox_sync_guard().await; + let guard = state.compute.sandbox_sync_guard().await.unwrap(); let task_state = state.clone(); let task = tokio::spawn(async move { handle_import_provider_profiles( @@ -8730,7 +8746,7 @@ mod tests { .await .unwrap(); - let guard = state.compute.sandbox_sync_guard().await; + let guard = state.compute.sandbox_sync_guard().await.unwrap(); let task_state = state.clone(); let task = tokio::spawn(async move { handle_delete_provider_profile( @@ -8785,7 +8801,7 @@ mod tests { .await .unwrap(); - let guard = state.compute.sandbox_sync_guard().await; + let guard = state.compute.sandbox_sync_guard().await.unwrap(); let task_state = state.clone(); let task = tokio::spawn(async move { let mut provider = provider_with_values("guarded-provider", "guarded-create"); diff --git a/crates/openshell-server/src/grpc/provider_readiness.rs b/crates/openshell-server/src/grpc/provider_readiness.rs index 881975a86..3de9148b2 100644 --- a/crates/openshell-server/src/grpc/provider_readiness.rs +++ b/crates/openshell-server/src/grpc/provider_readiness.rs @@ -307,6 +307,26 @@ pub(super) async fn handle_get_sandbox_provider_status( MinWorkspaceRole::User, ) .await?; + if let Some(owner) = + crate::supervisor_session::remote_supervisor_owner(state, sandbox.object_id()).await? + { + let response = crate::supervisor_session::forward_provider_status_query_to_owner( + state, + &owner, + sandbox.object_id(), + request, + ) + .await?; + return Ok(Response::new(response)); + } + handle_get_sandbox_provider_status_resolved(state, request, sandbox).await +} + +async fn handle_get_sandbox_provider_status_resolved( + state: &Arc, + request: GetSandboxProviderStatusRequest, + sandbox: Sandbox, +) -> Result, Status> { let workspace = sandbox.object_workspace().to_string(); // Validate the selector before it can contribute to a durable observation. if request.provider.len() > super::MAX_NAME_LEN { @@ -408,6 +428,31 @@ pub(super) async fn handle_get_sandbox_provider_status( })) } +pub(super) async fn handle_peer_get_sandbox_provider_status( + state: &Arc, + request: Request, +) -> Result, Status> { + ensure_peer_request(&request)?; + let request = request.into_inner(); + let workspace = + crate::auth::workspace_authz::selected_workspace_name(request.workspace_scope.as_ref())?; + if request.sandbox.is_empty() { + return Err(Status::invalid_argument("sandbox is required")); + } + let sandbox = state + .store + .get_message_by_name::(workspace, &request.sandbox) + .await + .map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + if !state.supervisor_sessions.has_session(sandbox.object_id()) { + return Err(Status::failed_precondition( + "gateway peer does not own the sandbox supervisor session", + )); + } + handle_get_sandbox_provider_status_resolved(state, request, sandbox).await +} + fn observation_matches( observation: &ProviderReadinessObservation, desired: &ProviderDesiredIdentity, @@ -568,9 +613,33 @@ pub(super) async fn handle_report_provider_readiness( ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); authorize_provider_readiness(&request, &sandbox_id)?; + let request = request.into_inner(); + if let Some(owner) = + crate::supervisor_session::remote_supervisor_owner(state, &sandbox_id).await? + { + let response = + crate::supervisor_session::forward_provider_readiness_to_owner(state, &owner, request) + .await?; + return Ok(Response::new(response)); + } + handle_report_provider_readiness_inner(state, request).await +} + +pub(super) async fn handle_peer_report_provider_readiness( + state: &Arc, + request: Request, +) -> Result, Status> { + ensure_peer_request(&request)?; + handle_report_provider_readiness_inner(state, request.into_inner()).await +} + +async fn handle_report_provider_readiness_inner( + state: &Arc, + request: ReportProviderReadinessRequest, +) -> Result, Status> { + let sandbox_id = request.sandbox_id.clone(); canonical_uuid(&sandbox_id)?; let observation = request - .into_inner() .observation .ok_or_else(|| Status::invalid_argument("provider readiness observation is required"))?; canonical_uuid(&observation.session_id)?; @@ -615,6 +684,21 @@ pub(super) async fn handle_report_provider_readiness( })) } +fn ensure_peer_request(request: &Request) -> Result<(), Status> { + if matches!( + request + .extensions() + .get::(), + Some(crate::auth::principal::Principal::Peer(_)) + ) { + Ok(()) + } else { + Err(Status::permission_denied( + "gateway peer principal is required", + )) + } +} + #[cfg(test)] #[path = "provider_readiness_tests.rs"] mod tests; diff --git a/crates/openshell-server/src/grpc/provider_readiness_tests.rs b/crates/openshell-server/src/grpc/provider_readiness_tests.rs index 1bdf4aa63..d83bb80ef 100644 --- a/crates/openshell-server/src/grpc/provider_readiness_tests.rs +++ b/crates/openshell-server/src/grpc/provider_readiness_tests.rs @@ -70,6 +70,7 @@ fn hello() -> SupervisorHello { SupervisorHello { sandbox_id: Uuid::new_v4().to_string(), instance_id: Uuid::new_v4().to_string(), + connection_epoch: 0, supports_provider_readiness: true, } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index f3ba04a2c..bffdc6ebb 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -46,7 +46,7 @@ use openshell_core::telemetry::{ use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; use prost_types::{Struct, Value, value::Kind}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::IpAddr; use std::pin::Pin; use std::sync::Arc; @@ -75,6 +75,7 @@ use crate::persistence::current_time_ms; const TCP_FORWARD_CHUNK_SIZE: usize = 64 * 1024; const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); const MAX_TEMPLATES_PER_WORKSPACE: u32 = 1000; +const MAX_CREATE_SERVICE_EXPOSURES: usize = 32; #[derive(Debug)] pub struct WatchSandboxStream { @@ -452,7 +453,9 @@ async fn handle_create_sandbox_inner( let _sandbox_sync_guard = if spec.providers.is_empty() { None } else { - Some(state.compute.sandbox_sync_guard().await) + Some(state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire sandbox mutation lock") + })?) }; // Validate provider names exist (fail fast). @@ -627,6 +630,46 @@ async fn handle_create_sandbox_inner( ) .await?; + let mut service_urls = HashMap::with_capacity(request.service_exposures.len()); + for exposure in &request.service_exposures { + let endpoint = match super::service::expose_service_endpoint( + state, + sandbox.object_workspace(), + &sandbox, + &exposure.service, + exposure.target_port, + ) + .await + { + Ok(endpoint) => endpoint, + Err(exposure_error) => { + let rollback = state + .compute + .delete_sandbox_by_id(sandbox.object_id(), sandbox.object_name()) + .await; + if let Err(rollback_error) = rollback { + warn!( + sandbox_id = %sandbox.object_id(), + sandbox_name = %sandbox.object_name(), + service_name = %exposure.service, + exposure_error = %exposure_error, + rollback_error = %rollback_error, + "Failed to roll back sandbox after service exposure failed" + ); + return Err(Status::internal(format!( + "create sandbox failed while exposing service '{}': {}; rollback failed: {}; sandbox '{}' may require manual deletion", + exposure.service, + exposure_error.message(), + rollback_error.message(), + sandbox.object_name(), + ))); + } + return Err(exposure_error); + } + }; + service_urls.insert(exposure.service.clone(), endpoint.into_inner().url); + } + info!( sandbox_id = %id, sandbox_name = %name, @@ -634,6 +677,7 @@ async fn handle_create_sandbox_inner( ); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), + service_urls, })) } @@ -648,6 +692,22 @@ fn validate_create_sandbox_request_pre_io( } crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; + if request.service_exposures.len() > MAX_CREATE_SERVICE_EXPOSURES { + return Err(Status::invalid_argument(format!( + "service_exposures must contain at most {MAX_CREATE_SERVICE_EXPOSURES} entries" + ))); + } + let mut service_names = HashSet::with_capacity(request.service_exposures.len()); + for exposure in &request.service_exposures { + super::service::validate_service_exposure_request(&exposure.service, exposure.target_port)?; + if !service_names.insert(exposure.service.as_str()) { + return Err(Status::invalid_argument(format!( + "duplicate service exposure name: '{}'", + exposure.service + ))); + } + } + if workload_template_name.is_empty() { let spec = request .spec @@ -782,6 +842,7 @@ pub(super) async fn handle_get_sandbox( .await?; Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -1174,7 +1235,10 @@ pub(super) async fn handle_attach_sandbox_provider( if let Some(probe) = attach_wait_probe { probe.notify_one(); } - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = + state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire sandbox mutation lock") + })?; let provider_record = get_provider_record(state.store.as_ref(), &workspace, &request.provider) .await .map_err(|err| { @@ -1344,7 +1408,10 @@ pub(super) async fn handle_detach_sandbox_provider( ))); } - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let _sandbox_sync_guard = + state.compute.sandbox_sync_guard().await.map_err(|err| { + super::persistence_error_to_status(err, "acquire sandbox mutation lock") + })?; let sandbox_name = sandbox.object_name().to_string(); let sandbox_id = sandbox .metadata @@ -1531,6 +1598,7 @@ async fn handle_stop_sandbox_inner( info!(sandbox_name = %name, "StopSandbox request completed successfully"); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -1567,34 +1635,25 @@ async fn handle_start_sandbox_inner( .await?; let workspace = resolved.object_workspace().to_string(); let name = resolved.object_name().to_string(); - let current = resolved; - let current_phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); - let launch_authentication = if current_phase == SandboxPhase::Ready { - Vec::new() - } else if state.sandbox_session_jwt_authority.is_some() { - let authentication = if matches!( - current_phase, - SandboxPhase::Stopped | SandboxPhase::Completed - ) { - mint_next_runtime_authentication(state, ¤t).await? - } else { - mint_persisted_authentication(state, ¤t)? - }; - serde_json::to_vec(&authentication) - .map_err(|error| Status::internal(format!("encode launch authentication: {error}")))? - } else { - Vec::new() - }; let mut sandbox = state .compute - .start_sandbox_authenticated(&workspace, &name, launch_authentication) + .start_sandbox_authenticated( + &workspace, + &name, + state.sandbox_session_jwt_authority.as_deref(), + ) .await?; + let remote_authority = + crate::supervisor_session::remote_supervisor_owner(state, sandbox.object_id()) + .await? + .is_some(); state .supervisor_sessions - .project_endpoint_status(&mut sandbox); + .project_endpoint_status(&mut sandbox, remote_authority); info!(sandbox_name = %name, "StartSandbox request completed successfully"); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), + service_urls: HashMap::new(), })) } @@ -1616,50 +1675,6 @@ pub fn mint_persisted_authentication( authority.mint_persisted_launch(sandbox.object_id(), &identity) } -async fn mint_next_runtime_authentication( - state: &Arc, - sandbox: &Sandbox, -) -> Result { - let authority = state - .sandbox_session_jwt_authority - .as_ref() - .ok_or_else(|| Status::failed_precondition("sandbox session authority is unavailable"))?; - let metadata = sandbox - .metadata - .as_ref() - .ok_or_else(|| Status::failed_precondition("sandbox metadata is missing"))?; - let current = - crate::auth::sandbox_session::PersistedSandboxIdentity::read(&metadata.annotations) - .map_err(|error| Status::failed_precondition(error.to_string()))?; - let next_epoch = current - .auth_epoch - .get() - .checked_add(1) - .and_then(|epoch| openshell_core::jwt::CredentialEpoch::new(epoch).ok()) - .ok_or_else(|| Status::internal("sandbox authorization epoch overflow"))?; - let next = crate::auth::sandbox_session::PersistedSandboxIdentity { - runtime_generation: current.runtime_generation, - auth_epoch: next_epoch, - gateway_token_id: uuid::Uuid::new_v4(), - refresh_replay: None, - }; - let authentication = authority.mint_persisted_launch(sandbox.object_id(), &next)?; - state - .store - .update_message_cas::( - sandbox.object_id(), - metadata.resource_version, - |updated| { - if let Some(metadata) = updated.metadata.as_mut() { - next.write(&mut metadata.annotations); - } - }, - ) - .await - .map_err(|error| Status::aborted(format!("persist sandbox runtime identity: {error}")))?; - Ok(authentication) -} - async fn providers_for_sandbox( state: &Arc, sandbox: &Sandbox, @@ -2010,11 +2025,15 @@ pub(super) async fn handle_exec_sandbox( // Open a relay channel through the supervisor session. Use a 15s // session-wait timeout, enough to cover a transient supervisor reconnect // while still failing quickly during normal operation. - let (channel_id, relay_rx) = state - .supervisor_sessions - .open_relay(sandbox.object_id(), std::time::Duration::from_secs(15)) - .await - .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; + let (channel_id, relay_rx) = crate::supervisor_session::open_routed_relay_with_target( + state, + sandbox.object_id(), + relay_open::Target::Ssh(SshRelayTarget {}), + String::new(), + std::time::Duration::from_secs(15), + ) + .await + .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; let command_str = build_remote_exec_command(&req) .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; @@ -2139,16 +2158,15 @@ pub(super) async fn handle_forward_tcp( } let connection_guard = acquire_forward_connection_guard(state, &init, &sandbox).await?; - let (channel_id, relay_rx) = state - .supervisor_sessions - .open_relay_with_target( - sandbox.object_id(), - target, - init.service_id.clone(), - std::time::Duration::from_secs(15), - ) - .await - .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; + let (channel_id, relay_rx) = crate::supervisor_session::open_routed_relay_with_target( + state, + sandbox.object_id(), + target, + init.service_id.clone(), + std::time::Duration::from_secs(15), + ) + .await + .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; let sandbox_id = sandbox.object_id().to_string(); let (tx, rx) = mpsc::channel::>(256); @@ -2462,11 +2480,15 @@ pub(super) async fn handle_exec_sandbox_interactive( return Err(Status::failed_precondition("sandbox is not ready")); } - let (channel_id, relay_rx) = state - .supervisor_sessions - .open_relay(sandbox.object_id(), std::time::Duration::from_secs(15)) - .await - .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; + let (channel_id, relay_rx) = crate::supervisor_session::open_routed_relay_with_target( + state, + sandbox.object_id(), + relay_open::Target::Ssh(SshRelayTarget {}), + String::new(), + std::time::Duration::from_secs(15), + ) + .await + .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; let command_str = build_remote_exec_command(&req) .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; @@ -3342,8 +3364,8 @@ mod tests { use crate::grpc::test_support::{ authed_request, test_server_state, test_server_state_with_driver, }; - use openshell_core::proto::GpuResourceRequirements; use openshell_core::proto::datamodel::v1::ObjectMeta; + use openshell_core::proto::{GpuResourceRequirements, SandboxServiceExposure, ServiceEndpoint}; // ---- shell_escape ---- @@ -3891,7 +3913,7 @@ mod tests { // Hold the global guard so the handler can resolve the original ID and // acquire its delete gate, but cannot yet revalidate or mutate it. - let global_guard = state.compute.sandbox_sync_guard().await; + let global_guard = state.compute.sandbox_sync_guard().await.unwrap(); let delete_state = state.clone(); let delete = tokio::spawn(async move { handle_delete_sandbox_inner( @@ -4444,6 +4466,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -4472,6 +4495,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -4509,6 +4533,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -4590,6 +4615,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -4965,6 +4991,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -5003,6 +5030,199 @@ mod tests { ); } + #[tokio::test] + async fn create_sandbox_registers_requested_service_exposures() { + let state = test_server_state().await; + + let response = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "services".to_string(), + spec: Some(SandboxSpec::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + service_exposures: vec![ + SandboxServiceExposure { + service: String::new(), + target_port: 4500, + }, + SandboxServiceExposure { + service: "metrics".to_string(), + target_port: 9090, + }, + ], + ..Default::default() + }), + ) + .await + .expect("sandbox with service exposures should be created") + .into_inner(); + + let sandbox = response.sandbox.expect("created sandbox"); + assert_eq!(response.service_urls.len(), 2); + assert_eq!( + response.service_urls.get("").map(String::as_str), + Some("http://default--services.openshell.localhost:17670/") + ); + assert_eq!( + response.service_urls.get("metrics").map(String::as_str), + Some("http://default--services--metrics.openshell.localhost:17670/") + ); + for (service, target_port) in [("", 4500), ("metrics", 9090)] { + let key = crate::service_routing::endpoint_key("services", service); + let endpoint = state + .store + .get_message_by_name::("default", &key) + .await + .expect("service endpoint lookup should succeed") + .expect("service endpoint should be persisted"); + assert_eq!(endpoint.sandbox_id, sandbox.object_id()); + assert_eq!(endpoint.sandbox, "services"); + assert_eq!(endpoint.name, service); + assert_eq!(endpoint.target_port, target_port); + assert!(endpoint.domain); + } + } + + #[tokio::test] + async fn create_sandbox_begins_rollback_when_service_exposure_fails() { + let state = test_server_state().await; + let corrupt_service_key = + crate::service_routing::endpoint_key("rollback-services", "metrics"); + state + .store + .put_if( + ServiceEndpoint::object_type(), + "corrupt-service-endpoint", + &corrupt_service_key, + "default", + b"not-a-service-endpoint", + None, + WriteCondition::MustCreate, + ) + .await + .expect("corrupt service endpoint fixture should be stored"); + + let error = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "rollback-services".to_string(), + spec: Some(SandboxSpec::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + service_exposures: vec![ + SandboxServiceExposure { + service: "web".to_string(), + target_port: 8080, + }, + SandboxServiceExposure { + service: "metrics".to_string(), + target_port: 9090, + }, + ], + ..Default::default() + }), + ) + .await + .expect_err("corrupt endpoint should fail sandbox creation"); + + assert_eq!(error.code(), tonic::Code::Internal); + assert!(error.message().contains("fetch endpoint failed")); + let sandbox = state + .store + .get_message_by_name::("default", "rollback-services") + .await + .expect("sandbox lookup should succeed") + .expect("asynchronous driver cleanup retains a deleting record"); + assert_eq!( + SandboxPhase::try_from(sandbox.phase()).ok(), + Some(SandboxPhase::Deleting), + "failed create must begin sandbox cleanup" + ); + } + + #[tokio::test] + async fn create_rollback_by_id_preserves_same_name_replacement() { + let state = test_server_state().await; + let original = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "rollback-replace".to_string(), + spec: Some(SandboxSpec::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner() + .sandbox + .unwrap(); + let original_id = original.object_id().to_string(); + let original_name = original.object_name().to_string(); + + state + .store + .delete(Sandbox::object_type(), &original_id) + .await + .unwrap(); + let mut replacement = original; + let replacement_id = uuid::Uuid::new_v4().to_string(); + let metadata = replacement.metadata.as_mut().unwrap(); + metadata.id.clone_from(&replacement_id); + metadata.resource_version = 0; + state.store.put_message(&replacement).await.unwrap(); + + state + .compute + .delete_sandbox_by_id(&original_id, &original_name) + .await + .unwrap(); + + let stored = state + .store + .get_message_by_name::("default", &original_name) + .await + .unwrap() + .expect("replacement must survive rollback for the original ID"); + assert_eq!(stored.object_id(), replacement_id); + } + + #[tokio::test] + async fn create_sandbox_rejects_duplicate_service_exposures_before_persisting() { + let state = test_server_state().await; + let error = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "duplicate-services".to_string(), + spec: Some(SandboxSpec::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + service_exposures: vec![ + SandboxServiceExposure { + service: "web".to_string(), + target_port: 8080, + }, + SandboxServiceExposure { + service: "web".to_string(), + target_port: 8081, + }, + ], + ..Default::default() + }), + ) + .await + .expect_err("duplicate service names should be rejected"); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("duplicate service exposure name")); + assert!( + state + .store + .get_message_by_name::("default", "duplicate-services") + .await + .expect("sandbox lookup should succeed") + .is_none() + ); + } + #[tokio::test] async fn create_and_get_preserve_partial_process_identity() { let state = test_server_state_with_driver("docker").await; @@ -5031,6 +5251,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -5101,6 +5322,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -5136,6 +5358,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -5154,7 +5377,7 @@ mod tests { .await .unwrap(); - let guard = state.compute.sandbox_sync_guard().await; + let guard = state.compute.sandbox_sync_guard().await.unwrap(); let task_state = state.clone(); let task = tokio::spawn(async move { handle_create_sandbox( @@ -5173,6 +5396,7 @@ mod tests { )), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }), ) .await @@ -5689,7 +5913,7 @@ mod tests { let message = pool .get_message_by_name(message_name) .expect("message descriptor"); - let classified: std::collections::HashSet<&str> = copied_from_create_request + let classified: HashSet<&str> = copied_from_create_request .iter() .chain(rejected_template_workload_overrides.iter()) .chain(generated_by_gateway.iter()) @@ -5702,7 +5926,7 @@ mod tests { + generated_by_gateway.len(), "every field must have exactly one create-time owner" ); - let actual: std::collections::HashSet = message + let actual: HashSet = message .fields() .map(|field| field.name().to_string()) .collect(); @@ -5742,7 +5966,7 @@ mod tests { .await .unwrap(); let supplied_epoch = uuid::Uuid::new_v4().to_string(); - let mut generated_epochs = std::collections::HashSet::new(); + let mut generated_epochs = HashSet::new(); for (name, workload_template_name) in [("direct-epoch", ""), ("template-epoch", "epoch-template")] { @@ -5832,6 +6056,7 @@ mod tests { )), workload_template: "gpu-kata".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -5920,6 +6145,7 @@ mod tests { )), workload_template: "default-image".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -5973,6 +6199,7 @@ mod tests { )), workload_template: "default-gpu".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -6013,6 +6240,7 @@ mod tests { )), workload_template: "corrupt-template".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -6054,6 +6282,7 @@ mod tests { )), workload_template: "gpu-kata".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -6080,6 +6309,7 @@ mod tests { )), workload_template: "Invalid_Template_Name".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -6109,6 +6339,7 @@ mod tests { )), workload_template: "missing-template".to_string(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await @@ -6138,6 +6369,7 @@ mod tests { )), workload_template: String::new(), await_main_process_attachment: false, + service_exposures: Vec::new(), }), ) .await diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 8d956761e..675b69565 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest, - ListServicesRequest, ListServicesResponse, ServiceEndpoint, ServiceEndpointResponse, + ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse, }; use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace}; use prost::Message as _; @@ -49,19 +49,37 @@ pub(super) async fn handle_expose_service( super::workspace::resolve_workspace(state.store.as_ref(), sandbox.object_workspace()) .await? .ensure_active()?; - let sandbox_name = sandbox.object_name(); - validate_optional_endpoint_name("service", &req.name, MAX_SERVICE_NAME_LEN)?; - if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { + validate_service_exposure_request(&req.name, req.target_port)?; + expose_service_endpoint(state, &workspace, &sandbox, &req.name, req.target_port).await +} + +pub(super) fn validate_service_exposure_request( + service: &str, + target_port: u32, +) -> Result<(), Status> { + validate_optional_endpoint_name("service", service, MAX_SERVICE_NAME_LEN)?; + if target_port == 0 || target_port > u32::from(u16::MAX) { return Err(Status::invalid_argument("target_port must be in 1..=65535")); } + Ok(()) +} + +pub(super) async fn expose_service_endpoint( + state: &Arc, + workspace: &str, + sandbox: &Sandbox, + service: &str, + target_port: u32, +) -> Result, Status> { + let sandbox_name = sandbox.object_name(); let now = crate::persistence::current_time_ms(); - let key = service_routing::endpoint_key(sandbox_name, &req.name); + let key = service_routing::endpoint_key(sandbox_name, service); // Fetch existing endpoint to determine create vs. update path let existing = state .store - .get_message_by_name::(&workspace, &key) + .get_message_by_name::(workspace, &key) .await .map_err(|e| Status::internal(format!("fetch endpoint failed: {e}")))?; @@ -106,13 +124,13 @@ pub(super) async fn handle_expose_service( labels: HashMap::from([("sandbox".to_string(), sandbox_name.to_string())]), resource_version: 0, annotations: HashMap::new(), - workspace: workspace.clone(), + workspace: workspace.to_string(), deletion_time: None, }), sandbox_id: sandbox.object_id().to_string(), sandbox: sandbox_name.to_string(), - name: req.name.clone(), - target_port: req.target_port, + name: service.to_string(), + target_port, domain: true, }; @@ -123,7 +141,7 @@ pub(super) async fn handle_expose_service( ServiceEndpoint::object_type(), &id, &key, - &workspace, + workspace, &endpoint.encode_to_vec(), Some(&labels_json), condition, @@ -136,7 +154,7 @@ pub(super) async fn handle_expose_service( meta.resource_version = result.resource_version; } - let url = service_routing::endpoint_url(&state.config, &workspace, sandbox_name, &req.name) + let url = service_routing::endpoint_url(&state.config, workspace, sandbox_name, service) .unwrap_or_default(); service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index f3a939269..c8677d890 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -68,6 +68,9 @@ fn membership_filter_subject<'a>( } } Principal::Sandbox(_) => Ok(None), + Principal::Peer(_) => Err(Status::permission_denied( + "gateway peer 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 a8cd09acc..ee3ef1768 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -38,6 +38,7 @@ mod sandbox_watch; mod service_routing; mod ssh_sessions; mod storage_proto; +mod supervisor_owner; pub mod supervisor_session; mod telemetry; #[cfg(any(test, feature = "test-support"))] @@ -304,6 +305,20 @@ pub struct ServerState { /// distinguish expected transport closes from runtime failures. pub(crate) gateway_shutting_down: AtomicBool, + /// Stable identity for this gateway process. + pub replica_id: String, + + /// Internal endpoint other gateway replicas can dial for peer RPCs. + pub peer_endpoint: Option, + + /// Reused peer connections, peer token, and owner lookups for relay + /// forwarding. Keeps per-relay cost off the connection and auth paths. + pub peer_routes: Arc, + + /// Idle HTTP/1 upstreams to sandbox services, so routed requests reuse a + /// relay instead of opening one per request. + pub service_upstreams: Arc, + /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, @@ -328,6 +343,9 @@ pub struct ServerState { /// bootstrap path. pub compute_driver_authenticator: Option>, + /// Optional K8s `ServiceAccount` authenticator for gateway peer RPCs. + pub peer_authenticator: Option>, + /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, @@ -404,6 +422,8 @@ impl ServerState { oidc_cache: Option>, credentials: credentials::CredentialRuntime, ) -> Self { + let replica_id = compute::lease::replica_id(); + let peer_endpoint = derive_peer_endpoint(&config); let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config); let admin_role = config .oidc @@ -423,6 +443,10 @@ impl ServerState { settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, gateway_shutting_down: AtomicBool::new(false), + replica_id, + peer_endpoint, + peer_routes: Arc::new(supervisor_session::PeerRouteCache::default()), + service_upstreams: Arc::new(service_routing::ServiceUpstreamPool::default()), extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, @@ -430,6 +454,7 @@ impl ServerState { sandbox_session_jwt_authority: None, sandbox_jwt_authenticator: None, compute_driver_authenticator: None, + peer_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, provider_profile_sources: @@ -439,6 +464,55 @@ impl ServerState { } } +fn derive_peer_endpoint(config: &Config) -> Option { + if let Ok(endpoint) = std::env::var("OPENSHELL_PEER_ENDPOINT") + && !endpoint.trim().is_empty() + { + return Some(endpoint.trim().to_string()); + } + + let pod_name = std::env::var("OPENSHELL_POD_NAME").ok()?; + let namespace = std::env::var("OPENSHELL_POD_NAMESPACE").ok()?; + let service = std::env::var("OPENSHELL_PEER_SERVICE_NAME").ok()?; + if pod_name.trim().is_empty() || namespace.trim().is_empty() || service.trim().is_empty() { + return None; + } + + let scheme = if config.tls.is_some() { + "https" + } else { + "http" + }; + Some(format!( + "{scheme}://{pod}.{service}.{namespace}.svc.cluster.local:{port}", + pod = pod_name.trim(), + service = service.trim(), + namespace = namespace.trim(), + port = config.bind_address.port() + )) +} + +/// Reject a plaintext peer endpoint on a gateway that serves TLS. +/// +/// Peer relay traffic carries whole supervisor sessions between replicas. The +/// chart renders a plaintext peer endpoint only when the gateway itself serves +fn validate_peer_endpoint_scheme(config: &Config, peer_endpoint: &str) -> Result<()> { + if peer_endpoint.starts_with("https://") { + return Ok(()); + } + if config.tls.is_some() { + return Err(Error::config(format!( + "gateway peer endpoint {peer_endpoint} is plaintext but this gateway serves TLS; \ + set an https:// OPENSHELL_PEER_ENDPOINT so peer relay traffic is not downgraded" + ))); + } + warn!( + peer_endpoint, + "gateway peer relay traffic is plaintext because this gateway does not serve TLS" + ); + Ok(()) +} + /// Run the `OpenShell` server. /// /// This starts a multiplexed gRPC/HTTP server on the configured bind address. @@ -700,6 +774,91 @@ pub(crate) async fn run_server( ); } + let peer_routing_expected = state.peer_endpoint.is_some() && !state.store.is_single_replica(); + if let Some(peer_endpoint) = state.peer_endpoint.as_deref() + && peer_routing_expected + { + validate_peer_endpoint_scheme(&state.config, peer_endpoint)?; + } + if state.peer_endpoint.is_none() && !state.store.is_single_replica() { + warn!( + "no gateway peer endpoint configured; this replica owns its supervisor sessions but \ + peers cannot reach it. Single-gateway deployments are unaffected; set \ + OPENSHELL_PEER_ENDPOINT on every replica when running more than one." + ); + } + + if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { + let namespace = std::env::var("OPENSHELL_POD_NAMESPACE").ok(); + let service_account = std::env::var("OPENSHELL_SERVICE_ACCOUNT_NAME").ok(); + match (namespace, service_account) { + (Some(namespace), Some(service_account)) + if !namespace.trim().is_empty() && !service_account.trim().is_empty() => + { + let required_labels = + auth::peer::required_pod_labels_from_env().map_err(Error::config)?; + match kube::Client::try_default().await { + Ok(client) => { + let audience = auth::peer::peer_token_audience_from_env(); + let resolver = Arc::new(auth::peer::LiveGatewayPeerResolver::new( + client, + namespace.trim(), + audience.clone(), + service_account.trim().to_string(), + required_labels, + )); + let cache_ttl = auth::peer::peer_token_cache_ttl_from_env(); + let resolver = Arc::new(auth::peer::CachingGatewayPeerResolver::new( + resolver, cache_ttl, + )); + let authenticator = + auth::peer::PeerServiceAccountAuthenticator::new(resolver); + state.peer_authenticator = Some(Arc::new(authenticator)); + info!( + namespace = %namespace.trim(), + service_account = %service_account.trim(), + audience, + token_cache_ttl_secs = cache_ttl.as_secs(), + "gateway peer ServiceAccount TokenReview authentication enabled" + ); + } + Err(err) if peer_routing_expected => { + return Err(Error::config(format!( + "in-cluster K8s client construction failed ({err}); \ + gateway peer authentication is required because \ + OPENSHELL_PEER_ENDPOINT is configured" + ))); + } + Err(err) => warn!( + error = %err, + "in-cluster K8s client construction failed; \ + gateway peer ServiceAccount authentication is disabled" + ), + } + } + _ if peer_routing_expected => { + return Err(Error::config( + "OPENSHELL_POD_NAMESPACE or OPENSHELL_SERVICE_ACCOUNT_NAME missing; \ + both are required for gateway peer authentication because \ + OPENSHELL_PEER_ENDPOINT is configured" + .to_string(), + )); + } + _ => { + debug!( + "OPENSHELL_POD_NAMESPACE or OPENSHELL_SERVICE_ACCOUNT_NAME missing; \ + gateway peer ServiceAccount authentication disabled" + ); + } + } + } else if peer_routing_expected { + return Err(Error::config( + "OPENSHELL_PEER_ENDPOINT is configured but the gateway is not running in a \ + Kubernetes cluster, so gateway peer authentication is unavailable" + .to_string(), + )); + } + let state = Arc::new(state); // Reconcile local-driver running intent before watchers spawn so their @@ -839,6 +998,16 @@ pub(crate) async fn run_server( } startup_tx.send_replace(true); + // The poller exists to observe writes made by other replicas. Single- + // replica backends have none, so it would only add load. + if !store.is_single_replica() { + sandbox_watch::spawn_store_poller( + store.clone(), + state.sandbox_watch_bus.clone(), + sandbox_watch::DEFAULT_STORE_POLL_INTERVAL, + 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)); @@ -1749,7 +1918,7 @@ mod tests { MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listener, classify_initial_bytes, configured_compute_driver, extension_token_ttl, is_benign_tls_handshake_failure, mint_gateway_extension_credential, - serve_gateway_listener, + serve_gateway_listener, validate_peer_endpoint_scheme, }; use openshell_core::{ Config, @@ -1769,6 +1938,40 @@ mod tests { use crate::tls_test_utils::generate_test_certs_with_ca; + fn tls_enabled_config() -> Config { + Config::new(Some(openshell_core::TlsConfig { + cert_path: "/tmp/cert.pem".into(), + key_path: "/tmp/key.pem".into(), + client_ca_path: None, + require_client_auth: false, + external_cert_path: None, + external_key_path: None, + external_server_names: Vec::new(), + })) + } + + #[test] + fn plaintext_peer_endpoint_is_rejected_on_a_tls_gateway() { + let error = validate_peer_endpoint_scheme(&tls_enabled_config(), "http://10.0.0.1:8080") + .expect_err("plaintext peer endpoint must not be accepted alongside gateway TLS"); + assert!( + error.to_string().contains("plaintext"), + "error should name the downgrade: {error}" + ); + } + + #[test] + fn https_peer_endpoint_is_accepted_on_a_tls_gateway() { + validate_peer_endpoint_scheme(&tls_enabled_config(), "https://10.0.0.1:8080").unwrap(); + } + + #[test] + fn plaintext_peer_endpoint_is_allowed_on_a_plaintext_gateway() { + let config = Config::new(None); + assert!(config.tls.is_none()); + validate_peer_endpoint_scheme(&config, "http://10.0.0.1:8080").unwrap(); + } + static DETECTION_PROBE_ORDER: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 2df0ea540..6ec78d7f6 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -200,6 +200,11 @@ macro_rules! request_id_middleware { const MAX_GRPC_DECODE_SIZE: usize = 1_048_576; const MAX_INTERCEPTED_GRPC_BODY_SIZE: usize = MAX_GRPC_DECODE_SIZE + 5; +/// Concurrent HTTP/2 streams allowed per connection. Sits above the +/// per-replica pending relay budget so pooled peer connections are bounded by +/// the relay caps rather than by the transport. +const MAX_HTTP2_CONCURRENT_STREAMS: u32 = 1024; + /// Multiplexed gRPC/HTTP service. #[derive(Clone)] pub struct MultiplexService { @@ -271,10 +276,15 @@ impl MultiplexService { // it the gateway never PINGs them, so idle/half-dead connections linger and orphan // in-flight relay execs. The timer is required — hyper panics on the keepalive // interval without one. + // + // Peer relays from one replica now share a single pooled connection, so every + // forwarded session for every sandbox counts against this one limit. hyper's + // default of 200 would cap the whole replica pair below MAX_PENDING_RELAYS. builder .http2() .timer(TokioTimer::new()) .adaptive_window(true) + .max_concurrent_streams(MAX_HTTP2_CONCURRENT_STREAMS) .keep_alive_interval(Some(Duration::from_secs(20))) .keep_alive_timeout(Duration::from_secs(10)); @@ -675,6 +685,11 @@ fn gateway_principal_fields(principal: &Principal) -> BTreeMap { fields.insert("trust_domain".to_string(), trust_domain.clone()); } } + Principal::Peer(peer) => { + fields.insert("kind".to_string(), "peer".to_string()); + fields.insert("replica_id".to_string(), peer.replica_id.clone()); + fields.insert("pod_uid".to_string(), peer.pod_uid.clone()); + } Principal::Anonymous => { fields.insert("kind".to_string(), "anonymous".to_string()); } @@ -845,12 +860,15 @@ where /// Assemble the authenticator chain for the gateway. /// /// Chain order (first-match-wins): -/// 1. `ComputeDriverAuthenticator` (path-scoped to `IssueSandboxToken`) +/// 1. `PeerServiceAccountAuthenticator` (path-scoped to peer RPCs) +/// — validates gateway replica projected `ServiceAccount` tokens with +/// `TokenReview` for internal peer relay calls. No-op on every other path. +/// 2. `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. -/// 2. `SandboxJwtAuthenticator` — validates gateway-minted JWTs. Recognized +/// 3. `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 +/// 4. `OidcAuthenticator` — validates user Bearer tokens against the /// configured OIDC issuer. Returns `Unauthenticated` for missing /// Bearer headers so non-OIDC clients can't sneak through. /// @@ -865,6 +883,9 @@ 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(peer) = state.peer_authenticator.clone() { + authenticators.push(peer); + } if let Some(driver) = state.compute_driver_authenticator.clone() { authenticators.push(driver); } @@ -1040,6 +1061,13 @@ where ))); } } + Principal::Peer(_) => { + if !crate::auth::method_authz::is_peer_callable(&path) { + return Ok(status_response(tonic::Status::permission_denied( + "gateway peer principals may not call this method", + ))); + } + } Principal::Anonymous => { return Ok(status_response(tonic::Status::unauthenticated( "anonymous callers may not call authenticated methods", @@ -1314,6 +1342,12 @@ mod tests { }], provider_profiles: false, expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::GatewayInterceptor, + "openshell/post-commit-test", + "test", + [], + )), })) } diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index 0b365ccd0..0306bdd31 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -197,6 +197,11 @@ pub enum Store { Sqlite(SqliteStore), } +/// RAII guard for the database-backed cross-object mutation lock. +pub struct DistributedMutationGuard { + _postgres: Option, +} + /// Trait for inferring an object type string from a message type. pub trait ObjectType { fn object_type() -> &'static str; @@ -277,6 +282,23 @@ impl Store { matches!(self, Self::Sqlite(_)) } + /// Serialize mutations whose invariants span multiple persisted objects. + /// + /// `SQLite` deployments are single-replica and use only the caller's local + /// mutex. `PostgreSQL` deployments additionally hold a session-level + /// advisory lock so concurrent gateway replicas cannot validate and write + /// the same cross-object invariant independently. + pub async fn acquire_distributed_mutation_guard( + &self, + ) -> PersistenceResult { + match self { + Self::Postgres(store) => Ok(DistributedMutationGuard { + _postgres: Some(store.acquire_cross_object_lock().await?), + }), + Self::Sqlite(_) => Ok(DistributedMutationGuard { _postgres: None }), + } + } + /// Connect to a persistence store based on the database URL. pub async fn connect(url: &str) -> CoreResult { if url.starts_with("postgres://") || url.starts_with("postgresql://") { diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index c0be94e0c..d79335b5f 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -14,6 +14,7 @@ use crate::policy_store::{ use openshell_core::SetResourceVersion; use openshell_core::proto::Sandbox; use prost::Message; +use sqlx::pool::PoolConnection; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool, Postgres, QueryBuilder, Row}; @@ -34,6 +35,23 @@ pub struct PostgresStore { pool: PgPool, } +// Stable cluster-wide key for serializing sandbox/provider cross-object +// mutations. The bytes spell "OPENSHLL" and stay within PostgreSQL's signed +// 64-bit advisory-lock key space. +const CROSS_OBJECT_ADVISORY_LOCK_KEY: i64 = 0x4f50_454e_5348_4c4c; + +// Bounds the wait for the cross-object lock. The holder only validates and +// writes, so a wait this long means a stuck replica; failing beats blocking +// every sandbox and provider mutation in the fleet indefinitely. +const CROSS_OBJECT_ADVISORY_LOCK_TIMEOUT: &str = "10s"; + +pub(super) struct PostgresAdvisoryLockGuard { + // `close_on_drop` is set before this guard is constructed. Closing the + // dedicated session releases the session-level advisory lock even when a + // request is cancelled or returns early. + _connection: PoolConnection, +} + impl PostgresStore { pub async fn connect(url: &str) -> PersistenceResult { let pool = PgPoolOptions::new() @@ -96,6 +114,26 @@ impl PostgresStore { conn.ping().await.map_err(|e| map_db_error(&e)) } + pub(super) async fn acquire_cross_object_lock( + &self, + ) -> PersistenceResult { + let mut connection = self.pool.acquire().await.map_err(|e| map_db_error(&e))?; + connection.close_on_drop(); + sqlx::query("SELECT set_config('lock_timeout', $1, false)") + .bind(CROSS_OBJECT_ADVISORY_LOCK_TIMEOUT) + .execute(&mut *connection) + .await + .map_err(|e| map_db_error(&e))?; + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(CROSS_OBJECT_ADVISORY_LOCK_KEY) + .execute(&mut *connection) + .await + .map_err(|e| map_db_error(&e))?; + Ok(PostgresAdvisoryLockGuard { + _connection: connection, + }) + } + /// Test support only: close the underlying connection pool. /// /// Do not call from runtime code; this tears down the active pool. diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 313703672..6728b2f0c 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -850,6 +850,12 @@ mod tests { Ok(Response::new(InterceptorManifest { name: "mock-profile-source".to_string(), provider_profiles: self.advertises_profiles, + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::GatewayInterceptor, + "openshell/mock-profile-source", + "test", + [], + )), ..InterceptorManifest::default() })) } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 6d666fbd5..e8b39cbac 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -1228,7 +1228,9 @@ async fn apply_minted_credential( // prevents route status from committing against the old provider revision // after the rotation writes, without holding the guard across network I/O. let _sandbox_sync_guard = if let Some(compute) = compute { - Some(compute.sandbox_sync_guard().await) + Some(compute.sandbox_sync_guard().await.map_err(|error| { + Status::internal(format!("acquire provider mutation lock: {error}")) + })?) } else { None }; diff --git a/crates/openshell-server/src/sandbox_watch.rs b/crates/openshell-server/src/sandbox_watch.rs index ac38eba8d..3a62fce39 100644 --- a/crates/openshell-server/src/sandbox_watch.rs +++ b/crates/openshell-server/src/sandbox_watch.rs @@ -3,12 +3,20 @@ //! In-memory buses to support sandbox watch streaming. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; +use std::time::Duration; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, watch}; use tonic::Status; +use crate::persistence::Store; +use openshell_core::proto::Sandbox; + +/// How often [`spawn_store_poller`] rechecks watched sandboxes for writes made +/// by other gateway replicas. +pub const DEFAULT_STORE_POLL_INTERVAL: Duration = Duration::from_secs(1); + /// Broadcast bus of sandbox updates keyed by sandbox id. /// /// Producers call [`SandboxWatchBus::notify`] whenever the persisted sandbox record changes. @@ -57,6 +65,71 @@ impl SandboxWatchBus { let mut inner = self.inner.lock().expect("sandbox watch bus lock poisoned"); inner.remove(sandbox_id); } + + fn active_sandbox_ids(&self) -> HashSet { + self.inner + .lock() + .expect("sandbox watch bus lock poisoned") + .iter() + .filter(|(_, sender)| sender.receiver_count() > 0) + .map(|(sandbox_id, _)| sandbox_id.clone()) + .collect() + } +} + +/// Poll persisted sandbox resource versions once per gateway and notify the +/// existing in-memory watch bus when another replica changes a record. +/// +/// The poller performs at most one lookup per actively watched sandbox per +/// interval, regardless of how many clients are watching that sandbox. +pub fn spawn_store_poller( + store: Arc, + bus: SandboxWatchBus, + interval: Duration, + mut shutdown_rx: watch::Receiver, +) { + tokio::spawn(async move { + let mut known_versions: HashMap> = HashMap::new(); + let mut timer = tokio::time::interval(interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + _ = timer.tick() => { + let active = bus.active_sandbox_ids(); + known_versions.retain(|sandbox_id, _| active.contains(sandbox_id)); + + for sandbox_id in active { + let current = match store.get_message::(&sandbox_id).await { + Ok(sandbox) => sandbox.map(|sandbox| { + sandbox.metadata.as_ref().map_or(0, |metadata| metadata.resource_version) + }), + Err(err) => { + tracing::warn!( + sandbox_id, + error = %err, + "sandbox watch poller: failed to read persisted sandbox" + ); + continue; + } + }; + + let changed = known_versions + .insert(sandbox_id.clone(), current) + .is_none_or(|previous| previous != current); + if changed { + bus.notify(&sandbox_id); + } + } + } + } + } + }); } /// Helper to translate broadcast lag into a gRPC status. @@ -72,6 +145,7 @@ pub fn broadcast_to_status(err: broadcast::error::RecvError) -> Status { #[cfg(test)] mod tests { use super::*; + use openshell_core::proto::datamodel::v1::ObjectMeta; #[test] fn sandbox_watch_bus_remove_cleans_up() { @@ -114,4 +188,42 @@ mod tests { // Should not panic bus.remove("nonexistent"); } + + #[tokio::test] + async fn shared_store_poller_notifies_remote_resource_version_change() { + let store = Arc::new(crate::persistence::test_store().await); + let bus = SandboxWatchBus::new(); + let sandbox = Sandbox { + metadata: Some(ObjectMeta { + id: "sb-1".to_string(), + name: "sandbox-a".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + ..Default::default() + }; + store.put_message(&sandbox).await.unwrap(); + + let mut rx = bus.subscribe("sb-1"); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + spawn_store_poller(store.clone(), bus, Duration::from_millis(10), shutdown_rx); + + tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("poller should publish its initial observation") + .unwrap(); + + store + .update_message_cas::("sb-1", 0, |stored| { + stored.set_phase(1); + }) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("poller should observe a remote store update") + .unwrap(); + + shutdown_tx.send(true).unwrap(); + } } diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index e2b67e731..3d49d74d7 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -17,9 +17,10 @@ use openshell_ocsf::{ HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder, OCSF_TARGET, OcsfEvent, SeverityId, StateId, StatusId, Url as OcsfUrl, }; +use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr}; -use std::sync::Arc; -use std::time::Duration; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use tokio::io::AsyncWriteExt; use tracing::{info, warn}; @@ -31,6 +32,113 @@ const ROUTING_RULE_NAME: &str = "sandbox_service_routing"; const ROUTING_RULE_TYPE: &str = "gateway"; const RELAY_RULE_NAME: &str = "sandbox_service_relay"; const RELAY_TARGET_HOST: &str = "127.0.0.1"; +/// How long an idle upstream is kept. Deliberately short: a sandbox app can +/// close its side at any time, and a connection we hand out after it died +/// fails the request. +const UPSTREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(15); +/// Idle upstreams kept per endpoint. Concurrent requests need one each, +/// because HTTP/1 serves a single request at a time per connection. +const UPSTREAM_MAX_IDLE_PER_ENDPOINT: usize = 8; +/// Minimum gap between full sweeps of the pool. +const UPSTREAM_SWEEP_INTERVAL: Duration = Duration::from_secs(30); + +type UpstreamSender = hyper::client::conn::http1::SendRequest; + +struct PooledUpstream { + sender: UpstreamSender, + idle_since: Instant, +} + +#[derive(Default)] +struct UpstreamPoolInner { + idle: HashMap>, + last_sweep: Option, +} + +impl UpstreamPoolInner { + /// Full sweeps are rate-limited so a large pool can't turn every insert + /// into a scan of every endpoint. + fn sweep_if_due(&mut self, now: Instant) { + if let Some(last) = self.last_sweep + && now.duration_since(last) < UPSTREAM_SWEEP_INTERVAL + { + return; + } + self.last_sweep = Some(now); + self.idle.retain(|_, entries| { + entries.retain(|entry| is_reusable(entry, now)); + !entries.is_empty() + }); + } +} + +/// Idle upstream connections to sandbox services, keyed by endpoint. +/// +/// Without this every HTTP request opened its own supervisor relay, which +/// meant a new TCP connection inside the sandbox and a new HTTP/1 handshake +/// per request, and counted against the 32 in-flight relay cap per sandbox. +#[derive(Default)] +pub struct ServiceUpstreamPool { + inner: Mutex, +} + +impl std::fmt::Debug for ServiceUpstreamPool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServiceUpstreamPool") + .finish_non_exhaustive() + } +} + +impl ServiceUpstreamPool { + /// Takes an upstream that is connected and free. + /// + /// `is_ready` is what makes reuse safe: HTTP/1 can't start a request until + /// the previous response body has been read, and hyper only reports ready + /// once that has happened. + fn take(&self, key: &str) -> Option { + let now = Instant::now(); + let mut inner = self.inner.lock().unwrap(); + let entries = inner.idle.get_mut(key)?; + entries.retain(|entry| is_reusable(entry, now)); + let taken = entries + .iter() + .position(|entry| entry.sender.is_ready()) + .map(|index| entries.swap_remove(index).sender); + if entries.is_empty() { + inner.idle.remove(key); + } + taken + } + + fn put(&self, key: &str, sender: UpstreamSender) { + if sender.is_closed() { + return; + } + let now = Instant::now(); + let mut inner = self.inner.lock().unwrap(); + inner.sweep_if_due(now); + let entries = inner.idle.entry(key.to_string()).or_default(); + if entries.len() >= UPSTREAM_MAX_IDLE_PER_ENDPOINT { + return; + } + entries.push(PooledUpstream { + sender, + idle_since: now, + }); + } + + fn evict(&self, key: &str) { + self.inner.lock().unwrap().idle.remove(key); + } +} + +fn is_reusable(entry: &PooledUpstream, now: Instant) -> bool { + !entry.sender.is_closed() && now.duration_since(entry.idle_since) <= UPSTREAM_IDLE_TIMEOUT +} + +fn upstream_pool_key(endpoint_id: &str, target_port: u16) -> String { + format!("{endpoint_id}|{target_port}") +} impl ObjectType for ServiceEndpoint { fn object_type() -> &'static str { @@ -325,75 +433,57 @@ async fn proxy_to_endpoint( let websocket_upgrade = is_websocket_upgrade(&req); let downstream_upgrade = websocket_upgrade.then(|| hyper::upgrade::on(&mut req)); - let (_channel_id, relay_rx) = state - .supervisor_sessions - .open_relay_with_target( - sandbox.object_id(), - relay_open::Target::Tcp(TcpRelayTarget { - host: RELAY_TARGET_HOST.to_string(), - port: u32::from(target_port), - }), - endpoint.object_id().to_string(), - Duration::from_secs(15), - ) - .await - .map_err(|err| { - warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "sandbox service routing: supervisor relay unavailable"); - let route_err = ServiceRouteError::service_unreachable(); - emit_service_relay_failure(&endpoint, target_port, route_err.reason); - route_err - })?; - - let relay = tokio::time::timeout(Duration::from_secs(10), relay_rx) - .await - .map_err(|_| { - let err = ServiceRouteError::service_unreachable(); - emit_service_relay_failure(&endpoint, target_port, "relay claim timed out"); - err - })? - .map_err(|_| { - let err = ServiceRouteError::service_unreachable(); - emit_service_relay_failure(&endpoint, target_port, "relay claim canceled"); - err - })? - .map_err(|err| { - warn!(error = %err, "sandbox service routing: relay target open failed"); - let route_err = ServiceRouteError::service_unreachable(); - emit_service_relay_failure(&endpoint, target_port, route_err.reason); - route_err - })?; - - let (mut sender, conn) = hyper::client::conn::http1::Builder::new() - .handshake(TokioIo::new(relay)) - .await - .map_err(|err| { - warn!(error = %err, "sandbox service routing: failed to start upstream HTTP client"); - let route_err = ServiceRouteError::service_unreachable(); - emit_service_relay_failure(&endpoint, target_port, route_err.reason); - route_err - })?; - - if websocket_upgrade { - tokio::spawn(async move { - if let Err(err) = conn.with_upgrades().await { - warn!(error = %err, "sandbox service routing: upstream WebSocket connection failed"); - } - }); + // An upgrade takes the connection over, so it is never pooled. + let pool_key = upstream_pool_key(endpoint.object_id(), target_port); + let pooled = if websocket_upgrade { + None } else { - tokio::spawn(async move { - if let Err(err) = conn.await { - warn!(error = %err, "sandbox service routing: upstream HTTP connection failed"); - } - }); - } + state.service_upstreams.take(&pool_key) + }; + + let reused = pooled.is_some(); + let mut sender = match pooled { + Some(sender) => sender, + None => open_upstream(&state, &sandbox, &endpoint, target_port, websocket_upgrade).await?, + }; let upstream = build_upstream_request(req, target_port, websocket_upgrade)?; - let mut response = sender.send_request(upstream).await.map_err(|err| { - warn!(error = %err, "sandbox service routing: upstream HTTP request failed"); - let route_err = ServiceRouteError::service_unreachable(); - emit_service_relay_failure(&endpoint, target_port, route_err.reason); - route_err - })?; + let replay = reused.then(|| replayable_request(&upstream)).flatten(); + let first_attempt = if reused { + sender.try_send_request(upstream).await.map_err(|mut err| { + let request = err.take_message(); + (err.into_error(), request) + }) + } else { + sender + .send_request(upstream) + .await + .map_err(|err| (err, None)) + }; + let mut response = match first_attempt { + Ok(response) => response, + Err((err, recovered)) => { + warn!(error = %err, "sandbox service routing: upstream HTTP request failed"); + state.service_upstreams.evict(&pool_key); + let Some(retry_request) = recovered.or(replay) else { + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + return Err(route_err); + }; + sender = + open_upstream(&state, &sandbox, &endpoint, target_port, websocket_upgrade).await?; + sender.send_request(retry_request).await.map_err(|err| { + warn!(error = %err, "sandbox service routing: upstream HTTP retry failed"); + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + route_err + })? + } + }; + + if !websocket_upgrade { + state.service_upstreams.put(&pool_key, sender); + } if websocket_upgrade && response.status() == StatusCode::SWITCHING_PROTOCOLS { let upstream_upgrade = hyper::upgrade::on(&mut response); @@ -428,6 +518,77 @@ async fn proxy_to_endpoint( Ok(Response::from_parts(parts, Body::new(body))) } +async fn open_upstream( + state: &Arc, + sandbox: &Sandbox, + endpoint: &ServiceEndpoint, + target_port: u16, + websocket_upgrade: bool, +) -> Result { + let (_channel_id, relay_rx) = crate::supervisor_session::open_routed_relay_with_target( + state, + sandbox.object_id(), + relay_open::Target::Tcp(TcpRelayTarget { + host: RELAY_TARGET_HOST.to_string(), + port: u32::from(target_port), + }), + endpoint.object_id().to_string(), + Duration::from_secs(15), + ) + .await + .map_err(|err| { + warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "sandbox service routing: supervisor relay unavailable"); + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(endpoint, target_port, route_err.reason); + route_err + })?; + + let relay = tokio::time::timeout(Duration::from_secs(10), relay_rx) + .await + .map_err(|_| { + let err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(endpoint, target_port, "relay claim timed out"); + err + })? + .map_err(|_| { + let err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(endpoint, target_port, "relay claim canceled"); + err + })? + .map_err(|err| { + warn!(error = %err, "sandbox service routing: relay target open failed"); + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(endpoint, target_port, route_err.reason); + route_err + })?; + + let (sender, conn) = hyper::client::conn::http1::Builder::new() + .handshake(TokioIo::new(relay)) + .await + .map_err(|err| { + warn!(error = %err, "sandbox service routing: failed to start upstream HTTP client"); + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(endpoint, target_port, route_err.reason); + route_err + })?; + + if websocket_upgrade { + tokio::spawn(async move { + if let Err(err) = conn.with_upgrades().await { + warn!(error = %err, "sandbox service routing: upstream WebSocket connection failed"); + } + }); + } else { + tokio::spawn(async move { + if let Err(err) = conn.await { + warn!(error = %err, "sandbox service routing: upstream HTTP connection failed"); + } + }); + } + + Ok(sender) +} + async fn load_endpoint( store: &Store, workspace: &str, @@ -445,6 +606,23 @@ async fn load_endpoint( .ok_or_else(ServiceRouteError::endpoint_not_found) } +/// Copies a request that can be sent again on a fresh connection. +/// +/// A pooled connection can be closed by the sandbox between the liveness check +/// and the send. Only bodyless methods are replayable, because the original +/// body is consumed by the failed attempt. +fn replayable_request(request: &Request) -> Option> { + if !matches!(*request.method(), Method::GET | Method::HEAD) { + return None; + } + let mut replay = Request::new(Body::empty()); + *replay.method_mut() = request.method().clone(); + *replay.uri_mut() = request.uri().clone(); + *replay.version_mut() = request.version(); + *replay.headers_mut() = request.headers().clone(); + Some(replay) +} + fn build_upstream_request( req: Request, target_port: u16, @@ -820,9 +998,9 @@ mod tests { id: "endpoint-id".to_string(), name: "my-sandbox--web".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_700_000_000_000).ok(), - labels: std::collections::HashMap::default(), + labels: HashMap::default(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), workspace: "default".to_string(), deletion_time: None, }), @@ -1205,9 +1383,9 @@ mod tests { id: "ep-1".to_string(), name: "my-sandbox--web".to_string(), created_time: openshell_core::time::timestamp_from_millis(1_700_000_000_000).ok(), - labels: std::collections::HashMap::default(), + labels: HashMap::default(), resource_version: 0, - annotations: std::collections::HashMap::new(), + annotations: HashMap::new(), workspace: "default".to_string(), deletion_time: None, }), @@ -1228,4 +1406,190 @@ mod tests { "should not find endpoint in wrong workspace" ); } + + /// Returns a live upstream plus the sandbox end of the connection. Hold + /// the returned half: dropping it closes the upstream. + async fn test_upstream() -> (UpstreamSender, tokio::io::DuplexStream) { + let (client_io, server_io) = tokio::io::duplex(1024); + let (sender, conn) = hyper::client::conn::http1::Builder::new() + .handshake(TokioIo::new(client_io)) + .await + .unwrap(); + tokio::spawn(async move { + let _ = conn.await; + }); + tokio::time::sleep(Duration::from_millis(20)).await; + (sender, server_io) + } + + #[test] + fn upstream_pool_key_separates_endpoints_and_ports() { + assert_eq!(upstream_pool_key("ep-a", 8080), "ep-a|8080"); + assert_ne!( + upstream_pool_key("ep-a", 8080), + upstream_pool_key("ep-b", 8080) + ); + assert_ne!( + upstream_pool_key("ep-a", 8080), + upstream_pool_key("ep-a", 9090) + ); + } + + #[tokio::test] + async fn pool_hands_back_a_ready_upstream() { + let pool = ServiceUpstreamPool::default(); + let (sender, _sandbox) = test_upstream().await; + + pool.put("ep-a|8080", sender); + + assert!( + pool.take("ep-a|8080").is_some(), + "put upstream should be reusable" + ); + assert!( + pool.take("ep-a|8080").is_none(), + "an upstream in use must not be handed out twice" + ); + } + + #[tokio::test] + async fn take_removes_an_endpoint_left_with_no_upstreams() { + let pool = ServiceUpstreamPool::default(); + let (sender, _sandbox) = test_upstream().await; + pool.put("ep-a|8080", sender); + + assert!(pool.take("ep-a|8080").is_some()); + assert!( + !pool.inner.lock().unwrap().idle.contains_key("ep-a|8080"), + "an emptied endpoint must not linger until the next sweep" + ); + } + + #[test] + fn only_bodyless_requests_are_replayable() { + for method in [Method::GET, Method::HEAD] { + let mut request = Request::new(Body::empty()); + *request.method_mut() = method.clone(); + *request.uri_mut() = "/health".parse().unwrap(); + request + .headers_mut() + .insert(header::HOST, HeaderValue::from_static("svc")); + + let replay = + replayable_request(&request).expect("bodyless method should be replayable"); + assert_eq!(*replay.method(), method); + assert_eq!(replay.uri().path(), "/health"); + assert_eq!(replay.headers().get(header::HOST).unwrap(), "svc"); + } + + let mut post = Request::new(Body::empty()); + *post.method_mut() = Method::POST; + assert!( + replayable_request(&post).is_none(), + "a request with a body cannot be replayed" + ); + } + + #[tokio::test] + async fn pool_does_not_hand_out_another_endpoints_upstream() { + let pool = ServiceUpstreamPool::default(); + let (sender, _sandbox) = test_upstream().await; + + pool.put("ep-a|8080", sender); + + assert!(pool.take("ep-b|8080").is_none()); + } + + #[tokio::test] + async fn pool_drops_an_upstream_the_sandbox_closed() { + let pool = ServiceUpstreamPool::default(); + let (sender, sandbox) = test_upstream().await; + pool.put("ep-a|8080", sender); + + drop(sandbox); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + pool.take("ep-a|8080").is_none(), + "a closed upstream must never be reused" + ); + } + + #[tokio::test] + async fn closed_upstream_recovers_an_unsent_post_for_retry() { + let (mut sender, sandbox) = test_upstream().await; + drop(sandbox); + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut request = Request::new(Body::from("payload")); + *request.method_mut() = Method::POST; + *request.uri_mut() = "/write".parse().unwrap(); + let mut error = sender + .try_send_request(request) + .await + .expect_err("a closed connection must reject the request"); + let recovered = error + .take_message() + .expect("an unsent request must be recoverable for a fresh connection"); + + assert_eq!(recovered.method(), Method::POST); + assert_eq!(recovered.uri().path(), "/write"); + } + + #[tokio::test] + async fn pool_drops_upstreams_past_the_idle_timeout() { + let pool = ServiceUpstreamPool::default(); + let (sender, _sandbox) = test_upstream().await; + pool.inner.lock().unwrap().idle.insert( + "ep-a|8080".to_string(), + vec![PooledUpstream { + sender, + idle_since: Instant::now() + .checked_sub(UPSTREAM_IDLE_TIMEOUT + Duration::from_secs(1)) + .unwrap(), + }], + ); + + assert!(pool.take("ep-a|8080").is_none()); + } + + #[tokio::test] + async fn pool_caps_idle_upstreams_per_endpoint() { + let pool = ServiceUpstreamPool::default(); + let mut sandboxes = Vec::new(); + for _ in 0..(UPSTREAM_MAX_IDLE_PER_ENDPOINT + 4) { + let (sender, sandbox) = test_upstream().await; + sandboxes.push(sandbox); + pool.put("ep-a|8080", sender); + } + + let held = pool + .inner + .lock() + .unwrap() + .idle + .get("ep-a|8080") + .map_or(0, Vec::len); + assert_eq!(held, UPSTREAM_MAX_IDLE_PER_ENDPOINT); + // Every connection stayed open, so the cap dropped the surplus rather + // than the pool losing entries to closure. + assert_eq!(sandboxes.len(), UPSTREAM_MAX_IDLE_PER_ENDPOINT + 4); + } + + #[tokio::test] + async fn evict_drops_every_upstream_for_an_endpoint() { + let pool = ServiceUpstreamPool::default(); + let (first, _sandbox_a) = test_upstream().await; + let (second, _sandbox_b) = test_upstream().await; + pool.put("ep-a|8080", first); + pool.put("ep-b|8080", second); + + pool.evict("ep-a|8080"); + + assert!(pool.take("ep-a|8080").is_none()); + assert!( + pool.take("ep-b|8080").is_some(), + "eviction must be scoped to one endpoint" + ); + } } diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index bd3f4790a..1b40c62bb 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -119,7 +119,7 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "d68401809d8cea445c35233ef32412bbd041cb2ac5acaf368a0d0bf74d2ddf17"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "5e0cddacd16cbbc28d0a3b209bd9158f1ad3a0affd3fead80dae66bf08a13d86"; + "07e889beb43942535d80d635c490f3fb6c6f3e554f3087c101deec8c05502883"; const DURABLE_SCHEMA_SHA256: &str = "9eeaa29dfba187bff69fb7bc4f9a13a0f1d7be3f7049a38c8f0e20ce77ec7d8b"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = @@ -158,6 +158,11 @@ mod tests { "openshell.v1.OpenShell/GetSandboxProviderStatus|.openshell.v1.GetSandboxProviderStatusRequest|.openshell.v1.GetSandboxProviderStatusResponse|false|false", "openshell.v1.OpenShell/ReportProviderReadiness|.openshell.v1.ReportProviderReadinessRequest|.openshell.v1.ReportProviderReadinessResponse|false|false", ]; + const PEER_OWNER_RPC_SIGNATURES: [&str; 3] = [ + "openshell.v1.OpenShell/PeerGetSandboxProviderStatus|.openshell.v1.GetSandboxProviderStatusRequest|.openshell.v1.GetSandboxProviderStatusResponse|false|false", + "openshell.v1.OpenShell/PeerReportEndpointStatus|.openshell.v1.ReportEndpointStatusRequest|.openshell.v1.ReportEndpointStatusResponse|false|false", + "openshell.v1.OpenShell/PeerReportProviderReadiness|.openshell.v1.ReportProviderReadinessRequest|.openshell.v1.ReportProviderReadinessResponse|false|false", + ]; // Synthetic SandboxSpec bytes with log level, provider, and command fields, // emitted before the gateway-owned attachment epoch field was introduced. const PRE_READINESS_SANDBOX_SPEC: &str = @@ -516,14 +521,20 @@ mod tests { "provider readiness RPC is missing or changed: {signature}" ); } + for signature in PEER_OWNER_RPC_SIGNATURES { + assert!( + methods.iter().any(|method| method == signature), + "peer owner RPC is missing or changed: {signature}" + ); + } assert_eq!( compiled_method_count, - 101 + PROVIDER_READINESS_RPC_SIGNATURES.len(), + 102 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len(), "classify every compiled RPC" ); assert_eq!( methods.len(), - 76 + PROVIDER_READINESS_RPC_SIGNATURES.len(), + 77 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len(), "inventory every public gateway RPC" ); assert_eq!( @@ -531,7 +542,7 @@ mod tests { .iter() .filter(|method| method.starts_with("openshell.v1.OpenShell/")) .count(), - 76 + PROVIDER_READINESS_RPC_SIGNATURES.len() + 77 + PROVIDER_READINESS_RPC_SIGNATURES.len() + PEER_OWNER_RPC_SIGNATURES.len() ); assert!(methods.iter().all(|method| !method.contains(".storage."))); @@ -575,7 +586,7 @@ mod tests { overlap_hash.as_str(), ), ( - (299, 24), + (304, 25), (92, 19), (80, 19), PUBLIC_RPC_SCHEMA_SHA256, diff --git a/crates/openshell-server/src/supervisor_owner.rs b/crates/openshell-server/src/supervisor_owner.rs new file mode 100644 index 000000000..a517bce0f --- /dev/null +++ b/crates/openshell-server/src/supervisor_owner.rs @@ -0,0 +1,427 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared supervisor-session ownership index for HA gateway replicas. + +use crate::persistence::{PersistenceError, Store, WriteCondition}; +use openshell_core::time::now_ms; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use thiserror::Error; + +const OWNER_OBJECT_TYPE: &str = "supervisor_session_owner"; + +pub const OWNER_TTL: Duration = Duration::from_secs(45); + +fn owner_object_id(sandbox_id: &str) -> String { + format!("supervisor-owner:{sandbox_id}") +} + +#[derive(Debug, Error)] +pub enum OwnerError { + #[error("supervisor session is owned by another active gateway replica")] + AlreadyOwned, + #[error("supervisor owner record CAS conflict")] + Conflict, + #[error("persistence error: {0}")] + Store(#[from] PersistenceError), +} + +impl OwnerError { + /// True when another replica holds ownership, as opposed to the store + /// being unreachable. + pub fn is_ownership_lost(&self) -> bool { + matches!(self, Self::AlreadyOwned | Self::Conflict) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OwnerPayload { + sandbox_id: String, + session_id: String, + supervisor_instance_id: String, + connection_epoch: u64, + owner_replica_id: String, + owner_peer_endpoint: String, + connected_at_ms: i64, +} + +#[derive(Debug, Clone)] +pub struct OwnerRecord { + pub session_id: String, + pub supervisor_instance_id: String, + pub connection_epoch: u64, + pub owner_replica_id: String, + pub owner_peer_endpoint: String, + #[allow(dead_code)] + pub connected_at_ms: i64, + pub updated_at_ms: i64, + pub resource_version: u64, +} + +impl OwnerRecord { + /// True while the record's last update is within `ttl`. + pub fn is_fresh(&self, ttl: Duration) -> bool { + let ttl_ms = i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX); + now_ms().saturating_sub(self.updated_at_ms).max(0) < ttl_ms + } +} + +#[derive(Debug, Clone)] +pub struct OwnerGuard { + pub sandbox_id: String, + pub session_id: String, + pub supervisor_instance_id: String, + pub connection_epoch: u64, + pub owner_replica_id: String, + pub owner_peer_endpoint: String, + connected_at_ms: i64, + resource_version: u64, + last_renewed_at: Instant, +} + +impl OwnerGuard { + /// True once renewals have failed for long enough that another replica can + /// supersede this claim, making it unsafe to keep serving the session. + pub fn claim_expired(&self, ttl: Duration) -> bool { + self.last_renewed_at.elapsed() >= ttl + } +} + +pub struct SupervisorOwnerIndex { + store: Arc, + ttl: Duration, +} + +impl SupervisorOwnerIndex { + pub fn new(store: Arc, ttl: Duration) -> Self { + Self { store, ttl } + } + + pub async fn publish( + &self, + sandbox_id: &str, + session_id: &str, + supervisor_instance_id: &str, + connection_epoch: u64, + owner_replica_id: &str, + owner_peer_endpoint: &str, + ) -> Result { + let connected_at_ms = now_ms(); + let payload = OwnerPayload { + sandbox_id: sandbox_id.to_string(), + session_id: session_id.to_string(), + supervisor_instance_id: supervisor_instance_id.to_string(), + connection_epoch, + owner_replica_id: owner_replica_id.to_string(), + owner_peer_endpoint: owner_peer_endpoint.to_string(), + connected_at_ms, + }; + + let condition = match self.read(sandbox_id).await? { + None => WriteCondition::MustCreate, + Some(existing) + if can_supersede( + &existing, + supervisor_instance_id, + connection_epoch, + self.ttl, + ) => + { + WriteCondition::MatchResourceVersion(existing.resource_version) + } + Some(_) => return Err(OwnerError::AlreadyOwned), + }; + + let result = self.write_payload(sandbox_id, &payload, condition).await?; + Ok(OwnerGuard { + sandbox_id: sandbox_id.to_string(), + session_id: session_id.to_string(), + supervisor_instance_id: supervisor_instance_id.to_string(), + connection_epoch, + owner_replica_id: owner_replica_id.to_string(), + owner_peer_endpoint: owner_peer_endpoint.to_string(), + connected_at_ms, + resource_version: result.resource_version, + last_renewed_at: Instant::now(), + }) + } + + pub async fn renew(&self, guard: &mut OwnerGuard) -> Result<(), OwnerError> { + let payload = OwnerPayload { + sandbox_id: guard.sandbox_id.clone(), + session_id: guard.session_id.clone(), + supervisor_instance_id: guard.supervisor_instance_id.clone(), + connection_epoch: guard.connection_epoch, + owner_replica_id: guard.owner_replica_id.clone(), + owner_peer_endpoint: guard.owner_peer_endpoint.clone(), + connected_at_ms: guard.connected_at_ms, + }; + + match self + .write_payload( + &guard.sandbox_id, + &payload, + WriteCondition::MatchResourceVersion(guard.resource_version), + ) + .await + { + Ok(result) => { + guard.resource_version = result.resource_version; + guard.last_renewed_at = Instant::now(); + Ok(()) + } + Err(OwnerError::Store(PersistenceError::Conflict { .. })) => Err(OwnerError::Conflict), + Err(err) => Err(err), + } + } + + pub async fn release_if_current(&self, guard: &OwnerGuard) -> Result<(), OwnerError> { + let Some(record) = self.read(&guard.sandbox_id).await? else { + return Ok(()); + }; + if record.session_id != guard.session_id + || record.owner_replica_id != guard.owner_replica_id + { + return Ok(()); + } + match self + .store + .delete_if( + OWNER_OBJECT_TYPE, + &owner_object_id(&guard.sandbox_id), + record.resource_version, + ) + .await + { + Ok(_) => Ok(()), + Err(PersistenceError::Conflict { .. }) => Err(OwnerError::Conflict), + Err(err) => Err(OwnerError::Store(err)), + } + } + + pub async fn read(&self, sandbox_id: &str) -> Result, OwnerError> { + let Some(record) = self + .store + .get(OWNER_OBJECT_TYPE, &owner_object_id(sandbox_id)) + .await + .map_err(OwnerError::Store)? + else { + return Ok(None); + }; + + let payload: OwnerPayload = serde_json::from_slice(&record.payload) + .map_err(|err| PersistenceError::Decode(err.to_string()))?; + Ok(Some(OwnerRecord { + session_id: payload.session_id, + supervisor_instance_id: payload.supervisor_instance_id, + connection_epoch: payload.connection_epoch, + owner_replica_id: payload.owner_replica_id, + owner_peer_endpoint: payload.owner_peer_endpoint, + connected_at_ms: payload.connected_at_ms, + updated_at_ms: record.updated_at_ms, + resource_version: record.resource_version, + })) + } + + async fn write_payload( + &self, + sandbox_id: &str, + payload: &OwnerPayload, + condition: WriteCondition, + ) -> Result { + let payload_bytes = + serde_json::to_vec(payload).map_err(|err| PersistenceError::Encode(err.to_string())); + let payload_bytes = payload_bytes.map_err(OwnerError::Store)?; + match self + .store + .put_if( + OWNER_OBJECT_TYPE, + &owner_object_id(sandbox_id), + sandbox_id, + "", + &payload_bytes, + None, + condition, + ) + .await + { + Ok(result) => Ok(result), + Err(PersistenceError::UniqueViolation { .. }) => Err(OwnerError::AlreadyOwned), + Err(PersistenceError::Conflict { .. }) => Err(OwnerError::Conflict), + Err(err) => Err(OwnerError::Store(err)), + } + } +} + +fn can_supersede( + existing: &OwnerRecord, + supervisor_instance_id: &str, + connection_epoch: u64, + ttl: Duration, +) -> bool { + if !existing.is_fresh(ttl) { + return true; + } + + existing.supervisor_instance_id == supervisor_instance_id + && connection_epoch > existing.connection_epoch +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_index(ttl: Duration) -> SupervisorOwnerIndex { + let store = Arc::new(crate::persistence::test_store().await); + SupervisorOwnerIndex::new(store, ttl) + } + + fn record_updated_at(updated_at_ms: i64) -> OwnerRecord { + OwnerRecord { + session_id: "s1".to_string(), + supervisor_instance_id: "inst".to_string(), + connection_epoch: 1, + owner_replica_id: "gw-1".to_string(), + owner_peer_endpoint: "https://gw-1".to_string(), + connected_at_ms: 0, + updated_at_ms, + resource_version: 1, + } + } + + fn owner_ttl_ms() -> i64 { + i64::try_from(OWNER_TTL.as_millis()).unwrap() + } + + #[test] + fn freshness_clamps_a_future_timestamp_instead_of_going_negative() { + let skewed = record_updated_at(now_ms() + owner_ttl_ms() * 10); + assert!(skewed.is_fresh(OWNER_TTL)); + assert!(!can_supersede(&skewed, "other-inst", 99, OWNER_TTL)); + } + + #[test] + fn only_ownership_conflicts_count_as_lost_ownership() { + assert!(OwnerError::AlreadyOwned.is_ownership_lost()); + assert!(OwnerError::Conflict.is_ownership_lost()); + assert!( + !OwnerError::Store(PersistenceError::Database("db unreachable".to_string())) + .is_ownership_lost() + ); + } + + #[tokio::test] + async fn a_claim_expires_once_renewals_stop_for_the_ttl() { + let index = test_index(OWNER_TTL).await; + let guard = index + .publish("sbx", "s1", "inst", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + assert!(!guard.claim_expired(OWNER_TTL)); + assert!(guard.claim_expired(Duration::ZERO)); + } + + #[test] + fn freshness_survives_a_corrupt_timestamp() { + let corrupt = record_updated_at(i64::MIN); + assert!(!corrupt.is_fresh(OWNER_TTL)); + } + + #[test] + fn freshness_expires_past_the_ttl() { + let stale = record_updated_at(now_ms() - owner_ttl_ms() - 1); + assert!(!stale.is_fresh(OWNER_TTL)); + assert!(can_supersede(&stale, "other-inst", 1, OWNER_TTL)); + } + + #[tokio::test] + async fn publish_creates_owner() { + let index = test_index(OWNER_TTL).await; + let guard = index + .publish("sbx", "s1", "inst", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + let record = index.read("sbx").await.unwrap().unwrap(); + assert_eq!(record.session_id, guard.session_id); + assert_eq!(record.owner_replica_id, "gw-1"); + } + + #[tokio::test] + async fn publish_does_not_collide_with_sandbox_object_id() { + let index = test_index(OWNER_TTL).await; + index + .store + .put("sandbox", "sbx", "sandbox-a", "default", br"{}", None) + .await + .unwrap(); + + index + .publish("sbx", "s1", "inst", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + + let record = index.read("sbx").await.unwrap().unwrap(); + assert_eq!(record.session_id, "s1"); + assert_eq!(record.owner_replica_id, "gw-1"); + } + + #[tokio::test] + async fn publish_rejects_active_different_instance() { + let index = test_index(OWNER_TTL).await; + index + .publish("sbx", "s1", "inst-a", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + let err = index + .publish("sbx", "s2", "inst-b", 1, "gw-2", "http://gw-2") + .await + .unwrap_err(); + assert!(matches!(err, OwnerError::AlreadyOwned)); + } + + #[tokio::test] + async fn publish_supersedes_same_instance_higher_epoch() { + let index = test_index(OWNER_TTL).await; + index + .publish("sbx", "s1", "inst", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + let guard = index + .publish("sbx", "s2", "inst", 2, "gw-2", "http://gw-2") + .await + .unwrap(); + let record = index.read("sbx").await.unwrap().unwrap(); + assert_eq!(record.session_id, guard.session_id); + assert_eq!(record.owner_replica_id, "gw-2"); + } + + #[tokio::test] + async fn release_if_current_ignores_stale_guard() { + let index = test_index(OWNER_TTL).await; + let old = index + .publish("sbx", "s1", "inst", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + let new = index + .publish("sbx", "s2", "inst", 2, "gw-2", "http://gw-2") + .await + .unwrap(); + index.release_if_current(&old).await.unwrap(); + let record = index.read("sbx").await.unwrap().unwrap(); + assert_eq!(record.session_id, new.session_id); + } + + #[tokio::test] + async fn renew_updates_resource_version() { + let index = test_index(OWNER_TTL).await; + let mut guard = index + .publish("sbx", "s1", "inst", 1, "gw-1", "http://gw-1") + .await + .unwrap(); + let before = guard.resource_version; + index.renew(&mut guard).await.unwrap(); + assert!(guard.resource_version > before); + } +} diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 1bf961e46..a1140e9fd 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -9,15 +9,19 @@ use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; use tokio_stream::wrappers::ReceiverStream; +use tonic::metadata::{Ascii, MetadataValue}; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; use uuid::Uuid; use openshell_core::proto::{ - GatewayMessage, ProviderReadinessObservation, RelayFrame, RelayInit, RelayOpen, - ReportMainProcessExitRequest, ReportMainProcessExitResponse, Sandbox, SandboxPhase, - SessionAccepted, SshRelayTarget, SupervisorMessage, gateway_message, relay_open, - supervisor_message, + GatewayMessage, GetSandboxProviderStatusRequest, GetSandboxProviderStatusResponse, + PeerRelayFrame, PeerRelayInit, ProviderReadinessObservation, RelayFrame, RelayInit, RelayOpen, + ReportEndpointStatusRequest, ReportEndpointStatusResponse, ReportMainProcessExitRequest, + ReportMainProcessExitResponse, ReportProviderReadinessRequest, ReportProviderReadinessResponse, + Sandbox, SandboxPhase, SessionAccepted, SshRelayTarget, SupervisorMessage, gateway_message, + open_shell_client, peer_relay_frame, relay_open, supervisor_message, }; use openshell_core::transport_errors::is_expected_transport_close_status; @@ -25,8 +29,10 @@ use crate::ServerState; use crate::auth::principal::Principal; use crate::grpc::provider_readiness::ProviderReadinessEvidence; use crate::persistence::ObjectId; +use crate::supervisor_owner::{OWNER_TTL, OwnerError, OwnerGuard, SupervisorOwnerIndex}; const HEARTBEAT_INTERVAL_SECS: u32 = 15; +const OWNER_RENEW_TIMEOUT: Duration = Duration::from_secs(5); const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10); /// Initial backoff between session-availability polls in `wait_for_session`. const SESSION_WAIT_INITIAL_BACKOFF: Duration = Duration::from_millis(100); @@ -42,6 +48,219 @@ const MAX_PENDING_RELAYS: usize = 256; /// consume the entire global budget. Sits above the SSH-tunnel per-sandbox /// cap (20) so tunnel-specific limits still fire first for that caller. const MAX_PENDING_RELAYS_PER_SANDBOX: usize = 32; +const PEER_TLS_CA_FILE_ENV: &str = "OPENSHELL_PEER_TLS_CA_FILE"; +const PEER_TLS_CERT_FILE_ENV: &str = "OPENSHELL_PEER_TLS_CERT_FILE"; +const PEER_TLS_KEY_FILE_ENV: &str = "OPENSHELL_PEER_TLS_KEY_FILE"; +const PEER_TLS_SERVER_NAME_ENV: &str = "OPENSHELL_PEER_TLS_SERVER_NAME"; +/// How long a resolved owner record is reused before rereading the store. +/// Well below `OWNER_TTL` so a cache hit can never outlive the record itself. +/// Marks an owner record written by a gateway that advertises no peer endpoint. +/// Only that gateway can serve such a session, so no peer should dial it. +const LOCAL_OWNER_ENDPOINT_SCHEME: &str = "local://"; +const OWNER_CACHE_TTL: Duration = Duration::from_secs(3); +/// How often the owner cache reclaims expired entries. Rate-limited so an +/// insert never scans the whole map. +const OWNER_CACHE_SWEEP_INTERVAL: Duration = Duration::from_secs(30); +/// How long the projected peer `ServiceAccount` token is held in memory. +/// The kubelet rotates the file hourly, so this only bounds staleness. +const PEER_TOKEN_CACHE_TTL: Duration = Duration::from_mins(5); + +#[derive(Debug, Default)] +struct PeerTlsClientConfig { + ca_file: Option, + cert_file: Option, + key_file: Option, + server_name: Option, +} + +impl PeerTlsClientConfig { + fn from_env() -> Self { + Self { + ca_file: nonempty_env(PEER_TLS_CA_FILE_ENV).map(Into::into), + cert_file: nonempty_env(PEER_TLS_CERT_FILE_ENV).map(Into::into), + key_file: nonempty_env(PEER_TLS_KEY_FILE_ENV).map(Into::into), + server_name: nonempty_env(PEER_TLS_SERVER_NAME_ENV), + } + } + + fn load(&self) -> Result { + let mut tls = if let Some(path) = self.ca_file.as_deref() { + let pem = std::fs::read(path).map_err(|err| { + Status::failed_precondition(format!( + "failed to read gateway peer TLS CA {}: {err}", + path.display() + )) + })?; + ClientTlsConfig::new().ca_certificate(Certificate::from_pem(pem)) + } else { + ClientTlsConfig::new().with_native_roots() + }; + + match (self.cert_file.as_deref(), self.key_file.as_deref()) { + (Some(cert_path), Some(key_path)) => { + let cert = std::fs::read(cert_path).map_err(|err| { + Status::failed_precondition(format!( + "failed to read gateway peer TLS certificate {}: {err}", + cert_path.display() + )) + })?; + let key = std::fs::read(key_path).map_err(|err| { + Status::failed_precondition(format!( + "failed to read gateway peer TLS key {}: {err}", + key_path.display() + )) + })?; + tls = tls.identity(Identity::from_pem(cert, key)); + } + (None, None) => {} + _ => { + return Err(Status::failed_precondition(format!( + "{PEER_TLS_CERT_FILE_ENV} and {PEER_TLS_KEY_FILE_ENV} must be configured together" + ))); + } + } + + if let Some(server_name) = self.server_name.as_deref() { + tls = tls.domain_name(server_name); + } + Ok(tls) + } +} + +fn nonempty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Reusable peer state: one HTTP/2 channel per peer endpoint, the projected +/// `ServiceAccount` token, and recently resolved owner records. +/// +/// Without this every forwarded relay paid a TLS handshake, a blocking token +/// file read, and a store read. +#[derive(Default)] +pub struct PeerRouteCache { + channels: Mutex>, + token: Mutex>, + owners: Mutex, +} + +#[derive(Default)] +struct OwnerCache { + entries: HashMap, + last_sweep: Option, +} + +/// Hand-written so the cached `ServiceAccount` token is never formatted. +impl std::fmt::Debug for PeerRouteCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PeerRouteCache").finish_non_exhaustive() + } +} + +struct CachedPeerToken { + token: String, + refresh_at: Instant, +} + +struct CachedOwner { + record: crate::supervisor_owner::OwnerRecord, + expires_at: Instant, +} + +impl PeerRouteCache { + /// Cloning a `Channel` shares the existing connection, so concurrent relays + /// to the same peer multiplex as HTTP/2 streams instead of dialing again. + async fn channel(&self, endpoint: &str) -> Result { + let cached = self.channels.lock().unwrap().get(endpoint).cloned(); + if let Some(channel) = cached { + return Ok(channel); + } + + let connected = build_peer_channel(endpoint).await?; + let mut channels = self.channels.lock().unwrap(); + // Two relays can miss together; keep whichever landed first so both + // end up on one connection and the loser's channel is dropped. + Ok(channels + .entry(endpoint.to_string()) + .or_insert(connected) + .clone()) + } + + /// Drops a peer connection so the next relay redials. Needed when a pod is + /// replaced and its endpoint now points at a dead or recycled address. + fn evict_channel(&self, endpoint: &str) { + self.channels.lock().unwrap().remove(endpoint); + } + + async fn peer_token(&self) -> Result { + let cached = self + .token + .lock() + .unwrap() + .as_ref() + .filter(|cached| cached.refresh_at > Instant::now()) + .map(|cached| cached.token.clone()); + if let Some(token) = cached { + return Ok(token); + } + + let loaded = tokio::task::spawn_blocking( + crate::auth::peer::load_peer_service_account_token_from_env, + ) + .await + .map_err(|_| Status::internal("gateway peer token read task failed"))? + .map_err(|err| { + Status::failed_precondition(format!("gateway peer token load failed: {err}")) + })? + .ok_or_else(|| { + Status::failed_precondition("gateway peer ServiceAccount token is not configured") + })?; + + *self.token.lock().unwrap() = Some(CachedPeerToken { + token: loaded.clone(), + refresh_at: Instant::now() + PEER_TOKEN_CACHE_TTL, + }); + Ok(loaded) + } + + fn cached_owner(&self, sandbox_id: &str) -> Option { + let now = Instant::now(); + let mut owners = self.owners.lock().unwrap(); + let entry = owners.entries.get(sandbox_id)?; + if entry.expires_at <= now { + owners.entries.remove(sandbox_id); + return None; + } + Some(entry.record.clone()) + } + + fn store_owner(&self, sandbox_id: &str, record: &crate::supervisor_owner::OwnerRecord) { + let now = Instant::now(); + let mut owners = self.owners.lock().unwrap(); + // Expiry is enforced per entry on read, so the full scan only needs to + // reclaim memory. Rate-limit it to keep inserts off an O(n) path. + if owners + .last_sweep + .is_none_or(|last| now.duration_since(last) >= OWNER_CACHE_SWEEP_INTERVAL) + { + owners.last_sweep = Some(now); + owners.entries.retain(|_, entry| entry.expires_at > now); + } + owners.entries.insert( + sandbox_id.to_string(), + CachedOwner { + record: record.clone(), + expires_at: now + OWNER_CACHE_TTL, + }, + ); + } + + fn evict_owner(&self, sandbox_id: &str) { + self.owners.lock().unwrap().entries.remove(sandbox_id); + } +} // --------------------------------------------------------------------------- // Session registry @@ -378,7 +597,7 @@ impl SupervisorSessionRegistry { /// Fail closed when projecting persisted endpoint status without a live, /// initialized observation authority in the current gateway process. - pub(crate) fn project_endpoint_status(&self, sandbox: &mut Sandbox) { + pub(crate) fn project_endpoint_status(&self, sandbox: &mut Sandbox, remote_authority: bool) { let sandbox_id = sandbox.object_id(); let has_authority = self .sessions @@ -386,7 +605,7 @@ impl SupervisorSessionRegistry { .unwrap() .get(sandbox_id) .is_some_and(|session| session.endpoint_status_initialized); - if has_authority { + if has_authority || remote_authority { return; } let Some(status) = sandbox.status.as_mut() else { @@ -512,16 +731,36 @@ impl SupervisorSessionRegistry { ), Status, > { - let tx = self - .wait_for_session(sandbox_id, session_wait_timeout) - .await?; - let channel_id = Uuid::new_v4().to_string(); let relay_open = RelayOpen { channel_id: channel_id.clone(), target: Some(target), service_id, }; + self.open_relay_with_message(sandbox_id, relay_open, session_wait_timeout) + .await + } + + pub async fn open_relay_with_message( + &self, + sandbox_id: &str, + relay_open: RelayOpen, + session_wait_timeout: Duration, + ) -> Result< + ( + String, + oneshot::Receiver>, + ), + Status, + > { + if relay_open.channel_id.is_empty() { + return Err(Status::invalid_argument("relay channel_id is required")); + } + let tx = self + .wait_for_session(sandbox_id, session_wait_timeout) + .await?; + + let channel_id = relay_open.channel_id.clone(); // Register the pending relay before sending RelayOpen to avoid a race. // Both caps are checked and the insert happens under a single lock hold @@ -683,6 +922,18 @@ pub fn spawn_relay_reaper(state: Arc, interval: Duration) { }); } +fn owner_error_to_status(err: OwnerError) -> Status { + match err { + OwnerError::AlreadyOwned => { + Status::unavailable("supervisor session owned by another gateway replica") + } + OwnerError::Conflict => Status::aborted("supervisor owner record changed concurrently"), + OwnerError::Store(err) => { + Status::internal(format!("supervisor owner persistence failed: {err}")) + } + } +} + async fn require_persisted_sandbox( store: &Arc, sandbox_id: &str, @@ -916,6 +1167,563 @@ fn expected_transport_close_during_session_state( ) } +// --------------------------------------------------------------------------- +// PeerRelay gRPC handler and client-side forwarding +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct PeerAuthInterceptor { + bearer: MetadataValue, + replica_id: MetadataValue, +} + +impl PeerAuthInterceptor { + fn new(token: &str, replica_id: &str) -> Result { + let bearer = MetadataValue::try_from(format!("Bearer {token}")) + .map_err(|_| Status::internal("invalid gateway peer SA token header value"))?; + let replica_id = MetadataValue::try_from(replica_id.to_string()) + .map_err(|_| Status::internal("invalid gateway replica id header value"))?; + Ok(Self { bearer, replica_id }) + } +} + +impl tonic::service::Interceptor for PeerAuthInterceptor { + fn call(&mut self, mut req: Request<()>) -> Result, Status> { + req.metadata_mut() + .insert("authorization", self.bearer.clone()); + req.metadata_mut() + .insert("x-openshell-peer-replica", self.replica_id.clone()); + Ok(req) + } +} + +async fn build_peer_channel(endpoint: &str) -> Result { + let mut ep = Endpoint::from_shared(endpoint.to_string()) + .map_err(|err| Status::internal(format!("invalid gateway peer endpoint: {err}")))? + .connect_timeout(Duration::from_secs(10)) + .http2_keep_alive_interval(Duration::from_secs(10)) + .keep_alive_while_idle(true) + .keep_alive_timeout(Duration::from_secs(20)) + .http2_adaptive_window(true); + + if endpoint.starts_with("https://") { + let peer_tls = PeerTlsClientConfig::from_env().load()?; + ep = ep + .tls_config(peer_tls) + .map_err(|err| Status::internal(format!("failed to configure peer TLS: {err}")))?; + } + + ep.connect() + .await + .map_err(|err| Status::unavailable(format!("gateway peer connection failed: {err}"))) +} + +async fn peer_rpc_client( + state: &Arc, + endpoint: &str, +) -> Result< + open_shell_client::OpenShellClient< + tonic::service::interceptor::InterceptedService, + >, + Status, +> { + let token = state.peer_routes.peer_token().await?; + let channel = state.peer_routes.channel(endpoint).await?; + let interceptor = PeerAuthInterceptor::new(&token, &state.replica_id)?; + Ok(open_shell_client::OpenShellClient::with_interceptor( + channel, + interceptor, + )) +} + +pub(crate) async fn remote_supervisor_owner( + state: &Arc, + sandbox_id: &str, +) -> Result, Status> { + let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); + // Supervisor-bound state must follow the durable owner record. A cached + // local owner can remain valid for relay discovery while ownership has + // already moved, but it must never authorize a stale local session to + // publish readiness or endpoint observations. + let Some(owner) = owner_index + .read(sandbox_id) + .await + .map_err(owner_error_to_status)? + else { + state.peer_routes.evict_owner(sandbox_id); + return Ok(None); + }; + if !owner_is_fresh(&owner) { + state.peer_routes.evict_owner(sandbox_id); + return Ok(None); + } + state.peer_routes.store_owner(sandbox_id, &owner); + if owner.owner_replica_id == state.replica_id { + return Ok(None); + } + if owner_endpoint_is_local_only(&owner.owner_peer_endpoint) { + return Err(Status::failed_precondition(format!( + "sandbox is owned by gateway replica {} which advertises no peer endpoint", + owner.owner_replica_id + ))); + } + Ok(Some(owner)) +} + +pub(crate) async fn forward_provider_readiness_to_owner( + state: &Arc, + owner: &crate::supervisor_owner::OwnerRecord, + request: ReportProviderReadinessRequest, +) -> Result { + let sandbox_id = request.sandbox_id.clone(); + let mut client = peer_rpc_client(state, &owner.owner_peer_endpoint).await?; + client + .peer_report_provider_readiness(request) + .await + .map(Response::into_inner) + .inspect_err(|_| { + state.peer_routes.evict_channel(&owner.owner_peer_endpoint); + state.peer_routes.evict_owner(&sandbox_id); + }) +} + +pub(crate) async fn forward_endpoint_status_to_owner( + state: &Arc, + owner: &crate::supervisor_owner::OwnerRecord, + request: ReportEndpointStatusRequest, +) -> Result { + let sandbox_id = request.sandbox_id.clone(); + let mut client = peer_rpc_client(state, &owner.owner_peer_endpoint).await?; + client + .peer_report_endpoint_status(request) + .await + .map(Response::into_inner) + .inspect_err(|_| { + state.peer_routes.evict_channel(&owner.owner_peer_endpoint); + state.peer_routes.evict_owner(&sandbox_id); + }) +} + +pub(crate) async fn forward_provider_status_query_to_owner( + state: &Arc, + owner: &crate::supervisor_owner::OwnerRecord, + sandbox_id: &str, + request: GetSandboxProviderStatusRequest, +) -> Result { + let mut client = peer_rpc_client(state, &owner.owner_peer_endpoint).await?; + client + .peer_get_sandbox_provider_status(request) + .await + .map(Response::into_inner) + .inspect_err(|_| { + state.peer_routes.evict_channel(&owner.owner_peer_endpoint); + state.peer_routes.evict_owner(sandbox_id); + }) +} + +pub async fn open_routed_relay_with_target( + state: &Arc, + sandbox_id: &str, + target: relay_open::Target, + service_id: String, + session_wait_timeout: Duration, +) -> Result< + ( + String, + oneshot::Receiver>, + ), + Status, +> { + let channel_id = Uuid::new_v4().to_string(); + let relay_open = RelayOpen { + channel_id: channel_id.clone(), + target: Some(target), + service_id, + }; + open_routed_relay_with_message(state, sandbox_id, relay_open, session_wait_timeout).await +} + +pub async fn open_routed_relay_with_message( + state: &Arc, + sandbox_id: &str, + relay_open: RelayOpen, + session_wait_timeout: Duration, +) -> Result< + ( + String, + oneshot::Receiver>, + ), + Status, +> { + let deadline = Instant::now() + session_wait_timeout; + let mut backoff = SESSION_WAIT_INITIAL_BACKOFF; + let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); + loop { + if state.supervisor_sessions.has_session(sandbox_id) { + match state + .supervisor_sessions + .open_relay_with_message(sandbox_id, relay_open.clone(), Duration::ZERO) + .await + { + Ok(relay) => return Ok(relay), + Err(status) if status.code() == tonic::Code::Unavailable => { + // The session can migrate after `has_session` but before + // RelayOpen reaches its sender. Fall through and reread the + // persisted owner instead of surfacing a handoff race. + warn!( + sandbox_id, + error = %status, + "local supervisor relay disappeared during open; resolving owner again" + ); + } + Err(status) => return Err(status), + } + } + + if let Some(owner) = resolve_owner(state, &owner_index, sandbox_id).await? + && owner_is_fresh(&owner) + { + if owner.owner_replica_id == state.replica_id { + warn!( + sandbox_id, + owner_replica_id = %owner.owner_replica_id, + "supervisor owner record points at this replica but no local session is registered; retrying" + ); + state.peer_routes.evict_owner(sandbox_id); + if Instant::now() + backoff > deadline { + return Err(Status::unavailable("supervisor session not connected")); + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(SESSION_WAIT_MAX_BACKOFF); + continue; + } + if owner_endpoint_is_local_only(&owner.owner_peer_endpoint) { + return Err(Status::failed_precondition(format!( + "sandbox is owned by gateway replica {} which advertises no peer endpoint; \ + set OPENSHELL_PEER_ENDPOINT on every replica to route across replicas", + owner.owner_replica_id + ))); + } + match open_peer_relay( + state, + owner.owner_peer_endpoint.clone(), + sandbox_id, + relay_open.clone(), + ) + .await + { + Ok(relay) => return Ok(relay), + Err(status) => { + warn!( + sandbox_id, + owner_replica_id = %owner.owner_replica_id, + owner_peer_endpoint = %owner.owner_peer_endpoint, + error = %status, + "gateway peer owner relay open failed; retrying until session wait timeout" + ); + // The record may name a replaced pod, so retry against a + // fresh read rather than the cached endpoint. + state.peer_routes.evict_owner(sandbox_id); + } + } + } + + if Instant::now() + backoff > deadline { + return Err(Status::unavailable("supervisor session not connected")); + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(SESSION_WAIT_MAX_BACKOFF); + } +} + +/// Reads the owning replica, reusing a recent result when one is cached. +async fn resolve_owner( + state: &Arc, + owner_index: &SupervisorOwnerIndex, + sandbox_id: &str, +) -> Result, Status> { + if let Some(record) = state.peer_routes.cached_owner(sandbox_id) { + return Ok(Some(record)); + } + + let record = owner_index + .read(sandbox_id) + .await + .map_err(owner_error_to_status)?; + if let Some(record) = record.as_ref() { + state.peer_routes.store_owner(sandbox_id, record); + } + Ok(record) +} + +fn owner_is_fresh(owner: &crate::supervisor_owner::OwnerRecord) -> bool { + owner.is_fresh(OWNER_TTL) +} + +/// Endpoint recorded when this replica advertises none. +fn local_owner_endpoint(replica_id: &str) -> String { + format!("{LOCAL_OWNER_ENDPOINT_SCHEME}{replica_id}") +} + +/// True when an owner record names a gateway that no peer can dial. +fn owner_endpoint_is_local_only(endpoint: &str) -> bool { + endpoint.starts_with(LOCAL_OWNER_ENDPOINT_SCHEME) +} + +async fn open_peer_relay( + state: &Arc, + owner_peer_endpoint: String, + sandbox_id: &str, + relay_open: RelayOpen, +) -> Result< + ( + String, + oneshot::Receiver>, + ), + Status, +> { + let channel_id = relay_open.channel_id.clone(); + let (relay_tx, relay_rx) = oneshot::channel(); + let stream = connect_peer_relay(state, &owner_peer_endpoint, sandbox_id, relay_open).await?; + let _ = relay_tx.send(Ok(stream)); + Ok((channel_id, relay_rx)) +} + +async fn connect_peer_relay( + state: &Arc, + owner_peer_endpoint: &str, + sandbox_id: &str, + relay_open: RelayOpen, +) -> Result { + let token = state.peer_routes.peer_token().await?; + let channel = state.peer_routes.channel(owner_peer_endpoint).await?; + let interceptor = PeerAuthInterceptor::new(&token, &state.replica_id)?; + let mut client = open_shell_client::OpenShellClient::with_interceptor(channel, interceptor); + + let (out_tx, out_rx) = mpsc::channel::(16); + out_tx + .send(PeerRelayFrame { + payload: Some(peer_relay_frame::Payload::Init(PeerRelayInit { + sandbox_id: sandbox_id.to_string(), + relay_open: Some(relay_open), + requester_replica_id: state.replica_id.clone(), + })), + }) + .await + .map_err(|_| Status::internal("failed to initialize peer relay stream"))?; + + let response = client + .peer_relay(ReceiverStream::new(out_rx)) + .await + .map_err(|err| { + state.peer_routes.evict_channel(owner_peer_endpoint); + Status::unavailable(format!("gateway peer relay RPC failed: {err}")) + })?; + let inbound = response.into_inner(); + let (gateway_stream, bridge_stream) = tokio::io::duplex(64 * 1024); + spawn_peer_bridge(bridge_stream, inbound, out_tx, sandbox_id.to_string()); + Ok(gateway_stream) +} + +pub async fn handle_peer_relay( + state: &Arc, + request: Request>, +) -> Result< + Response< + Pin> + Send + 'static>>, + >, + Status, +> { + let peer = match request.extensions().get::() { + Some(Principal::Peer(peer)) => peer.clone(), + _ => { + return Err(Status::permission_denied( + "gateway peer principal is required", + )); + } + }; + let mut inbound = request.into_inner(); + + let first = inbound + .message() + .await? + .ok_or_else(|| Status::invalid_argument("empty PeerRelay stream"))?; + let Some(peer_relay_frame::Payload::Init(init)) = first.payload else { + return Err(Status::invalid_argument( + "first PeerRelayFrame must be init", + )); + }; + if init.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if init.requester_replica_id != peer.replica_id { + return Err(Status::permission_denied( + "peer relay requester does not match authenticated gateway replica", + )); + } + let relay_open = init + .relay_open + .ok_or_else(|| Status::invalid_argument("relay_open is required"))?; + if relay_open.channel_id.is_empty() { + return Err(Status::invalid_argument("relay channel_id is required")); + } + + info!( + sandbox_id = %init.sandbox_id, + channel_id = %relay_open.channel_id, + requester = %peer.replica_id, + "gateway peer relay: opening local supervisor relay" + ); + + let (channel_id, relay_rx) = state + .supervisor_sessions + .open_relay_with_message(&init.sandbox_id, relay_open, Duration::from_secs(5)) + .await?; + let supervisor_stream = match tokio::time::timeout(Duration::from_secs(10), relay_rx).await { + Ok(Ok(Ok(stream))) => stream, + Ok(Ok(Err(status))) => return Err(status), + Ok(Err(_)) => return Err(Status::unavailable("relay channel dropped")), + Err(_) => return Err(Status::deadline_exceeded("relay open timed out")), + }; + + let (out_tx, out_rx) = mpsc::channel::>(16); + spawn_peer_owner_bridge( + supervisor_stream, + inbound, + out_tx, + init.sandbox_id, + channel_id, + ); + let stream: Pin< + Box> + Send + 'static>, + > = Box::pin(ReceiverStream::new(out_rx)); + Ok(Response::new(stream)) +} + +fn spawn_peer_bridge( + bridge_stream: tokio::io::DuplexStream, + mut inbound: tonic::Streaming, + out_tx: mpsc::Sender, + sandbox_id: String, +) { + let (mut read_half, mut write_half) = tokio::io::split(bridge_stream); + let sandbox_id_in = sandbox_id.clone(); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(frame)) => { + let Some(peer_relay_frame::Payload::Data(data)) = frame.payload else { + warn!(sandbox_id = %sandbox_id_in, "gateway peer relay: non-data frame after init"); + break; + }; + if data.is_empty() { + continue; + } + if let Err(err) = + tokio::io::AsyncWriteExt::write_all(&mut write_half, &data).await + { + warn!(sandbox_id = %sandbox_id_in, error = %err, "gateway peer relay: write to duplex failed"); + break; + } + } + Ok(None) => break, + Err(err) => { + warn!(sandbox_id = %sandbox_id_in, error = %err, "gateway peer relay: inbound errored"); + break; + } + } + } + let _ = tokio::io::AsyncWriteExt::shutdown(&mut write_half).await; + }); + + tokio::spawn(async move { + let mut buf = vec![0u8; RELAY_STREAM_CHUNK_SIZE]; + loop { + match tokio::io::AsyncReadExt::read(&mut read_half, &mut buf).await { + Ok(0) => break, + Ok(n) => { + if out_tx + .send(PeerRelayFrame { + payload: Some(peer_relay_frame::Payload::Data(buf[..n].to_vec())), + }) + .await + .is_err() + { + break; + } + } + Err(err) => { + warn!(sandbox_id = %sandbox_id, error = %err, "gateway peer relay: read from duplex failed"); + break; + } + } + } + }); +} + +fn spawn_peer_owner_bridge( + supervisor_stream: tokio::io::DuplexStream, + mut inbound: tonic::Streaming, + out_tx: mpsc::Sender>, + sandbox_id: String, + channel_id: String, +) { + let (mut read_half, mut write_half) = tokio::io::split(supervisor_stream); + let sandbox_id_in = sandbox_id.clone(); + let channel_id_in = channel_id.clone(); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(frame)) => { + let Some(peer_relay_frame::Payload::Data(data)) = frame.payload else { + warn!(sandbox_id = %sandbox_id_in, channel_id = %channel_id_in, "gateway peer relay owner: non-data frame after init"); + break; + }; + if data.is_empty() { + continue; + } + if let Err(err) = + tokio::io::AsyncWriteExt::write_all(&mut write_half, &data).await + { + warn!(sandbox_id = %sandbox_id_in, channel_id = %channel_id_in, error = %err, "gateway peer relay owner: write to supervisor relay failed"); + break; + } + } + Ok(None) => break, + Err(err) => { + warn!(sandbox_id = %sandbox_id_in, channel_id = %channel_id_in, error = %err, "gateway peer relay owner: inbound errored"); + break; + } + } + } + let _ = tokio::io::AsyncWriteExt::shutdown(&mut write_half).await; + }); + + tokio::spawn(async move { + let mut buf = vec![0u8; RELAY_STREAM_CHUNK_SIZE]; + loop { + match tokio::io::AsyncReadExt::read(&mut read_half, &mut buf).await { + Ok(0) => break, + Ok(n) => { + if out_tx + .send(Ok(PeerRelayFrame { + payload: Some(peer_relay_frame::Payload::Data(buf[..n].to_vec())), + })) + .await + .is_err() + { + break; + } + } + Err(err) => { + warn!(sandbox_id = %sandbox_id, channel_id = %channel_id, error = %err, "gateway peer relay owner: read from supervisor relay failed"); + break; + } + } + } + }); +} + // --------------------------------------------------------------------------- // ConnectSupervisor gRPC handler // --------------------------------------------------------------------------- @@ -954,10 +1762,28 @@ pub async fn handle_connect_supervisor( let provider_readiness = ProviderReadinessEvidence::from_hello(&hello)?; let session_id = Uuid::new_v4().to_string(); + let owner_peer_endpoint = state.peer_endpoint.as_deref().map_or_else( + || local_owner_endpoint(&state.replica_id), + ToString::to_string, + ); + let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); + let owner_guard = owner_index + .publish( + &sandbox_id, + &session_id, + &hello.instance_id, + hello.connection_epoch, + &state.replica_id, + &owner_peer_endpoint, + ) + .await + .map_err(owner_error_to_status)?; info!( sandbox_id = %sandbox_id, session_id = %session_id, instance_id = %hello.instance_id, + connection_epoch = hello.connection_epoch, + replica_id = %state.replica_id, "supervisor session: accepted" ); @@ -991,12 +1817,18 @@ pub async fn handle_connect_supervisor( state .supervisor_sessions .remove_if_current(&sandbox_id, &session_id); + if let Err(err) = owner_index.release_if_current(&owner_guard).await { + warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release owner after endpoint status initialization failure"); + } return Err(error); } if !state .supervisor_sessions .initialize_endpoint_status_authority(&sandbox_id, &session_id) { + if let Err(err) = owner_index.release_if_current(&owner_guard).await { + warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release superseded owner during endpoint status initialization"); + } return Err(Status::failed_precondition( "supervisor session was replaced during endpoint status initialization", )); @@ -1009,6 +1841,9 @@ pub async fn handle_connect_supervisor( state .supervisor_sessions .remove_if_current(&sandbox_id, &session_id); + if let Err(err) = owner_index.release_if_current(&owner_guard).await { + warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release owner after provider readiness initialization failure"); + } return Err(error); } @@ -1028,6 +1863,9 @@ pub async fn handle_connect_supervisor( state .supervisor_sessions .remove_if_current(&sandbox_id, &session_id); + if let Err(err) = owner_index.release_if_current(&owner_guard).await { + warn!(sandbox_id = %sandbox_id, session_id = %session_id, error = %err, "supervisor session: failed to release owner after accept send failure"); + } return Err(Status::internal("failed to send session accepted")); } @@ -1044,6 +1882,9 @@ pub async fn handle_connect_supervisor( state .supervisor_sessions .remove_if_current(&sandbox_id, &session_id); + if let Err(release_error) = owner_index.release_if_current(&owner_guard).await { + warn!(sandbox_id, session_id, error = %release_error, "supervisor session: failed to release owner after lifecycle persistence failure"); + } warn!( sandbox_id = %sandbox_id, session_id = %session_id, @@ -1067,6 +1908,7 @@ pub async fn handle_connect_supervisor( let state_clone = Arc::clone(state); let sandbox_id_clone = sandbox_id.clone(); tokio::spawn(async move { + let mut owner_guard = owner_guard; run_session_loop( &state_clone, &sandbox_id_clone, @@ -1074,11 +1916,18 @@ pub async fn handle_connect_supervisor( &tx, &mut inbound, shutdown_rx, + &mut owner_guard, ) .await; let terminal_finalized = state_clone .supervisor_sessions .remove_if_current(&sandbox_id_clone, &session_id); + // Release only this exact ownership record. A newer supervisor session + // may already have published replacement ownership on another replica. + let owner_index = SupervisorOwnerIndex::new(state_clone.store.clone(), OWNER_TTL); + if let Err(err) = owner_index.release_if_current(&owner_guard).await { + warn!(sandbox_id = %sandbox_id_clone, session_id = %session_id, error = %err, "supervisor session: failed to release owner record"); + } if let Some(terminal_finalized) = terminal_finalized { info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended"); state_clone @@ -1179,6 +2028,7 @@ async fn run_session_loop( tx: &mpsc::Sender, inbound: &mut tonic::Streaming, mut shutdown_rx: oneshot::Receiver<()>, + owner_guard: &mut OwnerGuard, ) { let heartbeat_interval = Duration::from_secs(u64::from(HEARTBEAT_INTERVAL_SECS)); let mut heartbeat_timer = tokio::time::interval(heartbeat_interval); @@ -1194,7 +2044,9 @@ async fn run_session_loop( msg = inbound.message() => { match msg { Ok(Some(msg)) => { - handle_supervisor_message(state, sandbox_id, session_id, msg); + if !handle_supervisor_message(state, sandbox_id, session_id, msg, owner_guard).await { + break; + } } Ok(None) => { info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: stream closed by supervisor"); @@ -1237,15 +2089,65 @@ async fn run_session_loop( } } -fn handle_supervisor_message( +async fn handle_supervisor_message( state: &Arc, sandbox_id: &str, session_id: &str, msg: SupervisorMessage, -) { + owner_guard: &mut OwnerGuard, +) -> bool { match msg.payload { Some(supervisor_message::Payload::Heartbeat(_)) => { - // Heartbeat received — nothing to do for now. + let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL); + match tokio::time::timeout(OWNER_RENEW_TIMEOUT, owner_index.renew(owner_guard)).await { + Ok(Ok(())) => {} + // Only a real ownership change ends the session. A store error + // means the database did not answer, and closing on that would + // drop every session heartbeating during the outage. + Ok(Err(err)) if err.is_ownership_lost() => { + warn!( + sandbox_id = %sandbox_id, + session_id = %session_id, + error = %err, + "supervisor session: ownership lost; closing session" + ); + return false; + } + // Past the TTL our record is stale, so another replica may + // already have superseded it. Close rather than serve a + // session we can no longer claim. + Ok(Err(err)) if owner_guard.claim_expired(OWNER_TTL) => { + warn!( + sandbox_id = %sandbox_id, + session_id = %session_id, + error = %err, + "supervisor session: owner renewal failed past the ownership TTL; \ + closing session" + ); + return false; + } + Ok(Err(err)) => warn!( + sandbox_id = %sandbox_id, + session_id = %session_id, + error = %err, + "supervisor session: owner renewal failed; retrying on next heartbeat" + ), + Err(_) if owner_guard.claim_expired(OWNER_TTL) => { + warn!( + sandbox_id = %sandbox_id, + session_id = %session_id, + timeout_ms = OWNER_RENEW_TIMEOUT.as_millis(), + "supervisor session: owner renewal timed out past the ownership TTL; closing session" + ); + return false; + } + Err(_) => warn!( + sandbox_id = %sandbox_id, + session_id = %session_id, + timeout_ms = OWNER_RENEW_TIMEOUT.as_millis(), + "supervisor session: owner renewal timed out; retrying on next heartbeat" + ), + } } Some(supervisor_message::Payload::RelayOpenResult(result)) => { if result.success { @@ -1286,6 +2188,7 @@ fn handle_supervisor_message( ); } } + true } // --------------------------------------------------------------------------- @@ -1311,6 +2214,42 @@ mod tests { oneshot::channel::<()>().0 } + #[test] + fn peer_tls_client_config_requires_certificate_and_key_together() { + let config = PeerTlsClientConfig { + cert_file: Some("client.crt".into()), + ..Default::default() + }; + + let err = config + .load() + .expect_err("an incomplete peer mTLS identity must fail closed"); + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains(PEER_TLS_KEY_FILE_ENV)); + } + + #[test] + fn peer_tls_client_config_loads_chart_ca_identity_and_server_name() { + let dir = tempfile::tempdir().unwrap(); + let ca = dir.path().join("ca.crt"); + let cert = dir.path().join("tls.crt"); + let key = dir.path().join("tls.key"); + std::fs::write(&ca, b"test-ca").unwrap(); + std::fs::write(&cert, b"test-cert").unwrap(); + std::fs::write(&key, b"test-key").unwrap(); + + let config = PeerTlsClientConfig { + ca_file: Some(ca), + cert_file: Some(cert), + key_file: Some(key), + server_name: Some("openshell.openshell.svc.cluster.local".to_string()), + }; + + config + .load() + .expect("complete chart peer TLS materials should configure tonic"); + } + fn sandbox_record(id: &str, name: &str) -> Sandbox { Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -1377,11 +2316,17 @@ mod tests { }; let mut without_session = sandbox.clone(); - registry.project_endpoint_status(&mut without_session); + registry.project_endpoint_status(&mut without_session, false); let projected_status = without_session.status.expect("status"); assert_eq!(projected_status.endpoint_statuses, vec![unknown.clone()]); assert_eq!(projected_status.conditions, vec![ready.clone()]); + let mut remotely_owned = sandbox.clone(); + registry.project_endpoint_status(&mut remotely_owned, true); + let remote_status = remotely_owned.status.expect("status"); + assert_eq!(remote_status.endpoint_statuses, vec![endpoint.clone()]); + assert_eq!(remote_status.conditions, vec![ready.clone()]); + let (session_tx, _session_rx) = mpsc::channel(1); registry.register( "sandbox-1".to_string(), @@ -1390,13 +2335,13 @@ mod tests { make_shutdown(), ); let mut before_initialization = sandbox.clone(); - registry.project_endpoint_status(&mut before_initialization); + registry.project_endpoint_status(&mut before_initialization, false); let uninitialized_status = before_initialization.status.expect("status"); assert_eq!(uninitialized_status.endpoint_statuses, vec![unknown]); assert_eq!(uninitialized_status.conditions, vec![ready.clone()]); assert!(registry.initialize_endpoint_status_authority("sandbox-1", "session-1")); - registry.project_endpoint_status(&mut sandbox); + registry.project_endpoint_status(&mut sandbox, false); let initialized_status = sandbox.status.expect("status"); assert_eq!(initialized_status.endpoint_statuses, vec![endpoint]); assert_eq!(initialized_status.conditions, vec![ready]); @@ -2095,4 +3040,97 @@ mod tests { .contains_key("ch-fresh") ); } + + fn owner_record(replica: &str) -> crate::supervisor_owner::OwnerRecord { + crate::supervisor_owner::OwnerRecord { + session_id: "session-a".to_string(), + supervisor_instance_id: "instance-a".to_string(), + connection_epoch: 1, + owner_replica_id: replica.to_string(), + owner_peer_endpoint: format!("http://{replica}:8080"), + connected_at_ms: 0, + updated_at_ms: openshell_core::time::now_ms(), + resource_version: 1, + } + } + + #[test] + fn a_gateway_without_a_peer_endpoint_still_records_ownership() { + let endpoint = local_owner_endpoint("gw-0"); + assert_eq!(endpoint, "local://gw-0"); + assert!(owner_endpoint_is_local_only(&endpoint)); + } + + #[test] + fn a_dialable_owner_endpoint_is_not_local_only() { + assert!(!owner_endpoint_is_local_only("https://10.0.0.1:8080")); + assert!(!owner_endpoint_is_local_only("http://10.0.0.1:8080")); + } + + #[test] + fn owner_cache_returns_stored_record() { + let cache = PeerRouteCache::default(); + cache.store_owner("sbx-a", &owner_record("replica-a")); + + let cached = cache + .cached_owner("sbx-a") + .expect("record should be cached"); + assert_eq!(cached.owner_replica_id, "replica-a"); + } + + #[test] + fn owner_cache_misses_for_unknown_sandbox() { + let cache = PeerRouteCache::default(); + cache.store_owner("sbx-a", &owner_record("replica-a")); + + assert!(cache.cached_owner("sbx-b").is_none()); + } + + #[test] + fn evict_owner_forces_a_fresh_read() { + let cache = PeerRouteCache::default(); + cache.store_owner("sbx-a", &owner_record("replica-a")); + cache.evict_owner("sbx-a"); + + assert!(cache.cached_owner("sbx-a").is_none()); + } + + #[test] + fn owner_cache_drops_entries_past_their_ttl() { + let cache = PeerRouteCache::default(); + cache.owners.lock().unwrap().entries.insert( + "sbx-a".to_string(), + CachedOwner { + record: owner_record("replica-a"), + expires_at: Instant::now().checked_sub(Duration::from_secs(1)).unwrap(), + }, + ); + + assert!(cache.cached_owner("sbx-a").is_none()); + assert!(!cache.owners.lock().unwrap().entries.contains_key("sbx-a")); + } + + #[test] + fn owner_cache_ttl_stays_below_owner_record_ttl() { + // A cache hit must never extend the window in which a stale owner + // looks routable; `owner_is_fresh` is what enforces the real TTL. + assert!(OWNER_CACHE_TTL < OWNER_TTL); + } + + #[tokio::test] + async fn evict_channel_removes_only_the_named_peer() { + let cache = PeerRouteCache::default(); + let channel = Endpoint::from_static("http://10.0.0.1:8080").connect_lazy(); + { + let mut channels = cache.channels.lock().unwrap(); + channels.insert("http://10.0.0.1:8080".to_string(), channel.clone()); + channels.insert("http://10.0.0.2:8080".to_string(), channel); + } + + cache.evict_channel("http://10.0.0.1:8080"); + + let channels = cache.channels.lock().unwrap(); + assert!(!channels.contains_key("http://10.0.0.1:8080")); + assert!(channels.contains_key("http://10.0.0.2:8080")); + } } diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 8ff4060e8..9d9650c2f 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -154,6 +154,12 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/fake-compute-driver", + "test", + [], + )), }, sandboxes: HashMap::new(), calls: Vec::new(), diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 64e2762e2..414251734 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -22,10 +22,10 @@ use openshell_core::proto::{ GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, - RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RelayFrame, RevokeSshSessionRequest, - RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, - SupervisorMessage, TcpForwardFrame, UpdateProviderRequest, WatchSandboxRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, PeerRelayFrame, + ProviderResponse, RefreshSandboxTokenRequest, RefreshSandboxTokenResponse, RelayFrame, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, SupervisorMessage, TcpForwardFrame, UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -53,6 +53,27 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -584,6 +605,15 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + type PeerRelayStream = ReceiverStream>; + + async fn peer_relay( + &self, + _request: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + type ForwardTcpStream = std::pin::Pin> + Send>>; diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 31ab9cb1e..5856acbbd 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -23,7 +23,7 @@ use hyper_util::{ server::conn::auto::Builder, }; use openshell_core::proto::{ - GatewayMessage, RelayFrame, RelayInit, SupervisorMessage, TcpForwardFrame, + GatewayMessage, PeerRelayFrame, RelayFrame, RelayInit, SupervisorMessage, TcpForwardFrame, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -48,6 +48,27 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn peer_report_provider_readiness( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_report_endpoint_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + + async fn peer_get_sandbox_provider_status( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not used by this test server")) + } + async fn report_endpoint_status( &self, _request: tonic::Request, @@ -126,6 +147,16 @@ impl OpenShell for RelayGateway { Err(Status::unimplemented("unused")) } + type PeerRelayStream = + std::pin::Pin> + Send>>; + + async fn peer_relay( + &self, + _: tonic::Request>, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + type ConnectSupervisorStream = ReceiverStream>; async fn connect_supervisor( &self, diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index f87afafc3..cf2e28de0 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -193,6 +193,12 @@ impl InProcessMiddleware for BuiltinMiddlewareService { service_version: env!("CARGO_PKG_VERSION").into(), bindings: regex::describe(), expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/regex", + openshell_core::VERSION, + [], + )), } } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index aff94023e..b621f8ad5 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -30,12 +30,15 @@ use std::time::Duration; use miette::{Result, miette}; use prost::Message; +use openshell_core::extension_protocol::{ + ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate, +}; use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; use openshell_core::proto::{ Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, - MiddlewareBinding, MiddlewareManifest, NetworkMiddlewareConfig, RequestContext, SandboxPolicy, - SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, SupervisorMiddlewareService, - ValidateConfigRequest, ValidateConfigResponse, + MiddlewareBinding, MiddlewareDescribeRequest, MiddlewareManifest, NetworkMiddlewareConfig, + RequestContext, SandboxPolicy, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, + SupervisorMiddlewareService, ValidateConfigRequest, ValidateConfigResponse, }; use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; @@ -55,7 +58,7 @@ struct GeneratedMiddlewareEndpoint { impl SupervisorMiddlewareEndpoint for GeneratedMiddlewareEndpoint { async fn describe( &self, - request: Request<()>, + request: Request, ) -> std::result::Result, TonicStatus> { self.service.describe(request).await } @@ -89,7 +92,9 @@ impl SupervisorMiddlewareEndpoint for GeneratedMiddlewareEndpoint { impl InProcessMiddleware for GeneratedMiddlewareEndpoint { async fn describe(&self) -> MiddlewareManifest { self.service - .describe(Request::new(())) + .describe(Request::new(MiddlewareDescribeRequest { + gateway: Some(gateway_metadata(ExtensionFamily::SupervisorMiddleware)), + })) .await .expect("generated in-process Describe failed") .into_inner() @@ -145,7 +150,9 @@ struct EndpointInProcessAdapter { impl InProcessMiddleware for EndpointInProcessAdapter { async fn describe(&self) -> MiddlewareManifest { self.endpoint - .describe(Request::new(())) + .describe(Request::new(MiddlewareDescribeRequest { + gateway: Some(gateway_metadata(ExtensionFamily::SupervisorMiddleware)), + })) .await .expect("in-process endpoint Describe failed") .into_inner() @@ -723,6 +730,7 @@ pub struct MiddlewareRegistry { services: Arc>>, registered_services: Arc>, middleware_names: Arc>, + negotiated_extensions: Arc>, work_admission: Arc, work_admission_waiters: Arc, session_admission: Arc, @@ -758,6 +766,7 @@ impl Default for MiddlewareRegistry { services: Arc::new(Vec::new()), registered_services: Arc::new(Vec::new()), middleware_names: Arc::new(HashSet::new()), + negotiated_extensions: Arc::new(Vec::new()), work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), @@ -1121,6 +1130,8 @@ impl MiddlewareRegistry { let mut services = Vec::with_capacity(in_process_services.len() + registrations.len()); let mut registered_services = Vec::with_capacity(registrations.len()); let mut middleware_names = HashSet::new(); + let mut negotiated_extensions = Vec::new(); + let gateway = gateway_metadata(ExtensionFamily::SupervisorMiddleware); for service in in_process_services { let service = MiddlewareDispatch::InProcess(service); @@ -1151,6 +1162,15 @@ impl MiddlewareRegistry { )); } validate_manifest_bindings(&source, &manifest, None)?; + negotiated_extensions.push( + negotiate( + ExtensionFamily::SupervisorMiddleware, + &manifest.name, + &gateway, + manifest.extension.clone(), + ) + .map_err(|error| miette!(error.to_string()))?, + ); let attachment_name = manifest.name.clone(); let manifest_cell = OnceCell::new(); manifest_cell @@ -1223,6 +1243,15 @@ impl MiddlewareRegistry { operator_max_payload_bytes, authenticated, )?; + negotiated_extensions.push( + negotiate( + ExtensionFamily::SupervisorMiddleware, + ®istration.name, + &gateway, + manifest.extension.clone(), + ) + .map_err(|error| miette!(error.to_string()))?, + ); let manifest_cell = OnceCell::new(); manifest_cell .set(manifest) @@ -1242,6 +1271,7 @@ impl MiddlewareRegistry { services: Arc::new(services), registered_services: Arc::new(registered_services), middleware_names: Arc::new(middleware_names), + negotiated_extensions: Arc::new(negotiated_extensions), work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), @@ -1304,6 +1334,11 @@ impl MiddlewareRegistry { .map(|service| service.registration.clone()) .collect() } + + #[must_use] + pub fn negotiated_extensions(&self) -> &[NegotiatedExtension] { + &self.negotiated_extensions + } } impl Default for ChainRunner { @@ -1338,6 +1373,7 @@ impl ChainRunner { })]), registered_services: Arc::new(Vec::new()), middleware_names: Arc::new(HashSet::new()), + negotiated_extensions: Arc::new(Vec::new()), work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), @@ -2181,6 +2217,7 @@ mod tests { struct BorrowedRecordingService { manifest_name: String, received: std::sync::Mutex>, + advertise_protocol: bool, } #[tonic::async_trait] @@ -2196,6 +2233,14 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: self.advertise_protocol.then(|| { + openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + ) + }), } } @@ -2240,6 +2285,7 @@ mod tests { let service = Arc::new(BorrowedRecordingService { manifest_name: "acme/redactor".into(), received: std::sync::Mutex::new(Vec::new()), + advertise_protocol: true, }); let runner = ChainRunner::new(service.clone()); let entries = [ @@ -2332,6 +2378,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } @@ -2423,6 +2475,12 @@ mod tests { request_timeout: Some(proto_duration("10ms")), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } @@ -2632,10 +2690,12 @@ mod tests { let first: Arc = Arc::new(BorrowedRecordingService { manifest_name: "openshell/test".into(), received: std::sync::Mutex::new(Vec::new()), + advertise_protocol: true, }); let second: Arc = Arc::new(BorrowedRecordingService { manifest_name: "openshell/test".into(), received: std::sync::Mutex::new(Vec::new()), + advertise_protocol: true, }); let error = MiddlewareRegistry::connect_services(vec![first, second], Vec::new()) @@ -2648,6 +2708,25 @@ mod tests { ); } + #[tokio::test] + async fn in_process_service_without_protocol_metadata_is_rejected() { + let service: Arc = Arc::new(BorrowedRecordingService { + manifest_name: "openshell/legacy".into(), + received: std::sync::Mutex::new(Vec::new()), + advertise_protocol: false, + }); + + let error = MiddlewareRegistry::connect_services(vec![service], Vec::new()) + .await + .expect_err("legacy middleware must fail negotiation"); + + assert!( + error + .to_string() + .contains("did not provide protocol metadata") + ); + } + /// A mock middleware that returns a fixed, caller-supplied result for every /// evaluation. Used to exercise chain behavior the built-in cannot produce /// (explicit deny, metadata, findings, unsafe header mutations). @@ -2671,7 +2750,7 @@ mod tests { async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, tonic::Status> { Ok(tonic::Response::new(MiddlewareManifest { name: self.manifest_name.clone(), @@ -2683,6 +2762,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } @@ -2726,7 +2811,7 @@ mod tests { async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, tonic::Status> { Ok(tonic::Response::new(MiddlewareManifest { name: "test/slow".into(), @@ -2738,6 +2823,12 @@ mod tests { request_timeout: self.binding_timeout, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } @@ -2785,7 +2876,7 @@ mod tests { async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, tonic::Status> { Ok(tonic::Response::new(MiddlewareManifest { name: "test/two-stage".into(), @@ -2797,6 +2888,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } @@ -3055,7 +3152,7 @@ mod tests { async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, tonic::Status> { Ok(tonic::Response::new(MiddlewareManifest { name: "test/recorder".into(), @@ -3067,6 +3164,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } @@ -3123,6 +3226,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } @@ -3170,7 +3279,7 @@ mod tests { async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, tonic::Status> { Ok(tonic::Response::new(MiddlewareManifest { name: "test/header-chain".into(), @@ -3182,6 +3291,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } @@ -3450,7 +3565,9 @@ mod tests { .expect("built-in manifest cache"); let manifest = service - .describe(Request::new(())) + .describe(Request::new(MiddlewareDescribeRequest { + gateway: Some(gateway_metadata(ExtensionFamily::SupervisorMiddleware)), + })) .await .expect("describe test service") .into_inner(); @@ -3484,6 +3601,7 @@ mod tests { ]), registered_services: Arc::new(vec![RegisteredMiddlewareService { registration }]), middleware_names: Arc::new(HashSet::from([builtin_name, registration_name])), + negotiated_extensions: Arc::new(Vec::new()), work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), @@ -3679,6 +3797,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; let error = validate_external_manifest(®istration, &manifest, 4097, false) .expect_err("operator limit must fit capability"); @@ -3706,6 +3830,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("extreme advertised payload limit must be rejected"); @@ -3726,6 +3856,12 @@ mod tests { service_version: "test".into(), bindings: vec![binding(), binding()], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; let error = validate_external_manifest(®istration, &manifest, 4096, false) @@ -3753,6 +3889,12 @@ mod tests { }), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; validate_external_manifest(®istration, &manifest, 4096, false) @@ -3772,6 +3914,12 @@ mod tests { service_version: "test".into(), bindings: vec![binding(SupervisorMiddlewarePhase::PreCredentials)], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; validate_manifest_bindings("test WebSocket service", &manifest, None) .expect("forward WebSocket binding is supported"); @@ -3795,6 +3943,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; let error = validate_external_manifest(®istration, &manifest, 0, false) @@ -3819,6 +3973,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; let error = validate_external_manifest(®istration, &manifest, 4097, false) @@ -3893,6 +4053,12 @@ mod tests { request_timeout: Some(proto_duration(timeout)), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }; let error = validate_external_manifest(®istration, &manifest, 4096, false) .expect_err("out-of-bounds binding timeout must be rejected"); @@ -4950,7 +5116,7 @@ mod tests { async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, tonic::Status> { self.describe_calls .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -4971,6 +5137,12 @@ mod tests { }), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } @@ -5011,7 +5183,7 @@ mod tests { impl SupervisorMiddlewareEndpoint for OpenAiRedactionService { async fn describe( &self, - request: Request<()>, + request: Request, ) -> std::result::Result, tonic::Status> { SupervisorMiddleware::describe(self, request).await } diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 80049b69d..78f27b8cf 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -9,8 +9,8 @@ use openshell_core::middleware::{ use openshell_core::proto::middleware::v1::http_response_pre_return_client::HttpResponsePreReturnClient; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, HttpResponseEvent, MiddlewareManifest, - ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, + HttpRequestEvaluation, HttpRequestResult, HttpResponseEvent, MiddlewareDescribeRequest, + MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, }; use openshell_extension_core::{ BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, @@ -61,7 +61,13 @@ impl GrpcMiddlewareService { /// Forward a manifest request through the protobuf service contract. pub async fn describe(&self) -> std::result::Result, Status> { - self.service.describe(Request::new(())).await + self.service + .describe(Request::new(MiddlewareDescribeRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + )), + })) + .await } /// Materialize the owned configuration request required by gRPC. @@ -158,7 +164,7 @@ impl RemoteMiddlewareService { impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { async fn describe( &self, - request: Request<()>, + request: Request, ) -> std::result::Result, Status> { let mut client = self.client.clone(); client.describe(request).await diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 073bebd88..6fe6d6c42 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -1247,7 +1247,7 @@ mod tests { async fn describe( &self, - _request: tonic::Request<()>, + _request: tonic::Request, ) -> Result, tonic::Status> { Ok(tonic::Response::new(response_manifest( "test/remote-response", @@ -1377,6 +1377,12 @@ mod tests { }), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-response-middleware", + "test", + [], + )), } } @@ -1717,6 +1723,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + name, + "test", + [], + )), } } diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 039310d37..39d4f11cf 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -308,9 +308,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { let tls = match tls_value.as_str() { "skip" => TlsMode::Skip, "terminate" => { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::Medium) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Other, "deprecated") .message( "'tls: terminate' is deprecated; TLS termination is now automatic. \ Use 'tls: skip' to explicitly disable. This field will be removed in a future version.", @@ -320,9 +321,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { TlsMode::Auto } "passthrough" => { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::Medium) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Other, "deprecated") .message( "'tls: passthrough' is deprecated; TLS termination is now automatic. \ Use 'tls: skip' to explicitly disable. This field will be removed in a future version.", @@ -374,9 +376,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { Some("sigv4:body") => CredentialSigning::SigV4Body, Some("sigv4:no_body") => CredentialSigning::SigV4NoBody, Some(other) if !other.is_empty() => { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::High) + .status(openshell_ocsf::StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") .message(format!( "rejecting endpoint: unrecognized credential_signing value {other:?}" )) @@ -391,9 +394,10 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { let signing_region = get_object_str(val, "signing_region").unwrap_or_default(); if credential_signing.is_sigv4() && signing_service.is_empty() { - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(openshell_ocsf::SeverityId::High) + .status(openshell_ocsf::StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") .message("rejecting endpoint: credential_signing requires signing_service".to_string()) .build(); openshell_ocsf::ocsf_emit!(event); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 380d96fcf..132b7674d 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -4210,7 +4210,7 @@ network_policies: async fn describe( &self, - _request: tonic::Request<()>, + _request: tonic::Request, ) -> std::result::Result< tonic::Response, tonic::Status, @@ -4233,6 +4233,12 @@ network_policies: }), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }, )) } @@ -6046,6 +6052,12 @@ network_policies: request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } @@ -6193,6 +6205,12 @@ network_policies: request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } @@ -6664,6 +6682,12 @@ network_policies: request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 4c65c983e..f532b6ee6 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -3609,6 +3609,12 @@ mod tests { request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-response-relay", + "test", + [], + )), } } diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 77ed82508..f2ea281a8 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3550,7 +3550,7 @@ network_policies: async fn describe( &self, - _request: Request<()>, + _request: Request, ) -> std::result::Result, Status> { use openshell_core::proto::{ @@ -3571,6 +3571,12 @@ network_policies: }), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), })) } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 02206063a..d63035475 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -35,8 +35,9 @@ use openshell_isolation_interface::contract::{ NetworkMediationSource, PendingTcpOpen, ResolveError, TcpOpenDecision, TcpOpenDenial, }; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, BaseEventBuilder, DispositionId, Endpoint, HttpActivityBuilder, + HttpRequest, HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, + Url as OcsfUrl, ocsf_emit, }; #[cfg(target_os = "linux")] use std::mem::size_of; @@ -114,6 +115,54 @@ const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint"; +fn build_connection_error_event( + peer_addr: SocketAddr, + message: String, +) -> openshell_ocsf::OcsfEvent { + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) + .message(message) + .build() +} + +fn build_proxy_connection_error_event( + peer_addr: Option, + transparent_destination: Option, + message: String, +) -> openshell_ocsf::OcsfEvent { + if let Some(peer_addr) = peer_addr { + return build_connection_error_event(peer_addr, message); + } + if let Some(destination) = transparent_destination { + return NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_ip(destination.ip(), destination.port())) + .message(message) + .build(); + } + + BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) + .activity_name("Proxy connection failure") + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(message) + .build() +} + +fn build_mediation_lane_failure_event(message: String) -> openshell_ocsf::OcsfEvent { + BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) + .activity_name("Network mediation source failure") + .severity(SeverityId::High) + .status(StatusId::Failure) + .message(message) + .build() +} + fn build_credential_endpoint_mismatch_event( method: &str, host: &str, @@ -426,6 +475,10 @@ impl ProxyHandle { }; match accepted { Ok((stream, supplied_identity, socket_addrs, transparent_destination)) => { + let peer_addr = socket_addrs.map(|(workload_addr, _)| workload_addr); + let transparent_destination_addr = transparent_destination + .as_ref() + .map(|transparent| transparent.destination); consecutive_resource_errors = 0; consecutive_unknown_errors = 0; let opa = opa_engine.clone(); @@ -476,57 +529,30 @@ impl ProxyHandle { ) .await { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("Proxy connection error: {err}")) - .build(); - ocsf_emit!(event); + ocsf_emit!(build_proxy_connection_error_event( + peer_addr, + transparent_destination_addr, + format!("Proxy connection error: {err}"), + )); } }); } Err(ProxyAcceptError::Source(err)) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::High) - .status(StatusId::Failure) - .message(format!( - "Network-mediation source failed; proxy accept loop exiting: {err}" - )) - .build(); - ocsf_emit!(event); + ocsf_emit!(build_mediation_lane_failure_event(format!( + "Network-mediation source failed; proxy accept loop exiting: {err}" + ))); break; } Err(ProxyAcceptError::Listener(err)) => { - match classify_accept_error( + let action = classify_accept_error( &err, &mut consecutive_resource_errors, &mut consecutive_unknown_errors, - ) { - AcceptAction::Terminal => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::High) - .status(StatusId::Failure) - .message(format!( - "Proxy accept loop exiting on terminal error: {err}", - )) - .build(); - ocsf_emit!(event); - break; - } - AcceptAction::Retry { backoff, severity } => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(severity) - .status(StatusId::Failure) - .message(format!( - "Proxy accept error (retrying in {}ms): {err}", - backoff.as_millis(), - )) - .build(); - ocsf_emit!(event); + ); + ocsf_emit!(build_accept_error_event(local_addr, &err, &action)); + match action { + AcceptAction::Terminal => break, + AcceptAction::Retry { backoff, .. } => { tokio::time::sleep(backoff).await; } } @@ -800,7 +826,7 @@ impl TransparentTcpHandle { ); } loop { - let Ok((stream, _)) = listener.accept().await else { + let Ok((stream, peer_addr)) = listener.accept().await else { break; }; set_tcp_nodelay_best_effort(&stream); @@ -826,14 +852,10 @@ impl TransparentTcpHandle { ) .await { - ocsf_emit!( - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("Transparent TCP connection error: {error}")) - .build() - ); + ocsf_emit!(build_connection_error_event( + peer_addr, + format!("Transparent TCP connection error: {error}") + )); } }); } @@ -1274,6 +1296,33 @@ enum AcceptAction { }, } +fn build_accept_error_event( + local_addr: SocketAddr, + err: &std::io::Error, + action: &AcceptAction, +) -> openshell_ocsf::OcsfEvent { + let (severity, message) = match action { + AcceptAction::Terminal => ( + SeverityId::High, + format!("Proxy accept loop exiting on terminal error: {err}"), + ), + AcceptAction::Retry { backoff, severity } => ( + *severity, + format!( + "Proxy accept error (retrying in {}ms): {err}", + backoff.as_millis() + ), + ), + }; + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .dst_endpoint(Endpoint::from_ip(local_addr.ip(), local_addr.port())) + .severity(severity) + .status(StatusId::Failure) + .message(message) + .build() +} + fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, @@ -1693,16 +1742,29 @@ fn build_forward_allow_ocsf_event( .build() } -fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) +fn build_forward_parse_error_ocsf_event( + peer_addr: Option, + method: &str, + path: &str, +) -> openshell_ocsf::OcsfEvent { + let builder = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::for_http_method(method)) + .http_request(HttpRequest { + http_method: method.parse().expect("HTTP method parsing is infallible"), + url: None, + }) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), }) .severity(SeverityId::Low) .status(StatusId::Failure) - .message(format!("FORWARD parse error for {path}")) - .build() + .message(format!("FORWARD parse error for {path}")); + match peer_addr { + Some(peer_addr) => builder + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .build(), + None => builder.build(), + } } /// Build the rejection event for an absolute-form request whose scheme is not @@ -4181,9 +4243,12 @@ fn parse_allowed_ips(raw: &[String]) -> std::result::Result, S } if n.prefix_len() < MIN_SAFE_PREFIX_LEN { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) + let event = openshell_ocsf::ConfigStateChangeBuilder::new( + openshell_ocsf::ctx::ctx(), + ) .severity(SeverityId::Medium) + .status(StatusId::Success) + .state(openshell_ocsf::StateId::Other, "warning") .message(format!( "allowed_ips entry has a very broad CIDR {n} (/{}) < /{MIN_SAFE_PREFIX_LEN}; \ this may expose control-plane services on the same network", @@ -4848,12 +4913,21 @@ async fn handle_forward_proxy( .as_ref() .and_then(EndpointObservationSender::capture); let mut endpoint_observer = None; + let workload_peer_addr = socket_addrs.map(|(workload, _)| workload); + // The connection handlers below require a workload address for policy and + // accounting. OCSF events must instead use `workload_peer_addr`, because + // the unspecified fallback is not an observed network endpoint. + let workload_addr = workload_peer_addr.unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))); let mut telemetry_path = forward_telemetry_path(target_uri); // 1. Parse the absolute-form URI. Every external forward target is // canonicalized below before credential binding, policy-path evaluation, // upstream bytes, or telemetry consume it. let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { - ocsf_emit!(build_forward_parse_error_ocsf_event(&telemetry_path)); + ocsf_emit!(build_forward_parse_error_ocsf_event( + workload_peer_addr, + method, + &telemetry_path + )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; @@ -4923,10 +4997,6 @@ async fn handle_forward_proxy( canonicalize_forward_host_header(&buf[..used], &canonical_authority)?; // 2. Evaluate OPA policy (same identity binding as CONNECT) - let workload_addr = socket_addrs.map_or_else( - || SocketAddr::from(([0, 0, 0, 0], 0)), - |(workload, _)| workload, - ); let intent = EgressIntent::forward_http(host_lc.clone(), port); let mut decision = if let Some(identity) = supplied_identity { authorize_supplied_identity(&opa_engine, intent, identity) @@ -6668,7 +6738,7 @@ process: impl openshell_core::middleware::SupervisorMiddlewareEndpoint for DenyWebSocketPreflight { async fn describe( &self, - _request: tonic::Request<()>, + _request: tonic::Request, ) -> std::result::Result< tonic::Response, tonic::Status, @@ -6690,6 +6760,12 @@ process: }), }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), }, )) } @@ -6780,6 +6856,12 @@ process: request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-forward-response", + "test", + [], + )), } } @@ -6886,6 +6968,12 @@ process: request_timeout: None, }], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell/test-middleware", + "test", + [], + )), } } @@ -7543,6 +7631,87 @@ network_policies: ); } + #[test] + fn accept_errors_include_the_listening_endpoint() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let addr = "127.0.0.1:3128".parse().unwrap(); + let error = std::io::Error::other("accept failed"); + for action in [ + AcceptAction::Terminal, + AcceptAction::Retry { + backoff: std::time::Duration::from_millis(250), + severity: SeverityId::Low, + }, + ] { + let event = build_accept_error_event(addr, &error, &action); + let json = event.to_json().unwrap(); + validate_required_fields(&json, &load_class_schema("network_activity")); + assert_eq!(json["dst_endpoint"]["ip"], "127.0.0.1"); + assert_eq!(json["dst_endpoint"]["port"], 3128); + assert!(json["message"].as_str().unwrap().contains("accept failed")); + } + } + + #[test] + fn connection_errors_include_the_known_peer() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let peer: SocketAddr = "127.0.0.1:54321".parse().unwrap(); + let schema = load_class_schema("network_activity"); + let json = build_connection_error_event(peer, "Proxy connection error".to_string()) + .to_json() + .unwrap(); + assert_eq!(json["class_uid"], 4001); + assert_eq!(json["src_endpoint"]["ip"], "127.0.0.1"); + assert_eq!(json["src_endpoint"]["port"], 54321); + validate_required_fields(&json, &schema); + } + + #[test] + fn forward_parse_errors_include_http_context_and_peer() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let peer: SocketAddr = "127.0.0.1:54321".parse().unwrap(); + let json = + build_forward_parse_error_ocsf_event(Some(peer), "GET", "/[INVALID_REQUEST_TARGET]") + .to_json() + .unwrap(); + + assert_eq!(json["class_uid"], 4002); + assert_eq!(json["activity_name"], "Get"); + assert_eq!(json["http_request"]["http_method"], "GET"); + assert!(json["http_request"].get("url").is_none()); + assert_eq!(json["http_response"]["code"], 400); + assert_eq!(json["src_endpoint"]["ip"], "127.0.0.1"); + assert_eq!(json["src_endpoint"]["port"], 54321); + validate_required_fields(&json, &load_class_schema("http_activity")); + } + + #[test] + fn forward_parse_errors_without_a_peer_do_not_fabricate_an_endpoint() { + let json = build_forward_parse_error_ocsf_event(None, "GET", "/[INVALID_REQUEST_TARGET]") + .to_json() + .unwrap(); + + assert_eq!(json["class_uid"], 4002); + assert!(json.get("src_endpoint").is_none()); + assert!(json.get("dst_endpoint").is_none()); + } + + #[test] + fn endpointless_proxy_failures_are_base_events() { + for event in [ + build_proxy_connection_error_event(None, None, "connection failed".to_string()), + build_mediation_lane_failure_event("source failed".to_string()), + ] { + let json = event.to_json().unwrap(); + assert_eq!(json["class_uid"], 0); + assert_ne!(json["activity_name"], "Stop"); + assert_eq!(json["status"], "Failure"); + } + } + #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy"); @@ -7883,9 +8052,13 @@ network_policies: assert!(!serialized.contains("real-secret"), "{serialized}"); assert!(!serialized.contains("?token="), "{serialized}"); - let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( - "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", - )) + let malformed = build_forward_parse_error_ocsf_event( + Some("127.0.0.1:12345".parse().unwrap()), + "GET", + &forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ), + ) .to_json() .unwrap(); assert_eq!( diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 533ee2b2e..1eba8a074 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -172,9 +172,7 @@ pub struct Networking { /// the workload child (entered via `setns()` in `pre_exec`). /// /// `denial_tx` and `denial_rx` are owned by the caller. The proxy uses the -/// sender; the aggregator owns the receiver. The caller is also responsible -/// for cloning `denial_tx` for the bypass monitor (which lives in -/// `openshell-supervisor-process`). +/// sender; the aggregator owns the receiver. /// /// # Errors /// diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 1ddfd734e..0b713f992 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -23,8 +23,8 @@ use openshell_core::proto::{ }; use openshell_isolation_interface::contract::{BoundaryLoopbackConnector, LoopbackTarget}; use openshell_ocsf::{ - ActivityId, ConnectionInfo, Endpoint, EventContext, NetworkActivityBuilder, OcsfEvent, - SeverityId, StatusId, ocsf_emit, + ActivityId, BaseEventBuilder, ConnectionInfo, Endpoint, EventContext, NetworkActivityBuilder, + OcsfEvent, SeverityId, StatusId, ocsf_emit, }; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::{mpsc, watch}; @@ -149,17 +149,23 @@ fn relay_open_event( open: &RelayOpen, ssh_socket_path: &std::path::Path, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + let message = relay_target_message(open, "open", ssh_socket_path); + let Some(endpoint) = relay_target_endpoint(open) else { + return BaseEventBuilder::new(ctx) + .activity_name("Relay open") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message) + .build(); + }; + NetworkActivityBuilder::new(ctx) .activity(ActivityId::Open) .severity(SeverityId::Informational) .status(StatusId::Success) - .message(relay_target_message(open, "open", ssh_socket_path)); - if let Some(endpoint) = relay_target_endpoint(open) { - builder = builder - .dst_endpoint(endpoint) - .connection_info(ConnectionInfo::new("tcp")); - } - builder.build() + .message(message) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_closed_event( @@ -167,17 +173,23 @@ fn relay_closed_event( open: &RelayOpen, ssh_socket_path: &std::path::Path, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + let message = relay_target_message(open, "closed", ssh_socket_path); + let Some(endpoint) = relay_target_endpoint(open) else { + return BaseEventBuilder::new(ctx) + .activity_name("Relay closed") + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message) + .build(); + }; + NetworkActivityBuilder::new(ctx) .activity(ActivityId::Close) .severity(SeverityId::Informational) .status(StatusId::Success) - .message(relay_target_message(open, "closed", ssh_socket_path)); - if let Some(endpoint) = relay_target_endpoint(open) { - builder = builder - .dst_endpoint(endpoint) - .connection_info(ConnectionInfo::new("tcp")); - } - builder.build() + .message(message) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_failed_event( @@ -186,25 +198,31 @@ fn relay_failed_event( ssh_socket_path: &std::path::Path, error: &str, ) -> OcsfEvent { - let mut builder = NetworkActivityBuilder::new(ctx) + let message = format!( + "{}: {error}", + relay_target_message(open, "bridge failed", ssh_socket_path) + ); + let Some(endpoint) = relay_target_endpoint(open) else { + return BaseEventBuilder::new(ctx) + .activity_name("Relay failed") + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(message) + .build(); + }; + NetworkActivityBuilder::new(ctx) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) - .message(format!( - "{}: {error}", - relay_target_message(open, "bridge failed", ssh_socket_path) - )); - if let Some(endpoint) = relay_target_endpoint(open) { - builder = builder - .dst_endpoint(endpoint) - .connection_info(ConnectionInfo::new("tcp")); - } - builder.build() + .message(message) + .dst_endpoint(endpoint) + .connection_info(ConnectionInfo::new("tcp")) + .build() } fn relay_close_from_gateway_event(ctx: &EventContext, channel_id: &str, reason: &str) -> OcsfEvent { - NetworkActivityBuilder::new(ctx) - .activity(ActivityId::Close) + BaseEventBuilder::new(ctx) + .activity_name("Relay close from gateway") .severity(SeverityId::Informational) .message(format!( "relay close from gateway (channel_id={channel_id}, reason={reason})" @@ -342,7 +360,7 @@ async fn run_session_loop(config: SessionConfig) { loop { attempt += 1; - let result = run_single_session(&config).await; + let result = run_single_session(&config, attempt).await; if let Some(updates) = &config.session_id_updates { updates.send_replace(None); } @@ -375,6 +393,7 @@ async fn run_session_loop(config: SessionConfig) { async fn run_single_session( config: &SessionConfig, + connection_epoch: u64, ) -> Result<(), Box> { // Connect to the gateway. The same `Channel` is used for both the // long-lived control stream and all data-plane `RelayStream` calls, so @@ -394,6 +413,7 @@ async fn run_single_session( payload: Some(supervisor_message::Payload::Hello(SupervisorHello { sandbox_id: config.sandbox_id.clone(), instance_id: config.instance_id.clone(), + connection_epoch, supports_provider_readiness: true, })), }) @@ -950,6 +970,13 @@ mod ocsf_event_tests { } } + fn base_event(event: &OcsfEvent) -> &openshell_ocsf::BaseEvent { + match event { + OcsfEvent::Base(event) => event, + other => panic!("expected Base Event, got {other:?}"), + } + } + fn ssh_relay_open(channel_id: &str) -> RelayOpen { RelayOpen { channel_id: channel_id.to_string(), @@ -1013,12 +1040,13 @@ mod ocsf_event_tests { } #[test] - fn relay_open_emits_network_open_success() { + fn relay_open_emits_base_event() { let event = relay_open_event(&ctx(), &ssh_relay_open("ch-42"), ssh_socket_path()); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Open.as_u8()); - assert_eq!(na.base.severity, SeverityId::Informational); - let msg = na.base.message.as_deref().unwrap_or_default(); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay open"); + assert_eq!(event.base.severity, SeverityId::Informational); + assert_eq!(event.base.status, Some(StatusId::Success)); + let msg = event.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("ch-42"), "message: {msg}"); assert!( msg.contains("target=unix:/run/openshell/ssh.sock"), @@ -1049,37 +1077,37 @@ mod ocsf_event_tests { } #[test] - fn relay_closed_emits_network_close_success() { + fn relay_closed_emits_base_event() { let event = relay_closed_event(&ctx(), &ssh_relay_open("ch-42"), ssh_socket_path()); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Close.as_u8()); - assert_eq!(na.base.status, Some(StatusId::Success)); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay closed"); + assert_eq!(event.base.status, Some(StatusId::Success)); } #[test] - fn relay_failed_emits_network_fail_low() { + fn relay_failed_emits_base_event() { let event = relay_failed_event( &ctx(), &ssh_relay_open("ch-42"), ssh_socket_path(), "write to ssh failed", ); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Fail.as_u8()); - assert_eq!(na.base.severity, SeverityId::Low); - assert_eq!(na.base.status, Some(StatusId::Failure)); - let msg = na.base.message.as_deref().unwrap_or_default(); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay failed"); + assert_eq!(event.base.severity, SeverityId::Low); + assert_eq!(event.base.status, Some(StatusId::Failure)); + let msg = event.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("ch-42"), "message: {msg}"); assert!(msg.contains("write to ssh failed"), "message: {msg}"); } #[test] - fn relay_close_from_gateway_is_network_close_informational() { + fn relay_close_from_gateway_is_base_event() { let event = relay_close_from_gateway_event(&ctx(), "ch-42", "sandbox deleted"); - let na = network_activity(&event); - assert_eq!(na.base.activity_id, ActivityId::Close.as_u8()); - assert_eq!(na.base.severity, SeverityId::Informational); - let msg = na.base.message.as_deref().unwrap_or_default(); + let event = base_event(&event); + assert_eq!(event.base.activity_name, "Relay close from gateway"); + assert_eq!(event.base.severity, SeverityId::Informational); + let msg = event.base.message.as_deref().unwrap_or_default(); assert!(msg.contains("sandbox deleted"), "message: {msg}"); } diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index 515572b08..a24ed8c08 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -423,7 +423,7 @@ fn main() -> Result<()> { }; let admitted_isolation_backend = std::env::var(openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND).ok(); - openshell_supervisor::run_sandbox( + Box::pin(openshell_supervisor::run_sandbox( command, workdir, args.timeout, @@ -442,7 +442,7 @@ fn main() -> Result<()> { auth_bundle, admitted_isolation_backend, args.main_exit_marker, - ) + )) .await } SupervisorRole::NetworkProxy => { diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 1cb2b0223..ef361b8b1 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1457,6 +1457,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())), await_main_process_attachment: false, workload_template: String::new(), + service_exposures: Vec::new(), }; let sandbox_name = diff --git a/deny.toml b/deny.toml index 410a92e3b..62ce9a909 100644 --- a/deny.toml +++ b/deny.toml @@ -21,7 +21,6 @@ ignore = [ { id = "RUSTSEC-2026-0204", reason = "crossbeam-epoch pointer deref — transitive via metrics/quanta" }, { id = "RUSTSEC-2023-0071", reason = "rsa Marvin attack — transitive via spiffe, no direct exposure" }, { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile unmaintained — transitive via older kube/hyper" }, - { id = "RUSTSEC-2025-0068", reason = "serde_yml unsound+unmaintained — direct dep, no maintained alternative yet" }, ] # -- Licenses ------------------------------------------------------------------ diff --git a/deploy/deb/control.in b/deploy/deb/control.in index 27887c56c..e0975d4d4 100644 --- a/deploy/deb/control.in +++ b/deploy/deb/control.in @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - Package: openshell Version: @VERSION@ Architecture: @ARCH@ diff --git a/deploy/deb/control.in.license b/deploy/deb/control.in.license new file mode 100644 index 000000000..817df2442 --- /dev/null +++ b/deploy/deb/control.in.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 003475df9..f0a96b143 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -142,6 +142,28 @@ Append these flags to any of the PostgreSQL commands above for OpenShift: --set securityContext.runAsUser=null ``` +### High availability + +Set `replicaCount` above `1` only with `server.externalDbSecret`; the default +SQLite database is per pod and cannot coordinate multiple gateway replicas. +The chart creates a headless peer Service for gateway-to-gateway relay traffic. +StatefulSet pods use stable pod DNS names through that headless Service. +Deployment pods advertise their pod IP with `OPENSHELL_PEER_ENDPOINT`, because +Kubernetes does not assign stable per-pod DNS names to Deployment replicas. + +Gateway peer traffic uses Kubernetes ServiceAccount identity. Each gateway pod +mounts a projected, pod-bound ServiceAccount token with audience +`openshell-gateway-peer`; receiving replicas validate that token with the +Kubernetes TokenReview API, verify the live pod UID and Helm selector labels, +and authorize only the internal `PeerRelay` RPC. The chart does not create or +accept a shared gateway peer Secret. + +With gateway TLS enabled, peer calls use the chart CA and client TLS Secret for +server verification and mTLS. The client verifies the stable gateway Service +DNS name while connecting directly to the owning pod. Custom TLS Secrets must +include that Service DNS name in the server certificate and provide the CA and +client credentials configured by `server.tls`. + ## Secret bootstrap By default, a pre-install/pre-upgrade hook Job runs `openshell-gateway generate-certs` diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index 75e651e21..07af6e78b 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -142,6 +142,28 @@ Append these flags to any of the PostgreSQL commands above for OpenShift: --set securityContext.runAsUser=null ``` +### High availability + +Set `replicaCount` above `1` only with `server.externalDbSecret`; the default +SQLite database is per pod and cannot coordinate multiple gateway replicas. +The chart creates a headless peer Service for gateway-to-gateway relay traffic. +StatefulSet pods use stable pod DNS names through that headless Service. +Deployment pods advertise their pod IP with `OPENSHELL_PEER_ENDPOINT`, because +Kubernetes does not assign stable per-pod DNS names to Deployment replicas. + +Gateway peer traffic uses Kubernetes ServiceAccount identity. Each gateway pod +mounts a projected, pod-bound ServiceAccount token with audience +`openshell-gateway-peer`; receiving replicas validate that token with the +Kubernetes TokenReview API, verify the live pod UID and Helm selector labels, +and authorize only the internal `PeerRelay` RPC. The chart does not create or +accept a shared gateway peer Secret. + +With gateway TLS enabled, peer calls use the chart CA and client TLS Secret for +server verification and mTLS. The client verifies the stable gateway Service +DNS name while connecting directly to the owning pod. Custom TLS Secrets must +include that Service DNS name in the server certificate and provide the CA and +client credentials configured by `server.tls`. + ## Secret bootstrap By default, a pre-install/pre-upgrade hook Job runs `openshell-gateway generate-certs` diff --git a/deploy/helm/openshell/skaffold.yaml b/deploy/helm/openshell/skaffold.yaml index 63037b075..baa2e0f69 100644 --- a/deploy/helm/openshell/skaffold.yaml +++ b/deploy/helm/openshell/skaffold.yaml @@ -146,6 +146,25 @@ deploy: sandboxRuntime.image.repository: '{{.IMAGE_REPO_openshell_sandbox}}' sandboxRuntime.image.tag: '{{.IMAGE_TAG_openshell_sandbox}}' profiles: + # Full HA test path: installs Envoy Gateway and layers both HA replicas and + # Gateway API routing values onto the OpenShell release. + - name: high-availability + patches: + - op: add + path: /deploy/helm/releases/0 + value: + name: envoy-gateway + remoteChart: oci://docker.io/envoyproxy/gateway-helm + version: v1.7.2 + namespace: envoy-gateway-system + createNamespace: true + wait: true + - op: add + path: /deploy/helm/releases/1/valuesFiles/- + value: ci/values-high-availability.yaml + - op: add + path: /deploy/helm/releases/1/valuesFiles/- + value: ci/values-gateway.yaml - name: credential-driver-kubernetes-secrets patches: - op: add diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 485fb0241..e8d461623 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -52,6 +52,50 @@ spec: - {{ .Values.server.dbUrl | quote }} {{- end }} env: + - name: OPENSHELL_REPLICA_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: OPENSHELL_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: OPENSHELL_POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- if eq (include "openshell.workloadKind" .) "deployment" }} + - name: OPENSHELL_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: OPENSHELL_PEER_ENDPOINT + value: {{ printf "%s://$(OPENSHELL_POD_IP):%d" (ternary "http" "https" (default false .Values.server.disableTls)) (int .Values.service.port) | quote }} + {{- end }} + - name: OPENSHELL_SERVICE_ACCOUNT_NAME + value: {{ include "openshell.serviceAccountName" . | quote }} + - name: OPENSHELL_PEER_SERVICE_NAME + value: {{ include "openshell.peerServiceName" . | quote }} + - name: OPENSHELL_PEER_TOKEN_AUDIENCE + value: "openshell-gateway-peer" + - name: OPENSHELL_PEER_SERVICE_ACCOUNT_TOKEN_FILE + value: /var/run/secrets/openshell-peer/token + - name: OPENSHELL_PEER_POD_LABELS + value: {{ printf "app.kubernetes.io/name=%s,app.kubernetes.io/instance=%s" (include "openshell.name" .) .Release.Name | quote }} + {{- if not .Values.server.disableTls }} + - name: OPENSHELL_PEER_TLS_SERVER_NAME + value: {{ printf "%s.%s.svc.cluster.local" (include "openshell.fullname" .) .Release.Namespace | quote }} + {{- if or .Values.pkiInitJob.enabled .Values.certManager.enabled }} + - name: OPENSHELL_PEER_TLS_CA_FILE + value: /etc/openshell-tls/server/ca.crt + {{- end }} + {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} + - name: OPENSHELL_PEER_TLS_CERT_FILE + value: /etc/openshell-tls/peer-client/tls.crt + - name: OPENSHELL_PEER_TLS_KEY_FILE + value: /etc/openshell-tls/peer-client/tls.key + {{- end }} + {{- end }} {{- if not (or .Values.server.credentialDrivers.kubernetesSecrets.enabled .Values.server.credentialDrivers.vault.enabled) }} - name: {{ include "openshell.credentialStorageKeyEncryptionKeyEnvName" . }} valueFrom: @@ -99,6 +143,9 @@ spec: - name: sandbox-jwt mountPath: /etc/openshell-jwt readOnly: true + - name: gateway-peer-token + mountPath: /var/run/secrets/openshell-peer + readOnly: true {{- if not .Values.server.disableTls }} - name: tls-cert mountPath: /etc/openshell-tls/server @@ -109,6 +156,9 @@ spec: readOnly: true {{- end }} {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} + - name: peer-client-tls + mountPath: /etc/openshell-tls/peer-client + readOnly: true - name: tls-client-ca mountPath: /etc/openshell-tls/client-ca readOnly: true @@ -174,6 +224,14 @@ spec: secret: secretName: {{ include "openshell.sandboxJwtSecretName" . }} defaultMode: {{ .Values.server.sandboxJwt.secretDefaultMode | default 0400 }} + - name: gateway-peer-token + projected: + defaultMode: 0400 + sources: + - serviceAccountToken: + path: token + audience: openshell-gateway-peer + expirationSeconds: 3600 {{- if not .Values.server.disableTls }} - name: tls-cert secret: @@ -184,6 +242,9 @@ spec: secretName: {{ include "openshell.fullname" . }}-server-external-tls {{- end }} {{- if eq (include "openshell.gatewayClientCaEnabled" .) "true" }} + - name: peer-client-tls + secret: + secretName: {{ .Values.server.tls.clientTlsSecretName }} - name: tls-client-ca secret: {{- if or (and .Values.pkiInitJob.enabled (not .Values.certManager.enabled)) (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index dd2d103f1..03713b9c2 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -210,6 +210,10 @@ Name of the Secret holding gateway-minted sandbox JWT signing material. {{- .Values.server.sandboxJwt.signingSecretName | default (printf "%s-jwt-keys" (include "openshell.fullname" .)) -}} {{- end }} +{{- define "openshell.peerServiceName" -}} +{{- printf "%s-peer" (include "openshell.fullname" .) -}} +{{- end }} + {{/* gRPC endpoint sandbox pods use to call back into the gateway. An explicit .Values.server.grpcEndpoint is used verbatim. Otherwise it is derived from diff --git a/deploy/helm/openshell/templates/clusterrole.yaml b/deploy/helm/openshell/templates/clusterrole.yaml index e48c27cdd..9265e0a96 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 ServiceAccount tokens during sandbox bootstrap and + # internal gateway peer authentication. - apiGroups: - authentication.k8s.io resources: diff --git a/deploy/helm/openshell/templates/peer-role.yaml b/deploy/helm/openshell/templates/peer-role.yaml new file mode 100644 index 000000000..59ae7479a --- /dev/null +++ b/deploy/helm/openshell/templates/peer-role.yaml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "openshell.fullname" . }}-peer + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell.labels" . | nindent 4 }} +rules: + # Gateway peer identity: TokenReview authenticates the projected token, then + # the receiver resolves the returned pod name and UID to the live gateway pod + # in the release namespace. + - apiGroups: + - "" + resources: + - pods + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "openshell.fullname" . }}-peer + namespace: {{ .Release.Namespace }} + labels: + {{- include "openshell.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "openshell.fullname" . }}-peer +subjects: + - kind: ServiceAccount + name: {{ include "openshell.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/deploy/helm/openshell/templates/peer-service.yaml b/deploy/helm/openshell/templates/peer-service.yaml new file mode 100644 index 000000000..f5d93af24 --- /dev/null +++ b/deploy/helm/openshell/templates/peer-service.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: v1 +kind: Service +metadata: + name: {{ include "openshell.peerServiceName" . }} + labels: + {{- include "openshell.labels" . | nindent 4 }} +spec: + clusterIP: None + publishNotReadyAddresses: true + ports: + - port: {{ .Values.service.port }} + targetPort: grpc + protocol: TCP + name: grpc + appProtocol: grpc + selector: + {{- include "openshell.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 30571f80b..10d0839f6 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -9,7 +9,7 @@ metadata: labels: {{- include "openshell.labels" . | nindent 4 }} spec: - serviceName: {{ include "openshell.fullname" . }} + serviceName: {{ include "openshell.peerServiceName" . }} replicas: {{ .Values.replicaCount }} selector: matchLabels: diff --git a/deploy/helm/openshell/tests/credential_drivers_test.yaml b/deploy/helm/openshell/tests/credential_drivers_test.yaml index df6de3647..65829712d 100644 --- a/deploy/helm/openshell/tests/credential_drivers_test.yaml +++ b/deploy/helm/openshell/tests/credential_drivers_test.yaml @@ -46,15 +46,14 @@ tests: - it: injects the default credential storage key-encryption key Secret into the gateway pod by default template: templates/statefulset.yaml asserts: - - equal: - path: spec.template.spec.containers[0].env[0].name - value: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY - - matchRegex: - path: spec.template.spec.containers[0].env[0].valueFrom.secretKeyRef.name - pattern: 'credential-storage-key-encryption-key$' - - equal: - path: spec.template.spec.containers[0].env[0].valueFrom.secretKeyRef.key - value: key-encryption-key + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: openshell-credential-storage-key-encryption-key + key: key-encryption-key - it: renders Kubernetes Secrets credential driver config template: templates/gateway-config.yaml @@ -178,9 +177,14 @@ tests: - equal: path: kind value: Deployment - - equal: - path: spec.template.spec.containers[0].env[0].name - value: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: openshell-credential-storage-key-encryption-key + key: key-encryption-key - it: allows default credential storage with multiple replicas and an external database template: templates/statefulset.yaml @@ -192,6 +196,11 @@ tests: - equal: path: spec.replicas value: 2 - - equal: - path: spec.template.spec.containers[0].env[0].name - value: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_GATEWAY_CREDENTIAL_KEY_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: openshell-credential-storage-key-encryption-key + key: key-encryption-key diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index a9186d333..4675cd9b6 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -5,6 +5,8 @@ suite: gateway TOML config shape templates: - templates/gateway-config.yaml - templates/deployment.yaml + - templates/peer-role.yaml + - templates/peer-service.yaml - templates/statefulset.yaml release: name: openshell @@ -153,16 +155,16 @@ tests: server.oidc.caConfigMapName: openshell-oidc-ca asserts: - equal: - path: spec.template.spec.containers[0].volumeMounts[3].name + path: spec.template.spec.containers[0].volumeMounts[4].name value: oidc-ca - equal: - path: spec.template.spec.containers[0].volumeMounts[3].mountPath + path: spec.template.spec.containers[0].volumeMounts[4].mountPath value: /etc/openshell-tls/oidc-ca - equal: - path: spec.template.spec.volumes[2].name + path: spec.template.spec.volumes[3].name value: oidc-ca - equal: - path: spec.template.spec.volumes[2].configMap.name + path: spec.template.spec.volumes[3].configMap.name value: openshell-oidc-ca # Regression for the P1 bug Drew flagged: grpc_endpoint MUST live in the @@ -632,6 +634,128 @@ tests: - failedTemplate: errorMessage: "server.tls.clientCaSecretName cannot be null; omit the key to use the chart default (openshell-server-client-ca), or set to \"\" to disable client certificate verification for HTTPS-only mode" + - it: configures gateway peer identity and projected peer token + template: templates/statefulset.yaml + asserts: + - equal: + path: spec.serviceName + value: openshell-peer + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_SERVICE_NAME + value: openshell-peer + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_TOKEN_AUDIENCE + value: openshell-gateway-peer + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_TLS_SERVER_NAME + value: openshell.my-namespace.svc.cluster.local + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_TLS_CA_FILE + value: /etc/openshell-tls/server/ca.crt + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_TLS_CERT_FILE + value: /etc/openshell-tls/peer-client/tls.crt + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_TLS_KEY_FILE + value: /etc/openshell-tls/peer-client/tls.key + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: gateway-peer-token + mountPath: /var/run/secrets/openshell-peer + readOnly: true + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: peer-client-tls + mountPath: /etc/openshell-tls/peer-client + readOnly: true + - equal: + path: spec.template.spec.volumes[2].projected.sources[0].serviceAccountToken.audience + value: openshell-gateway-peer + + - it: configures gateway peer identity and projected peer token for Deployment + template: templates/deployment.yaml + set: + workload.kind: deployment + server.externalDbSecret: my-pg-secret + server.disableTls: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_ENDPOINT + value: http://$(OPENSHELL_POD_IP):8080 + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_SERVICE_NAME + value: openshell-peer + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_PEER_TOKEN_AUDIENCE + value: openshell-gateway-peer + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: gateway-peer-token + mountPath: /var/run/secrets/openshell-peer + readOnly: true + - equal: + path: spec.template.spec.volumes[2].projected.sources[0].serviceAccountToken.audience + value: openshell-gateway-peer + + - it: renders headless gateway peer service + template: templates/peer-service.yaml + asserts: + - equal: + path: metadata.name + value: openshell-peer + - equal: + path: spec.clusterIP + value: None + - equal: + path: spec.publishNotReadyAddresses + value: true + + - it: grants release-namespace pod lookup for gateway peer identity validation + template: templates/peer-role.yaml + asserts: + - hasDocuments: + count: 2 + - equal: + path: metadata.namespace + value: my-namespace + documentIndex: 0 + - equal: + path: rules[0].resources[0] + value: pods + documentIndex: 0 + - equal: + path: subjects[0].name + value: openshell + documentIndex: 1 + - it: fails when legacy postgres.enabled is set template: templates/statefulset.yaml set: diff --git a/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml b/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml index df6df1569..6b3e320a8 100644 --- a/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml +++ b/deploy/helm/openshell/tests/statefulset_client_ca_test.yaml @@ -17,13 +17,13 @@ tests: certManager.enabled: false asserts: - equal: - path: spec.template.spec.volumes[3].name + path: spec.template.spec.volumes[5].name value: tls-client-ca - equal: - path: spec.template.spec.volumes[3].secret.secretName + path: spec.template.spec.volumes[5].secret.secretName value: openshell-server-tls - equal: - path: spec.template.spec.volumes[3].secret.items[0].key + path: spec.template.spec.volumes[5].secret.items[0].key value: ca.crt - it: shares the cert-manager server TLS ca.crt when clientCaFromServerTlsSecret is true @@ -33,13 +33,13 @@ tests: certManager.clientCaFromServerTlsSecret: true asserts: - equal: - path: spec.template.spec.volumes[3].name + path: spec.template.spec.volumes[5].name value: tls-client-ca - equal: - path: spec.template.spec.volumes[3].secret.secretName + path: spec.template.spec.volumes[5].secret.secretName value: openshell-server-tls - equal: - path: spec.template.spec.volumes[3].secret.items[0].key + path: spec.template.spec.volumes[5].secret.items[0].key value: ca.crt # Regression: with cert-manager enabled and pkiInitJob left at its default @@ -56,13 +56,13 @@ tests: server.tls.clientCaSecretName: openshell-ca-tls asserts: - equal: - path: spec.template.spec.volumes[3].name + path: spec.template.spec.volumes[5].name value: tls-client-ca - equal: - path: spec.template.spec.volumes[3].secret.secretName + path: spec.template.spec.volumes[5].secret.secretName value: openshell-ca-tls - notExists: - path: spec.template.spec.volumes[3].secret.items + path: spec.template.spec.volumes[5].secret.items - it: omits client CA volume and mount when mTLS is disabled template: templates/statefulset.yaml @@ -96,7 +96,7 @@ tests: asserts: - lengthEqual: path: spec.template.spec.volumes - count: 3 + count: 4 - notContains: path: spec.template.spec.containers[0].volumeMounts content: @@ -115,7 +115,7 @@ tests: asserts: - lengthEqual: path: spec.template.spec.volumes - count: 3 + count: 4 - notContains: path: spec.template.spec.containers[0].volumeMounts content: @@ -132,7 +132,7 @@ tests: asserts: - lengthEqual: path: spec.template.spec.volumes - count: 3 + count: 4 - notContains: path: spec.template.spec.containers[0].volumeMounts content: diff --git a/deploy/kube/manifests/envoy-gateway-openshell.yaml b/deploy/kube/manifests/envoy-gateway-openshell.yaml index 583f2b41b..68d1ea7d7 100644 --- a/deploy/kube/manifests/envoy-gateway-openshell.yaml +++ b/deploy/kube/manifests/envoy-gateway-openshell.yaml @@ -15,3 +15,24 @@ metadata: name: eg spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller +--- +# OpenShell gRPC streams can remain active across sandbox create, exec, relay, +# and watch operations. Disable Envoy's backend request and stream duration +# timeouts for the OpenShell GRPCRoute so the proxy does not reset long-running +# HTTP/2 streams while gateway pods rotate behind it. +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: openshell-grpc-timeouts + namespace: openshell + labels: + app.kubernetes.io/name: openshell +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: GRPCRoute + name: openshell + timeout: + http: + requestTimeout: 0s + maxStreamDuration: 0s diff --git a/docs/extensibility/extension-negotiation.mdx b/docs/extensibility/extension-negotiation.mdx new file mode 100644 index 000000000..c8e3a1d79 --- /dev/null +++ b/docs/extensibility/extension-negotiation.mdx @@ -0,0 +1,69 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Extension Protocol Negotiation" +sidebar-title: "Protocol Negotiation" +description: "Implement version and capability negotiation for OpenShell extensions." +keywords: "OpenShell Extensions, Protocol Version, Capabilities, Version Skew, Migration" +--- + +OpenShell negotiates a common metadata envelope before it uses a compute driver, credential driver, gateway interceptor, or supervisor middleware service. Family-specific fields remain in each protocol; the common envelope determines whether the peers can safely interpret them. + +## Exchange Peer Metadata + +Both peers send `openshell.extension.v1.PeerMetadata` during the family's startup RPC. The gateway sends its metadata in `GetCapabilities` or `Describe`; the extension returns its metadata in the capability response or manifest. + +Set these fields: + +- `protocol_version` identifies the extension-family contract. OpenShell starts each family at `1.0`. +- `implementation_name` identifies the implementation, such as `example/acme-compute`. +- `implementation_version` identifies the extension build. Do not put the Docker, Kubernetes, Vault, or another backend's version here. +- `supported_capabilities` lists optional behavior the peer understands. +- `required_capabilities` lists behavior the opposite peer must support. + +Capability identifiers must start with `openshell.`, contain at least three lowercase dot-separated segments, and use only lowercase ASCII letters, digits, and hyphens within each segment. Each family requires its base contract capability: + +| Family | Base capability | +| --- | --- | +| Compute driver | `openshell.compute.contract` | +| Credential driver | `openshell.credentials.contract` | +| Gateway interceptor | `openshell.gateway-interceptor.contract` | +| Supervisor middleware | `openshell.supervisor-middleware.contract` | + +Do not repeat a capability. OpenShell rejects empty, malformed, duplicate, or oversized metadata instead of normalizing ambiguous input. + +## Version-Skew Policy + +OpenShell applies this compatibility matrix at startup: + +| Peer relationship | Result | +| --- | --- | +| Same major and same minor | Accepted when both requirement sets are satisfied. | +| Same major and different minor | Accepted when both requirement sets are satisfied. Unknown optional capabilities are retained for discovery and otherwise ignored. | +| Different major | Rejected with both protocol versions in the error. | +| Missing peer metadata or protocol version | Rejected with upgrade guidance. | +| Either peer lacks a capability required by the other | Rejected with the missing capability names. | + +Minor releases must keep existing fields and behavior compatible. Add a capability when a peer must detect an optional behavior. Increment the protocol major when requirements cannot express a safe additive transition. + +## Preserve Family-Specific Capabilities + +Keep typed family data beside the common envelope. Compute resource capabilities, credential-driver feature flags, interceptor bindings, and middleware operation/phase bindings remain authoritative for their domains. Do not flatten typed values into capability strings. + +Built-in and external extensions follow the same validator. A built-in cannot bypass protocol version or requirement checks merely because it runs in the gateway process. + +## Migrate an Extension + +1. Regenerate bindings from the current OpenShell protobuf files. Supervisor middleware authors must update `Describe` from `google.protobuf.Empty` to `MiddlewareDescribeRequest`. +2. Read and validate the gateway metadata supplied in the startup request. +3. Return protocol `1.0`, a stable implementation name, the extension build version, the family base capability, and any additional supported or required capabilities. +4. Schedule a coordinated gateway and extension upgrade. There is no supported mixed legacy/current pairing: current extensions reject legacy gateways that omit metadata, and current gateways reject legacy extensions that omit metadata. Stop traffic, upgrade both peers, and restart them together. +5. Run mixed-minor tests with required capabilities present and absent after both peers implement negotiation. Verify that a major mismatch and missing metadata fail before runtime traffic. + +The legacy compute and credential `driver_version` fields and middleware `service_version` field remain populated during migration. New integrations must use `PeerMetadata.implementation_version`; the legacy fields are diagnostic compatibility fields and may be removed in a future protocol major. + +## Inspect Negotiated Extensions + +Admins can run `openshell gateway info` or call protected `GetGatewayInfo`. The response contains a sorted snapshot captured at startup: family, configured name, implementation identity/version, protocol version, supported capabilities, and extension requirements. + +The snapshot excludes endpoints, audiences, bearer tokens, certificates, backend configuration, and free-form extension diagnostics. diff --git a/docs/extensibility/gateway-interceptors.mdx b/docs/extensibility/gateway-interceptors.mdx index 4d931da5c..69c82b46d 100644 --- a/docs/extensibility/gateway-interceptors.mdx +++ b/docs/extensibility/gateway-interceptors.mdx @@ -48,6 +48,8 @@ An interceptor implements the `openshell.gateway_interceptor.v1.GatewayIntercept - `Evaluate` handles one selected operation phase. - `SnapshotProviderProfiles` optionally returns a provider profile catalog. +`DescribeRequest` carries gateway protocol metadata, and `InterceptorManifest.extension` returns the interceptor's protocol and implementation metadata. The gateway rejects missing metadata, incompatible majors, and unmet required capabilities before it accepts bindings. See [Extension Protocol Negotiation](/extensibility/extension-negotiation). + Each `InterceptorEvaluation` identifies the configured interceptor, manifest binding, public OpenShell service and method, authenticated principal, and active phase. The phase determines whether the payload contains a proposed operation, optional current state, or committed response. The interceptor returns an `InterceptorResult` with an allow or deny decision, an optional denial status and reason, JSON patches during `modify_operation`, and non-secret log annotations. diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index b48230a7f..1a74772b8 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -84,6 +84,8 @@ Each binding returned by `Describe` may advertise a shorter `timeout` using the The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes more than one binding for the same operation and phase. The manifest `name` is diagnostic metadata and does not need to match the operator registration name. Operator-run registration names cannot claim the reserved `openshell/` namespace. +`MiddlewareDescribeRequest` carries the caller's common protocol metadata, and `MiddlewareManifest.extension` returns the service's protocol and implementation metadata. This replaces the former empty `Describe` request. Regenerate service bindings and return protocol `1.0` plus `openshell.supervisor-middleware.contract`. Legacy gateways and current middleware cannot interoperate, so upgrade both peers during a coordinated outage. See [Extension Protocol Negotiation](/extensibility/extension-negotiation) for the skew policy and migration sequence. + Registration is static. Restart the gateway after adding, removing, or changing a service. See [Gateway Configuration](/reference/gateway-config#supervisor-middleware-services) for the complete gateway TOML context. ### Authenticate OpenShell Callers diff --git a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx index a07bfabdb..4204e7ebf 100644 --- a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx +++ b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx @@ -93,8 +93,8 @@ binaries: Lint and import the profile: ```shell -openshell provider profile lint -f microsoft-graph-mail.yaml -openshell provider profile import -f microsoft-graph-mail.yaml +openshell profile lint -f microsoft-graph-mail.yaml +openshell profile import -f microsoft-graph-mail.yaml ``` The profile defines the refresh strategy and Graph network policy. The `tenant_id` refresh material selects the Microsoft token endpoint during gateway-managed refresh. diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index c3f4e2416..43e221b82 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -192,7 +192,7 @@ The most commonly changed values are: | Value | Purpose | |---|---| | `image.repository` / `image.tag` | Gateway container image. Defaults to `ghcr.io/nvidia/openshell/gateway:latest`. | -| `replicaCount` | Number of gateway replicas. Leave at `1` unless you are explicitly testing multi-replica behavior. | +| `replicaCount` | Number of gateway replicas. Values above `1` require shared PostgreSQL through `server.externalDbSecret`. | | `workload.kind` | Gateway workload controller. Use `statefulset` for SQLite or `deployment` with `server.externalDbSecret`. | | `workload.allowMultiReplicaStatefulSet` | Allow `replicaCount > 1` with `workload.kind=statefulset`. Prefer Deployment for external database-backed multi-replica gateways. | | `server.sandboxNamespace` | Namespace where sandbox pods are created. Defaults to the Helm release namespace when left empty. | @@ -210,6 +210,24 @@ The most commonly changed values are: | `supervisor.sandboxRuntime.boundaryPort` | Non-privileged TLS port used between paired supervisor and sandbox Pods. | | `upstreamProxy` | Operator-owned corporate HTTP forward proxy for policy-approved TLS egress. Refer to [Configure a Corporate Upstream Proxy](#configure-a-corporate-upstream-proxy). | +### Run multiple gateway replicas + +Use a Deployment and shared PostgreSQL for high availability: + +```yaml +replicaCount: 2 +workload: + kind: deployment +server: + externalDbSecret: openshell-postgres +``` + +The chart creates the peer Service, projected ServiceAccount identity, and RBAC +that gateway replicas use to route exec, forwarding, file-sync, and service +traffic to the replica holding the external supervisor's live session. Peer +traffic uses the chart TLS materials when gateway TLS is enabled. Do not use +the per-pod SQLite database with more than one replica. + Use a values file for repeatable deployments: ```shell diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 4bc4aad6d..13ddfeda6 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -54,6 +54,7 @@ OpenShell maps sandbox events to these OCSF classes: | Shorthand prefix | OCSF class | Class UID | What it covers | |---|---|---|---| +| `EVENT` | Base Event | 0 | Endpointless relay, proxy, and mediation failures | | `NET:` | Network Activity | 4001 | TCP proxy CONNECT tunnels, bypass detection, DNS failures | | `HTTP:` | HTTP Activity | 4002 | HTTP FORWARD requests, L7 enforcement decisions | | `SSH:` | SSH Activity | 4007 | SSH handshakes, authentication, channel operations | @@ -70,6 +71,10 @@ The shorthand format follows this pattern: CLASS:ACTIVITY [SEVERITY] ACTION DETAILS [CONTEXT] ``` +Base Events use `EVENT [SEVERITY] MESSAGE [CONTEXT]`. Unix socket relay and +relay-control notifications, plus proxy and mediation failures with no observed +endpoint, use `EVENT` rather than a network or lifecycle event. + ### Components **Class and activity** (`NET:OPEN`, `HTTP:GET`, `PROC:LAUNCH`) identify the OCSF event class and what happened. The class name always starts at the same column position for vertical scanning. diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 29771f567..81136e2e3 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -160,6 +160,7 @@ The `class_uid` field identifies the event type: | `class_uid` | Class | Shorthand prefix | |---|---|---| +| 0 | Base Event | `EVENT` | | 4001 | Network Activity | `NET:` | | 4002 | HTTP Activity | `HTTP:` | | 4007 | SSH Activity | `SSH:` | @@ -172,6 +173,15 @@ HTTP Activity records include request or response details. Rejections that have parsed a request, including early credential-binding failures, include safe method-only request context and the generated response. +Connection-error records include the known peer or listening endpoint. + +Policy configuration warnings use Device Config State Change. + +Unix socket relay and relay-control notifications, and proxy or mediation +failures without an observed network endpoint, use Base Event (`class_uid: 0`). +Include these classes when filtering supervisor operational events. Relay-control +records that previously appeared as `NET:*` now appear as `EVENT`. + ## SIEM Schema Version Compatibility OpenShell emits OCSF v1.8.0 events internally, but many SIEMs only support older schema versions. The `ocsf_schema_version` setting tells the JSONL layer to downgrade events before writing, stripping fields and profiles that don't exist in the target version. diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index d89299720..8b574fb34 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -34,8 +34,8 @@ Provider profiles include these user-facing features: - An import-only catalog: the gateway serves the profiles you imported and nothing else. A gateway with none imported serves an empty catalog. - Gateway configuration can compose user-managed and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. - Platform-scoped and workspace-scoped profiles. A workspace profile takes precedence over a platform profile with the same ID inside that workspace, and the platform profile applies outside it. An interceptor-vended profile cannot share an ID with an imported one; that collision fails closed. -- `openshell provider list-profiles` with table, YAML, and JSON output. -- `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. +- `openshell profile list` and `openshell profile describe` with table, YAML, and JSON output. +- `openshell profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from imported profile IDs with `openshell provider create --type `. - Provider instances whose submitted credentials can be stored by a configured gateway credential driver. - Profile-backed credential discovery for explicit `openshell provider create --from-existing` and `openshell provider update --from-existing` flows. The `google-vertex-ai` profile also supplements discovery with Vertex config env vars such as `VERTEX_AI_PROJECT_ID` and `VERTEX_AI_REGION`. @@ -216,18 +216,38 @@ inference provider and call its native endpoint. A provider profile defines a provider type. It contains metadata, credential declarations, endpoint policy, binary policy, an informational provider category, and optional credential refresh metadata. +Use `openshell profile` to discover, inspect, and manage reusable profile definitions. Use `openshell provider` to manage provider instances and their credential values. Provider profiles are the supported profile type. + +Existing command names remain supported. `openshell provider list-profiles` is equivalent to `openshell profile list --type provider`, and `openshell provider profile export/import/update/lint/delete` invokes the same handlers as the corresponding `openshell profile` commands. The nested commands retain their existing arguments, output options, and workspace/global flags. New examples use the top-level form. + List available profiles: ```shell -openshell provider list-profiles +openshell profile list +openshell profile list --type provider -o json ``` -A gateway serves exactly the profiles you imported. OpenShell ships no profiles -inside the gateway binary, so a new gateway lists an empty catalog until you -import one. When a configured gateway interceptor vends an authoritative -provider profile catalog, that catalog becomes the visible source of truth: -list, export, provider creation, policy composition, and sandbox provider -environment resolution use the interceptor-vended profiles. +The default table shows `NAME`, `TYPE`, `CATEGORY`, `SOURCE`, and `SCOPE`. `NAME` is the profile ID used by the other profile commands and by `provider create --type`. The optional `--type provider` filter selects provider profiles. Use `-o json` or `-o yaml` for structured output. + +Inspect one profile before creating a provider: + +```shell +openshell profile describe github +openshell profile describe github -o yaml +``` + +The description shows metadata, credential names and authentication settings, endpoints with their protocols and policy rule counts, allowed binaries, source, and scope. Endpoint details include TLS handling, permission for uninspected credential traffic, and MCP method and tool-name settings. A `tls: skip` endpoint is shown as a raw tunnel without L7 inspection or credential rewrite, even when it declares an L7 protocol and rules. Use JSON or YAML output to inspect the complete rule definitions. The description reads the reusable definition without reading credential values from provider instances. A missing profile ID returns an error. + +List and describe use the selected workspace's effective catalog. Pass the inherited `--workspace` flag to select another workspace, or `--global` to target platform scope. Platform operations require Platform Admin access. + +```shell +openshell profile list --workspace team-ml +openshell profile describe github --workspace team-ml +openshell profile list --global +openshell profile describe github --global +``` + +A gateway serves exactly the profiles you imported. OpenShell ships no profiles inside the gateway binary, so a new gateway lists an empty catalog until you import one. When a configured gateway interceptor vends an authoritative provider profile catalog, that catalog becomes the visible source of truth: list, describe, export, provider creation, policy composition, and sandbox provider environment resolution use the interceptor-vended profiles. ### Import the Example Profiles @@ -248,19 +268,19 @@ injected and the traffic is denied. Copy the file, edit `binaries` and Lint a profile before importing it: ```shell -openshell provider profile lint -f providers/github.yaml +openshell profile lint -f providers/github.yaml ``` Import one profile file at platform scope: ```shell -openshell provider profile import -f providers/github.yaml --global +openshell profile import -f providers/github.yaml --global ``` Import all non-recursive `*.yaml`, `*.yml`, and `*.json` files from a directory: ```shell -openshell provider profile import --from ./providers --global +openshell profile import --from ./providers --global ``` Omit `--global` to import into the current workspace instead. @@ -268,7 +288,7 @@ Omit `--global` to import into the current workspace instead. Export a profile as YAML, to edit or to keep before an upgrade: ```shell -openshell provider profile export github -o yaml --global > github-profile.yaml +openshell profile export github -o yaml --global > github-profile.yaml ``` Import is create-only. It fails if a custom profile with the same ID already exists. @@ -276,17 +296,25 @@ Import is create-only. It fails if a custom profile with the same ID already exi Update an existing custom profile by exporting the current custom profile, editing the file, and submitting the edited file back: ```shell -openshell provider profile export github-profile -o yaml > github-profile.yaml -openshell provider profile update github-profile -f github-profile.yaml +openshell profile export github-profile -o yaml > github-profile.yaml +openshell profile update github-profile -f github-profile.yaml ``` Exported custom profiles include `resource_version`. OpenShell requires that version during update so stale files cannot silently overwrite newer profile definitions. The target ID in the command must match the profile ID in the file. Update accepts one file at a time. If an update would make dynamic token grants ambiguous for an attached sandbox, OpenShell rejects it before changing the profile. +Delete custom profiles by ID: + +```shell +openshell profile delete custom-api custom-alt +``` + +Export accepts `-o yaml` or `-o json` and defaults to YAML. Lint also accepts `--from ` and asks the gateway to validate the supplied definitions without storing them. Export, import, update, lint, and delete use the selected workspace unless you pass `--global`. + Profile IDs must use lowercase kebab-case with `a-z`, `0-9`, and `-`. No IDs are reserved: `github`, `openai`, and every other example profile imports at its own ID, and the imported profile is the only definition for it. Interceptor-managed profiles are read-only through the profile APIs. OpenShell rejects deleting a profile while a sandbox-attached provider uses it. ### Category Enum -The `category` field controls how `openshell provider list-profiles` groups profiles. Use one of these canonical YAML values: +The `category` field supplies the `CATEGORY` column in `openshell profile list`. Use one of these canonical YAML values: | Value | Use for | |---|---| @@ -434,7 +462,7 @@ import for compatibility. `id`, `display_name`, and `description` identify the profile. `id` is the value passed to `openshell provider create --type`. -`category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum. +`category` supplies the `CATEGORY` column in `openshell profile list`. Use one of the values in the category enum. `credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ead6f89f1..a65d9b1cb 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -360,6 +360,8 @@ Each service implements the supervisor middleware gRPC contract and exposes bind The gateway connects to every registered service and validates `Describe` before it starts. The service must therefore be running before the gateway. Policy creation and full policy updates call `ValidateConfig`; an unavailable service or invalid middleware configuration rejects the policy before persistence. +Startup also requires compatible [extension protocol metadata](/extensibility/extension-negotiation). Upgrade legacy gateways and operator-run services together during a coordinated outage; neither mixed legacy/current pairing is supported. A service rejects a gateway that omits peer metadata, and a gateway rejects a service that omits metadata, uses another protocol major, or requires unsupported gateway capabilities. Protected `GetGatewayInfo` and `openshell gateway info` report the resulting non-secret snapshot. + `max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and response units, replacement bodies, and complete WebSocket text messages and replacements. Whole-response inspection uses it as the stage's total body limit. Streaming response inspection applies it to each unit. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. `timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP request evaluation, HTTP response preflight and unit exchanges, WebSocket preflight, and each WebSocket message. Accepted streaming protocols have no connection-wide RPC deadline. @@ -374,6 +376,8 @@ See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, `[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target only methods in the gateway's built-in allowlist of unary mutation RPCs. New RPCs are non-interceptable until they are deliberately added to that allowlist; adding one does not require handler-specific interceptor code. Request bodies are exposed as protobuf JSON objects. Fields marked secret in the protobuf schema are recursively omitted from requests and post-commit responses. Interceptors cannot patch an omitted field or a containing object. +Each interceptor must also complete [extension protocol negotiation](/extensibility/extension-negotiation) during `Describe`. Upgrade legacy gateways and interceptors together during a coordinated outage; neither mixed legacy/current pairing is supported. The interceptor rejects missing or incompatible gateway metadata, and the gateway rejects missing metadata, incompatible protocol majors, and unmet requirements before serving requests. + HTTPS interceptor endpoints use the platform trust store by default. Set `tls_ca_cert_path` to a PEM certificate bundle for a private CA; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:interceptor:`. After authenticated `Describe` succeeds, the gateway treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. When `gateway_jwt` is configured, network interceptors must use HTTPS and receive short-lived gateway-caller bearer credentials; local Unix sockets are also supported. Set `allow_insecure_transport = true` to keep a plaintext `http://` interceptor endpoint with no credential attached and a startup warning. ### Extension Token Verification Endpoints @@ -505,6 +509,26 @@ args = [ ] ``` +## Gateway Peer Routing + +Multi-replica gateways relay session-bound traffic to the replica that owns a sandbox supervisor. The Helm chart sets these variables; set them manually only outside the chart. + +| Variable | Purpose | +|---|---| +| `OPENSHELL_PEER_ENDPOINT` | Address other replicas use to reach this one. Derived from the pod DNS name when unset. | +| `OPENSHELL_PEER_SERVICE_NAME` | Peer Service used to derive the endpoint. | +| `OPENSHELL_POD_NAME` / `OPENSHELL_POD_NAMESPACE` | Pod identity used for endpoint derivation and peer verification. | +| `OPENSHELL_SERVICE_ACCOUNT_NAME` | ServiceAccount that peer tokens must present. | +| `OPENSHELL_PEER_SERVICE_ACCOUNT_TOKEN_FILE` | Projected peer token path. Defaults to `/var/run/secrets/openshell-peer/token`. | +| `OPENSHELL_PEER_TOKEN_AUDIENCE` | Required token audience. Defaults to `openshell-gateway-peer`. | +| `OPENSHELL_PEER_POD_LABELS` | Comma-separated labels a calling pod must carry. | +| `OPENSHELL_PEER_TOKEN_CACHE_TTL_SECS` | How long a verified peer identity is cached. Defaults to `60`. | +| `OPENSHELL_PEER_TLS_CA_FILE` | CA bundle for peer connections. Falls back to the platform trust store. | +| `OPENSHELL_PEER_TLS_CERT_FILE` / `OPENSHELL_PEER_TLS_KEY_FILE` | Client certificate and key for peer mTLS. Set both or neither. | +| `OPENSHELL_PEER_TLS_SERVER_NAME` | Name verified on peer certificates. | + +The gateway refuses to start when a peer endpoint is configured on a multi-replica backend and peer authentication is unavailable, because the replica could not serve relays from its peers. That happens when the pod identity variables are missing, when the in-cluster Kubernetes client cannot be built, or when the gateway is not running in a cluster. A plaintext `http://` peer endpoint is also rejected when the gateway serves TLS; use it only when the gateway itself serves plaintext. + ## Driver References Each example is a complete TOML file for one compute driver. The examples repeat `[openshell]` and `[openshell.gateway]` so they stay copyable, and the driver tables list the accepted driver-specific keys. Drivers receive only their own tables, and the gateway rejects unknown gateway and driver fields. @@ -910,12 +934,12 @@ guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem" state_dir = "/var/lib/openshell/vm" # Where the gateway looks for the openshell-driver-vm subprocess binary. driver_dir = "/usr/local/libexec/openshell" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # Optional override. When omitted, the gateway derives # https://host.openshell.internal: for the VM driver. grpc_endpoint = "https://host.openshell.internal:17670" # Empty falls back to default_image. -bootstrap_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +bootstrap_image = "nvcr.io/nvidia/base/ubuntu:24.04" krun_log_level = 1 vcpus = 2 mem_mib = 2048 diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index fab785e91..3291e48d4 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -141,6 +141,8 @@ which request forms a driver implements. It does not report live resource inventory, availability, or scheduling capacity, so a supported request can still fail when the selected runtime cannot provide the resource. +The same response includes the compute driver's negotiated extension snapshot. Protocol version and implementation version are separate: the implementation version identifies the OpenShell driver build, not the Docker daemon, Kubernetes server, or another compute backend. See [Extension Protocol Negotiation](/extensibility/extension-negotiation). + CPU and memory capabilities report whether the driver enforces a resource limit. GPU capabilities report whether the driver accepts a default GPU request and an explicit GPU count. An omitted `resource_capabilities` message, or an @@ -364,7 +366,7 @@ compute_driver = "vm" For a launch-time override, set `OPENSHELL_COMPUTE_DRIVER=vm` in the gateway environment and restart the service. -Configure VM driver values such as `grpc_endpoint`, `driver_dir`, `state_dir`, `default_image`, `bootstrap_image`, `vcpus`, `mem_mib`, `overlay_disk_mib`, `krun_log_level`, and `guest_tls_*` in `[openshell.drivers.vm]`. The VM `state_dir` stores overlay disks, console logs, runtime state, image-rootfs cache, and the private `run/compute-driver.sock` socket. The VM socket path is managed by the gateway and is not configurable through remote endpoint settings. +Configure VM driver values such as `grpc_endpoint`, `driver_dir`, `state_dir`, `default_image`, `bootstrap_image`, `vcpus`, `mem_mib`, `overlay_disk_mib`, `krun_log_level`, and `guest_tls_*` in `[openshell.drivers.vm]`. The default sandbox image is `nvcr.io/nvidia/base/ubuntu:24.04`. The VM `state_dir` stores overlay disks, console logs, runtime state, image-rootfs cache, and the private `run/compute-driver.sock` socket. The VM socket path is managed by the gateway and is not configurable through remote endpoint settings. The gateway starts `openshell-driver-vm` over a private Unix socket and passes its process ID so the driver can reject unexpected local clients. The driver's standalone TCP listener is disabled unless `--allow-unauthenticated-tcp` is set for local development. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 03260b6cc..5a281a0d0 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -22,7 +22,8 @@ Provider profiles include metadata for known endpoints and binaries. View the available profiles before creating a provider: ```shell -openshell provider list-profiles +openshell profile list +openshell profile describe github ``` ## Create a Provider @@ -112,12 +113,12 @@ Update a custom provider profile after exporting it, editing its endpoints, binaries, or credential metadata, and preserving the exported `resource_version`: ```shell -openshell provider profile export my-api -o yaml > my-api-profile.yaml -openshell provider profile update my-api -f my-api-profile.yaml +openshell profile export my-api -o yaml > my-api-profile.yaml +openshell profile update my-api -f my-api-profile.yaml ``` Import remains create-only and fails if the profile ID already exists. Use -`provider profile update ` for existing profiles. Interceptor-managed +`profile update ` for existing profiles. Interceptor-managed profiles are read-only. The target ID must match the profile ID in the file. Update accepts one file at a time and rejects stale resource versions. Updated profile policy applies to all provider instances of that type on the next sandbox config sync. @@ -125,7 +126,7 @@ applies to all provider instances of that type on the next sandbox config sync. Delete one or more custom provider profiles by ID: ```shell -openshell provider profile delete custom-api custom-alt +openshell profile delete custom-api custom-alt ``` When a multi-profile delete fails for one entry, the CLI reports that profile's @@ -423,7 +424,7 @@ closed instead of being forwarded to the upstream service. Export the profile used by a provider to inspect its credential boundary: ```shell -openshell provider profile export github -o yaml +openshell profile export github -o yaml ``` For the GitHub example profile, `GITHUB_TOKEN` and `GH_TOKEN` can resolve for diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 7627a8765..056332c37 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -453,6 +453,69 @@ with SandboxClient.from_active_cluster(client_credentials=auth) as client: Service forwarding makes a long-running process inside a sandbox reachable through a gateway-managed URL. Use it for development servers, notebooks, dashboards, or other services that keep listening after the sandbox starts. Run the service on loopback inside the sandbox, expose its port, then open the URL printed by OpenShell. +Expose the unnamed service as part of sandbox creation when the sandbox's main +process starts the server: + +```shell +openshell sandbox create \ + --name my-sandbox \ + --expose 8080 \ + --detach \ + -- python -m http.server 8080 --bind 127.0.0.1 +``` + +The CLI includes the unnamed endpoint in the sandbox create request and prints +its URL after the sandbox reaches `Ready`. `--expose` keeps the sandbox after +the create command returns and cannot be combined with `--no-keep`. Use +`openshell service expose` to add or update an endpoint later. + +SDK create methods accept named or unnamed service exposures. An empty service +name selects the unnamed endpoint. The returned sandbox includes a service URL +map keyed by those names; use the empty key for the unnamed endpoint: + +```python +from openshell import SandboxClient, ServiceExposure + +with SandboxClient.from_active_cluster() as client: + sandbox = client.create( + workspace="default", + name="app-server", + service_exposures=[ServiceExposure(target_port=4500)], + ) + print(sandbox.service_urls[""]) +``` + +```ts +const sandbox = await client.sandbox.create({ + name: 'app-server', + image: 'base', + serviceExposures: [{ targetPort: 4500 }], +}) +console.log(sandbox.serviceUrls['']) +``` + +```go +sandbox, err := client.Sandboxes().Create( + ctx, "default", "app-server", spec, nil, + v1.CreateOptions{ServiceExposures: []v1.ServiceExposure{ + {TargetPort: 4500}, + }}, +) +fmt.Println(sandbox.ServiceURLs[""]) +``` + +```rust +let sandbox = client.create_sandbox(openshell_sdk::SandboxSpec { + name: Some("app-server".into()), + service_exposures: vec![openshell_sdk::ServiceExposure { + service: String::new(), + target_port: 4500, + }], + ..Default::default() +}).await?; +println!("{}", sandbox.service_urls[""]); +``` + Expose a service that listens on loopback inside the sandbox: ```shell diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index a8b743b5c..0fd92f966 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -698,7 +698,7 @@ When triaging denied requests, check: Then push the updated policy as described above. Do not fix `credential_endpoint_mismatch` by widening sandbox policy. Export the -provider profile with `openshell provider profile export -o yaml`. +provider profile with `openshell profile export -o yaml`. Update the custom provider profile only when the destination is an intended credential recipient. Refer to [Static Credential Endpoint Binding](/providers/profiles#understand-static-credential-endpoint-binding) diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index a60c0038d..51d1a446d 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -29,6 +29,7 @@ e2e-docker = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-gpu = ["e2e"] e2e-docker-gpu = ["e2e-docker", "e2e-gpu"] e2e-kubernetes = ["e2e"] +e2e-kubernetes-ha = ["e2e-kubernetes"] e2e-kubernetes-credential-drivers = ["e2e-kubernetes"] e2e-kubernetes-workspace-managed = ["e2e-kubernetes"] e2e-kubernetes-workspace-operator = ["e2e-kubernetes"] @@ -129,9 +130,9 @@ path = "tests/provider_token_exchange.rs" required-features = ["e2e-podman"] [[test]] -name = "readyz_health" -path = "tests/readyz_health.rs" -required-features = ["e2e-kubernetes"] +name = "kubernetes_ha_rebalancing" +path = "tests/kubernetes_ha_rebalancing.rs" +required-features = ["e2e-kubernetes-ha"] [[test]] name = "kubernetes_corporate_proxy" diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 8f687e5fe..6fd355170 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -51,7 +51,7 @@ DRIVER_BIN="${OPENSHELL_VM_DRIVER_BIN:-${ROOT}/target/debug/openshell-driver-vm} CLI_BIN="${OPENSHELL_BIN:-${ROOT}/target/debug/openshell}" E2E_TEST_OVERRIDE="${OPENSHELL_E2E_VM_TEST:-}" E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES-e2e-vm}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" # The VM driver places `compute-driver.sock` under `[openshell.drivers.vm].state_dir`. # AF_UNIX SUN_LEN is 104 bytes on macOS (108 on Linux), so paths anchored diff --git a/e2e/rust/tests/credential_gating.rs b/e2e/rust/tests/credential_gating.rs index 728188525..bf0d6ae9b 100644 --- a/e2e/rust/tests/credential_gating.rs +++ b/e2e/rust/tests/credential_gating.rs @@ -79,7 +79,7 @@ async fn delete_until_gone(args: &[&str]) -> Result<(), String> { /// before it is recreated, otherwise creation fails with "already exists". async fn ensure_provider_resources_absent() -> Result<(), String> { delete_until_gone(&["provider", "delete", PROVIDER_NAME]).await?; - delete_until_gone(&["provider", "profile", "delete", PROFILE_ID]).await + delete_until_gone(&["profile", "delete", PROFILE_ID]).await } /// Best-effort teardown. Never fails the test: it also runs on the failure @@ -134,8 +134,7 @@ async fn install_provider(rest_port: u16, websocket_port: u16) -> Result<(), Str .path() .to_str() .ok_or_else(|| "profile path is not UTF-8".to_string())?; - let (imported, output) = - run_cli(&["provider", "profile", "import", "--file", profile_path]).await; + let (imported, output) = run_cli(&["profile", "import", "--file", profile_path]).await; if !imported { return Err(format!("profile import failed:\n{output}")); } @@ -192,8 +191,7 @@ async fn install_endpointless_provider() -> Result<(), String> { .path() .to_str() .ok_or_else(|| "endpointless profile path is not UTF-8".to_string())?; - let (imported, output) = - run_cli(&["provider", "profile", "import", "--file", profile_path]).await; + let (imported, output) = run_cli(&["profile", "import", "--file", profile_path]).await; if !imported { return Err(format!("endpointless profile import failed:\n{output}")); } diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index cf7b9e657..e0df5d25c 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -246,8 +246,7 @@ async fn delete_provider(name: &str) { async fn delete_provider_profile(id: &str) { let mut cmd = openshell_cmd(); - cmd.arg("provider") - .arg("profile") + cmd.arg("profile") .arg("delete") .arg(id) .stdout(Stdio::null()) @@ -316,7 +315,11 @@ async fn sandbox_reaches_host_openshell_internal_via_host_gateway_alias() { .expect("temp policy path should be utf-8") .to_string(); + // The workload needs curl, which minimal default images such as the VM + // driver's nvcr.io/nvidia/base/ubuntu do not ship. let guard = SandboxGuard::create(&[ + "--from", + "base", "--policy", &policy_path, "--", @@ -369,10 +372,10 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { delete_provider(BINDING_PROVIDER_B_NAME).await; delete_provider_profile(BINDING_PROFILE_A_ID).await; delete_provider_profile(BINDING_PROFILE_B_ID).await; - run_cli(&["provider", "profile", "import", "--file", &profile_a_path]) + run_cli(&["profile", "import", "--file", &profile_a_path]) .await .expect("import provider A endpoint-binding profile"); - run_cli(&["provider", "profile", "import", "--file", &profile_b_path]) + run_cli(&["profile", "import", "--file", &profile_b_path]) .await .expect("import provider B endpoint-binding profile"); run_cli(&[ @@ -405,6 +408,8 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { server.port, server.port, server.port ); let mut guard = SandboxGuard::create(&[ + "--from", + "base", "--policy", &policy_path, "--provider", diff --git a/e2e/rust/tests/kubernetes_ha_rebalancing.rs b/e2e/rust/tests/kubernetes_ha_rebalancing.rs new file mode 100644 index 000000000..ca65f1e3e --- /dev/null +++ b/e2e/rust/tests/kubernetes_ha_rebalancing.rs @@ -0,0 +1,627 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-kubernetes-ha")] + +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::port::{find_free_port, wait_for_port}; +use openshell_e2e::harness::sandbox::SandboxGuard; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tokio::process::{Child, Command}; + +static KUBE_HA_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +const HA_SYNC_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; +const HA_SYNC_TIMEOUT: Duration = Duration::from_secs(600); + +#[derive(Clone)] +struct KubeTarget { + context: String, + namespace: String, + release: String, +} + +impl KubeTarget { + fn from_env() -> Self { + Self { + context: required_env("OPENSHELL_E2E_KUBE_CONTEXT"), + namespace: std::env::var("OPENSHELL_E2E_KUBE_NAMESPACE") + .unwrap_or_else(|_| "openshell".to_string()), + release: std::env::var("OPENSHELL_E2E_KUBE_RELEASE") + .unwrap_or_else(|_| "openshell".to_string()), + } + } + + async fn kubectl(&self, args: &[&str]) -> Result { + let output = Command::new("kubectl") + .arg("--context") + .arg(&self.context) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|err| format!("failed to spawn kubectl {args:?}: {err}"))?; + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if !output.status.success() { + return Err(format!( + "kubectl {args:?} failed with exit {:?}:\n{combined}", + output.status.code() + )); + } + + Ok(combined) + } + + async fn scale_gateway(&self, replicas: usize) -> Result<(), String> { + let resource = self.gateway_workload_resource().await?; + let replicas_arg = replicas.to_string(); + + self.kubectl(&[ + "-n", + &self.namespace, + "scale", + &resource, + "--replicas", + &replicas_arg, + ]) + .await?; + self.kubectl(&[ + "-n", + &self.namespace, + "rollout", + "status", + &resource, + "--timeout=180s", + ]) + .await?; + Ok(()) + } + + async fn gateway_workload_resource(&self) -> Result { + let deployment = format!("deployment/{}", self.release); + if self + .kubectl(&["-n", &self.namespace, "get", &deployment]) + .await + .is_ok() + { + return Ok(deployment); + } + + let statefulset = format!("statefulset/{}", self.release); + if self + .kubectl(&["-n", &self.namespace, "get", &statefulset]) + .await + .is_ok() + { + return Ok(statefulset); + } + + Err(format!( + "no gateway Deployment or StatefulSet named {} found in namespace {}", + self.release, self.namespace + )) + } + + async fn delete_gateway_pod(&self, pod: &str) -> Result<(), String> { + self.kubectl(&[ + "-n", + &self.namespace, + "delete", + "pod", + pod, + "--wait=true", + "--timeout=90s", + ]) + .await?; + Ok(()) + } + + async fn roll_gateway_pods(&self, pods: Vec, expected: usize) -> Result<(), String> { + for pod in pods { + self.delete_gateway_pod(&pod).await?; + self.wait_for_gateway_pods(expected).await?; + } + Ok(()) + } + + async fn wait_for_gateway_pods(&self, expected: usize) -> Result, String> { + let deadline = Instant::now() + Duration::from_secs(240); + let mut last = String::new(); + + while Instant::now() < deadline { + match self.gateway_pods().await { + Ok(pods) => { + if pods.len() == expected && pods.iter().all(|pod| pod.ready) { + return Ok(pods.into_iter().map(|pod| pod.name).collect()); + } + last = format!( + "pods={:?}", + pods.iter() + .map(|pod| format!("{} ready={}", pod.name, pod.ready)) + .collect::>() + ); + } + Err(err) => last = err, + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + + Err(format!( + "gateway pods did not reach expected ready count {expected} within 240s; last={last}" + )) + } + + async fn gateway_pods(&self) -> Result, String> { + let selector = format!("app.kubernetes.io/instance={}", self.release); + let json = self + .kubectl(&[ + "-n", + &self.namespace, + "get", + "pods", + "-l", + &selector, + "-o", + "json", + ]) + .await?; + let value = serde_json::from_str::(&json) + .map_err(|err| format!("failed to parse gateway pod JSON: {err}\n{json}"))?; + let items = value["items"] + .as_array() + .ok_or_else(|| format!("gateway pod JSON missing items array: {value}"))?; + + let mut pods = Vec::new(); + for item in items { + if !item["metadata"]["deletionTimestamp"].is_null() { + continue; + } + let Some(name) = item["metadata"]["name"].as_str() else { + continue; + }; + let ready = item["status"]["conditions"] + .as_array() + .is_some_and(|conditions| { + conditions.iter().any(|condition| { + condition["type"].as_str() == Some("Ready") + && condition["status"].as_str() == Some("True") + }) + }); + pods.push(GatewayPod { + name: name.to_string(), + ready, + }); + } + pods.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(pods) + } +} + +#[derive(Debug)] +struct GatewayPod { + name: String, + ready: bool, +} + +struct PortForward { + port: u16, + child: Child, +} + +impl PortForward { + async fn start(kube: &KubeTarget, pod: &str) -> Result { + let port = find_free_port(); + let mut child = Command::new("kubectl") + .arg("--context") + .arg(&kube.context) + .arg("-n") + .arg(&kube.namespace) + .arg("port-forward") + .arg(format!("pod/{pod}")) + .arg(format!("{port}:8080")) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|err| format!("failed to start kubectl port-forward for {pod}: {err}"))?; + + match wait_for_port("127.0.0.1", port, Duration::from_secs(30)).await { + Ok(()) => Ok(Self { port, child }), + Err(err) => { + let status = child.try_wait().ok().flatten(); + let _ = child.kill().await; + Err(format!( + "port-forward to {pod} did not become ready on {port}: {err}; status={status:?}" + )) + } + } + } +} + +impl Drop for PortForward { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +fn required_env(name: &str) -> String { + std::env::var(name) + .unwrap_or_else(|_| panic!("{name} is not set; run through e2e/rust/e2e-kubernetes.sh")) +} + +async fn exec_through_pod( + kube: &KubeTarget, + pod: &str, + sandbox_name: &str, + marker: &str, +) -> Result<(), String> { + let port_forward = PortForward::start(kube, pod).await?; + let endpoint = format!("http://127.0.0.1:{}", port_forward.port); + + let mut cmd = openshell_cmd(); + cmd.arg("--gateway-endpoint") + .arg(&endpoint) + .args([ + "sandbox", + "exec", + "--name", + sandbox_name, + "--no-tty", + "--", + "printf", + "%s", + marker, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = cmd + .output() + .await + .map_err(|err| format!("failed to spawn openshell exec via {pod}: {err}"))?; + + let combined = strip_ansi(&format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + if !output.status.success() || !combined.contains(marker) { + return Err(format!( + "exec through {pod} ({endpoint}) failed with exit {:?}; expected marker {marker:?}; output:\n{combined}", + output.status.code() + )); + } + + Ok(()) +} + +async fn exec_through_configured_gateway(sandbox_name: &str, marker: &str) -> Result<(), String> { + let mut cmd = openshell_cmd(); + cmd.args([ + "sandbox", + "exec", + "--name", + sandbox_name, + "--no-tty", + "--", + "printf", + "%s", + marker, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = cmd + .output() + .await + .map_err(|err| format!("failed to spawn openshell exec via configured gateway: {err}"))?; + + let combined = strip_ansi(&format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + if !output.status.success() || !combined.contains(marker) { + return Err(format!( + "exec through configured gateway failed with exit {:?}; expected marker {marker:?}; output:\n{combined}", + output.status.code() + )); + } + + Ok(()) +} + +async fn create_sandbox_through_configured_gateway(phase: &str) -> Result { + let marker = format!("ha-create-watch-{phase}"); + let guard = SandboxGuard::create(&["--", "printf", "%s", &marker]).await?; + let output = strip_ansi(&guard.create_output); + + if !output.contains(&marker) { + return Err(format!( + "sandbox create through configured gateway did not include marker {marker:?}; output:\n{output}" + )); + } + + Ok(guard) +} + +async fn assert_exec_through_all_pods( + kube: &KubeTarget, + pods: &[String], + sandbox_name: &str, + phase: &str, +) -> Result<(), String> { + for pod in pods { + let marker = format!("ha-rebalance-{phase}-{pod}"); + exec_through_pod(kube, pod, sandbox_name, &marker).await?; + } + Ok(()) +} + +fn write_deterministic_payload(path: &Path, size: usize) { + let mut file = fs::File::create(path).expect("create HA sync payload"); + let mut offset = 0usize; + let mut remaining = size; + let mut buf = vec![0_u8; 64 * 1024]; + + while remaining > 0 { + let chunk_len = remaining.min(buf.len()); + for (idx, byte) in buf[..chunk_len].iter_mut().enumerate() { + *byte = u8::try_from((offset + idx) % 251).expect("byte value fits"); + } + file.write_all(&buf[..chunk_len]) + .expect("write HA sync payload chunk"); + offset += chunk_len; + remaining -= chunk_len; + } +} + +fn sha256_file(path: &Path) -> String { + let data = fs::read(path).expect("read file for SHA-256"); + let mut hasher = Sha256::new(); + hasher.update(&data); + hex::encode(hasher.finalize()) +} + +fn upload_command(sandbox_name: &str, local_path: &Path, dest: &str) -> Command { + let mut cmd = openshell_cmd(); + cmd.arg("sandbox") + .arg("upload") + .arg(sandbox_name) + .arg(local_path) + .arg(dest) + .arg("--no-git-ignore"); + cmd +} + +fn download_command(sandbox_name: &str, sandbox_path: &str, local_dest: &Path) -> Command { + let mut cmd = openshell_cmd(); + cmd.arg("sandbox") + .arg("download") + .arg(sandbox_name) + .arg(sandbox_path) + .arg(local_dest); + cmd +} + +async fn run_cli_during_gateway_pod_roll( + kube: &KubeTarget, + mut cmd: Command, + operation: &str, +) -> Result { + let pods = kube.wait_for_gateway_pods(2).await?; + + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let child = cmd + .spawn() + .map_err(|err| format!("failed to spawn {operation} command: {err}"))?; + + let (roll_result, output_result) = tokio::time::timeout(HA_SYNC_TIMEOUT, async { + let roll = async { + tokio::time::sleep(Duration::from_millis(250)).await; + kube.roll_gateway_pods(pods, 2).await + }; + tokio::join!(roll, child.wait_with_output()) + }) + .await + .map_err(|_| { + format!( + "{operation} command and gateway pod roll did not finish within {HA_SYNC_TIMEOUT:?}" + ) + })?; + + roll_result.map_err(|err| { + format!("gateway pod roll failed while {operation} command was running: {err}") + })?; + + let output = + output_result.map_err(|err| format!("failed to wait for {operation} command: {err}"))?; + let combined = strip_ansi(&format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + if !output.status.success() { + return Err(format!( + "{operation} command failed with exit {:?} during gateway pod roll:\n{combined}", + output.status.code() + )); + } + + Ok(combined) +} + +#[tokio::test] +async fn sandbox_exec_rebalances_across_gateway_scale_and_rollout() { + let _test_lock = KUBE_HA_TEST_LOCK.lock().await; + let kube = KubeTarget::from_env(); + + let mut pods = kube + .wait_for_gateway_pods(2) + .await + .expect("gateway should start with two ready HA replicas"); + + let mut sandbox = create_sandbox_through_configured_gateway("initial") + .await + .expect("sandbox create and readiness watch should succeed through the configured gateway endpoint initially"); + + assert_exec_through_all_pods(&kube, &pods, &sandbox.name, "initial") + .await + .expect("exec should work through every initial gateway pod"); + exec_through_configured_gateway(&sandbox.name, "ha-rebalance-client-initial") + .await + .expect("exec should work through the configured client gateway endpoint initially"); + + kube.scale_gateway(3) + .await + .expect("scale gateway to three replicas"); + pods = kube + .wait_for_gateway_pods(3) + .await + .expect("gateway should scale to three ready replicas"); + assert_exec_through_all_pods(&kube, &pods, &sandbox.name, "scale-up") + .await + .expect("exec should work through every gateway pod after scale-up"); + exec_through_configured_gateway(&sandbox.name, "ha-rebalance-client-scale-up") + .await + .expect("exec should work through the configured client gateway endpoint after scale-up"); + let mut scale_up_sandbox = create_sandbox_through_configured_gateway("scale-up") + .await + .expect( + "sandbox create and readiness watch should succeed through the configured gateway endpoint after scale-up", + ); + scale_up_sandbox.cleanup().await; + + kube.scale_gateway(2) + .await + .expect("scale gateway back to two replicas"); + pods = kube + .wait_for_gateway_pods(2) + .await + .expect("gateway should scale back to two ready replicas"); + assert_exec_through_all_pods(&kube, &pods, &sandbox.name, "scale-down") + .await + .expect("exec should work through every gateway pod after scale-down"); + exec_through_configured_gateway(&sandbox.name, "ha-rebalance-client-scale-down") + .await + .expect("exec should work through the configured client gateway endpoint after scale-down"); + let mut scale_down_sandbox = create_sandbox_through_configured_gateway("scale-down") + .await + .expect( + "sandbox create and readiness watch should succeed through the configured gateway endpoint after scale-down", + ); + scale_down_sandbox.cleanup().await; + + for (idx, pod) in pods.clone().into_iter().enumerate() { + kube.delete_gateway_pod(&pod) + .await + .unwrap_or_else(|err| panic!("delete gateway pod {pod}: {err}")); + pods = kube.wait_for_gateway_pods(2).await.unwrap_or_else(|err| { + panic!("gateway pods should recover after deleting {pod}: {err}") + }); + assert_exec_through_all_pods(&kube, &pods, &sandbox.name, &format!("delete-{pod}")) + .await + .unwrap_or_else(|err| panic!("exec should work after deleting {pod}: {err}")); + exec_through_configured_gateway( + &sandbox.name, + &format!("ha-rebalance-client-delete-{pod}"), + ) + .await + .unwrap_or_else(|err| { + panic!( + "exec should work through the configured client gateway endpoint after deleting {pod}: {err}" + ) + }); + let mut delete_sandbox = + create_sandbox_through_configured_gateway(&format!("delete-{idx}")) + .await + .unwrap_or_else(|err| { + panic!( + "sandbox create and readiness watch should succeed through the configured gateway endpoint after deleting {pod}: {err}" + ) + }); + delete_sandbox.cleanup().await; + } + + sandbox.cleanup().await; +} + +#[tokio::test] +async fn sandbox_file_sync_survives_gateway_pod_rolls() { + let _test_lock = KUBE_HA_TEST_LOCK.lock().await; + let kube = KubeTarget::from_env(); + + kube.scale_gateway(2) + .await + .expect("gateway should run with two HA replicas for sync outage testing"); + kube.wait_for_gateway_pods(2) + .await + .expect("gateway should have two ready replicas before sync outage testing"); + + let mut sandbox = + SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") + .await + .expect("sandbox create --keep for HA sync testing"); + + let tmpdir = tempfile::tempdir().expect("create HA sync tmpdir"); + let upload_dir = tmpdir.path().join("ha-sync-upload"); + fs::create_dir_all(&upload_dir).expect("create HA sync upload dir"); + fs::write(upload_dir.join("marker.txt"), "ha-sync-marker").expect("write HA sync marker"); + + let payload = upload_dir.join("payload.bin"); + write_deterministic_payload(&payload, HA_SYNC_PAYLOAD_BYTES); + let expected_hash = sha256_file(&payload); + + let upload = upload_command(&sandbox.name, &upload_dir, "/sandbox/ha-sync"); + run_cli_during_gateway_pod_roll(&kube, upload, "upload") + .await + .expect("upload should survive rolling gateway pod outages"); + + let remote_payload = "/sandbox/ha-sync/ha-sync-upload/payload.bin"; + let remote_hash_cmd = format!("sha256sum {remote_payload} | awk '{{print $1}}'"); + let remote_hash = sandbox + .exec(&["sh", "-c", &remote_hash_cmd]) + .await + .expect("uploaded payload should be readable in sandbox"); + assert!( + strip_ansi(&remote_hash).contains(&expected_hash), + "uploaded payload SHA-256 mismatch; expected {expected_hash}, got:\n{remote_hash}" + ); + + let download_dir = tmpdir.path().join("ha-sync-download"); + fs::create_dir_all(&download_dir).expect("create HA sync download dir"); + let download = download_command( + &sandbox.name, + "/sandbox/ha-sync/ha-sync-upload", + &download_dir, + ); + run_cli_during_gateway_pod_roll(&kube, download, "download") + .await + .expect("download should survive rolling gateway pod outages"); + + let actual_hash = sha256_file(&download_dir.join("payload.bin")); + assert_eq!( + expected_hash, actual_hash, + "downloaded payload SHA-256 mismatch after gateway pod rolls" + ); + let marker = fs::read_to_string(download_dir.join("marker.txt")) + .expect("read downloaded HA sync marker"); + assert_eq!(marker, "ha-sync-marker", "downloaded marker mismatch"); + + sandbox.cleanup().await; +} diff --git a/e2e/rust/tests/provider_refresh_handles.rs b/e2e/rust/tests/provider_refresh_handles.rs index a12281f3e..78e1adfe7 100644 --- a/e2e/rust/tests/provider_refresh_handles.rs +++ b/e2e/rust/tests/provider_refresh_handles.rs @@ -97,7 +97,7 @@ async fn run_cli_with_env(args: &[&str], env: &[(&str, &str)]) -> Result Result { @@ -193,7 +193,7 @@ network_policies: async fn configure_refresh(profile: &NamedTempFile) -> Result<(), String> { let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + run_cli(&["profile", "import", "--file", &profile_path]).await?; run_cli_with_env( &[ "provider", diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index c4e06f029..b6340d646 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -836,14 +836,14 @@ async fn podman_provider_token_exchange_injects_bearer_header() { let _target = start_protected_target(target_port).await; run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; - run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + run_cli_ignore_error(&["profile", "delete", &profile_type, "--yes"]).await; let profile = write_profile(&profile_type, token_port, target_port); let profile_path = profile .path() .to_str() .expect("profile path should be UTF-8"); - run_cli(&["provider", "profile", "import", "-f", profile_path]) + run_cli(&["profile", "import", "-f", profile_path]) .await .expect("import provider profile"); run_cli(&[ @@ -883,7 +883,7 @@ async fn podman_provider_token_exchange_injects_bearer_header() { }; run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; - run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + run_cli_ignore_error(&["profile", "delete", &profile_type, "--yes"]).await; sandbox.cleanup().await; assert!( diff --git a/e2e/rust/tests/proxy_egress_pipeline.rs b/e2e/rust/tests/proxy_egress_pipeline.rs index d4c0094d2..9c5c83a96 100644 --- a/e2e/rust/tests/proxy_egress_pipeline.rs +++ b/e2e/rust/tests/proxy_egress_pipeline.rs @@ -107,7 +107,7 @@ async fn delete_provider(name: &str) { async fn delete_provider_profile(id: &str) { let mut cmd = openshell_cmd(); - cmd.args(["provider", "profile", "delete", id]) + cmd.args(["profile", "delete", id]) .stdout(Stdio::null()) .stderr(Stdio::null()); let _ = cmd.status().await; @@ -1512,7 +1512,7 @@ async fn http_credentials_are_rewritten_in_transparent_headers_and_bodies() { let server = CredentialProbeServer::start().await?; let profile = write_credential_profile(server.port)?; let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + run_cli(&["profile", "import", "--file", &profile_path]).await?; create_bound_provider(PROVIDER_NAME).await?; let endpoint_options = r#" path: /probe protocol: rest diff --git a/e2e/rust/tests/readyz_health.rs b/e2e/rust/tests/readyz_health.rs deleted file mode 100644 index 8f093dabe..000000000 --- a/e2e/rust/tests/readyz_health.rs +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e-kubernetes")] - -use bytes::Bytes; -use http_body_util::{BodyExt, Empty}; -use hyper::Request; -use hyper_util::rt::TokioIo; -use serde_json::Value; -use std::time::{Duration, Instant}; -use tokio::net::TcpStream; - -fn health_port_from_env() -> u16 { - let raw = std::env::var("OPENSHELL_E2E_HEALTH_PORT").unwrap_or_else(|_| { - panic!( - "OPENSHELL_E2E_HEALTH_PORT is not set. The Kubernetes e2e wrapper \ - (e2e/with-kube-gateway.sh) must export this variable so the \ - /readyz test can reach the gateway health listener." - ) - }); - raw.parse::().unwrap_or_else(|err| { - panic!("OPENSHELL_E2E_HEALTH_PORT=\"{raw}\" is not a valid u16 port: {err}") - }) -} - -async fn http_get_json(port: u16, path: &str) -> Result<(u16, Value), String> { - let stream = TcpStream::connect(("127.0.0.1", port)) - .await - .map_err(|err| format!("connect health endpoint :{port}: {err}"))?; - let (mut sender, conn) = hyper::client::conn::http1::Builder::new() - .handshake(TokioIo::new(stream)) - .await - .map_err(|err| format!("handshake health HTTP/1 client :{port}: {err}"))?; - tokio::spawn(async move { - let _ = conn.await; - }); - - let req = Request::builder() - .method("GET") - .uri(format!("http://127.0.0.1:{port}{path}")) - .body(Empty::::new()) - .map_err(|err| format!("build health request {path}: {err}"))?; - let resp = sender - .send_request(req) - .await - .map_err(|err| format!("send health request {path} to :{port}: {err}"))?; - let status_code = resp.status().as_u16(); - let bytes = resp - .into_body() - .collect() - .await - .map_err(|err| format!("read health response body {path}: {err}"))? - .to_bytes(); - let json = serde_json::from_slice::(&bytes) - .map_err(|err| format!("health endpoint {path} did not return valid JSON: {err}"))?; - - Ok((status_code, json)) -} - -#[tokio::test] -async fn readyz_reports_healthy_database_check() { - let port = health_port_from_env(); - - let deadline = Instant::now() + Duration::from_secs(20); - let timeout_detail = loop { - let observation = match http_get_json(port, "/readyz").await { - Ok((status, payload)) => { - let ready = status == 200 - && payload["status"] == "healthy" - && payload["checks"]["database"]["status"] == "healthy"; - if ready { - assert!( - payload["checks"]["database"]["latency_ms"].is_number(), - "readyz payload should include checks.database.latency_ms: {payload}" - ); - assert!( - payload["checks"]["database"]["error"].is_null(), - "readyz payload should not include checks.database.error when healthy: {payload}" - ); - return; - } - format!("unexpected /readyz response status={status} payload={payload}") - } - Err(err) => err, - }; - - if Instant::now() >= deadline { - break observation; - } - - tokio::time::sleep(Duration::from_secs(1)).await; - }; - panic!("timed out waiting for /readyz healthy response after 20s: {timeout_detail}"); -} diff --git a/e2e/rust/tests/vm_corporate_proxy.rs b/e2e/rust/tests/vm_corporate_proxy.rs index 1e34e71d0..1b7b58802 100644 --- a/e2e/rust/tests/vm_corporate_proxy.rs +++ b/e2e/rust/tests/vm_corporate_proxy.rs @@ -738,10 +738,20 @@ async fn vm_corporate_proxy_routes_approved_tls_egress() { // ── Run the workload ────────────────────────────────────────────── let (_policy, policy_path) = temp_file_with(&policy_yaml(&ports), "policy file"); let script = workload_script(&ports); - let mut sandbox = - SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) - .await - .expect("create VM sandbox behind the corporate proxy"); + // The workload runs python3, which the VM driver's default + // nvcr.io/nvidia/base/ubuntu image does not ship. + let mut sandbox = SandboxGuard::create(&[ + "--from", + "base", + "--policy", + &policy_path, + "--", + "python3", + "-c", + &script, + ]) + .await + .expect("create VM sandbox behind the corporate proxy"); assert_proxied_egress( &sandbox.create_output, @@ -805,10 +815,18 @@ async fn vm_corporate_proxy_trusts_ca_bundle_for_https_proxy() { let (_policy, policy_path) = temp_file_with(&policy_yaml(&ports), "policy file"); let script = workload_script(&ports); - let mut sandbox = - SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) - .await - .expect("create VM sandbox behind the https corporate proxy"); + let mut sandbox = SandboxGuard::create(&[ + "--from", + "base", + "--policy", + &policy_path, + "--", + "python3", + "-c", + &script, + ]) + .await + .expect("create VM sandbox behind the https corporate proxy"); let proxy_logs = proxy.logs().expect("read https proxy logs"); assert!( diff --git a/e2e/rust/tests/websocket_conformance.rs b/e2e/rust/tests/websocket_conformance.rs index d95841d07..1150e020b 100644 --- a/e2e/rust/tests/websocket_conformance.rs +++ b/e2e/rust/tests/websocket_conformance.rs @@ -69,7 +69,7 @@ async fn delete_provider(name: &str) { async fn delete_provider_profile(id: &str) { let mut cmd = openshell_cmd(); - cmd.args(["provider", "profile", "delete", id]) + cmd.args(["profile", "delete", id]) .stdout(Stdio::null()) .stderr(Stdio::null()); let _ = cmd.status().await; @@ -469,7 +469,7 @@ async fn websocket_text_placeholder_is_rewritten_transparently() { let server = WebSocketProbeServer::start().await?; let profile = write_credential_profile(server.port)?; let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + run_cli(&["profile", "import", "--file", &profile_path]).await?; create_bound_provider(PROVIDER_NAME).await?; let policy = write_websocket_policy(TEST_SERVER_HOST, server.port)?; let policy_path = policy diff --git a/e2e/supervisor-middleware-fixture/src/main.rs b/e2e/supervisor-middleware-fixture/src/main.rs index cecbacb69..03cb60bc2 100644 --- a/e2e/supervisor-middleware-fixture/src/main.rs +++ b/e2e/supervisor-middleware-fixture/src/main.rs @@ -13,9 +13,9 @@ use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ }; use openshell_core::proto::{ Decision, ExistingHeaderAction, HeaderMutation, HttpRequestEvaluation, HttpRequestResult, - MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, - SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, - WebSocketSessionEvent, WriteHeader, header_mutation, + MiddlewareBinding, MiddlewareDescribeRequest, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, + ValidateConfigResponse, WebSocketSessionEvent, WriteHeader, header_mutation, }; use openshell_sdk::extension::{ExtensionCallerKind, GatewayJwtAuthenticator}; use tonic::transport::{Identity, Server, ServerTlsConfig}; @@ -81,7 +81,10 @@ impl ScriptedMiddleware { impl SupervisorMiddleware for ScriptedMiddleware { type EvaluateWebSocketSessionStream = WebSocketResponseStream; - async fn describe(&self, request: Request<()>) -> Result, Status> { + async fn describe( + &self, + request: Request, + ) -> Result, Status> { self.authenticate( &request, &[ @@ -89,7 +92,7 @@ impl SupervisorMiddleware for ScriptedMiddleware { ExtensionCallerKind::Supervisor, ], )?; - Ok(Response::new(MiddlewareManifest { + let manifest = MiddlewareManifest { name: "openshell-e2e-middleware-fixture".into(), service_version: env!("CARGO_PKG_VERSION").into(), bindings: vec![MiddlewareBinding { @@ -99,7 +102,21 @@ impl SupervisorMiddleware for ScriptedMiddleware { request_timeout: None, }], expected_audience: self.audience.clone(), - })) + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell-e2e-middleware-fixture", + openshell_core::VERSION, + [], + )), + }; + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + "openshell-e2e-middleware-fixture", + manifest.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(manifest)) } async fn validate_config( diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index c4ed80529..36c321f07 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -101,6 +101,12 @@ EXTERNAL_PG_FIXTURE_SERVICE="openshell-e2e-postgres" EXTERNAL_PG_FIXTURE_USER="openshell" EXTERNAL_PG_FIXTURE_PASSWORD="openshell-e2e-postgres" EXTERNAL_PG_FIXTURE_DATABASE="openshell" +ENVOY_RELEASE_NAME="${OPENSHELL_E2E_ENVOY_RELEASE_NAME:-envoy-gateway}" +ENVOY_NAMESPACE="${OPENSHELL_E2E_ENVOY_NAMESPACE:-envoy-gateway-system}" +ENVOY_CHART_VERSION="${OPENSHELL_E2E_ENVOY_VERSION:-v1.7.2}" +ENVOY_GATEWAY_MANIFEST="${ROOT}/deploy/kube/manifests/envoy-gateway-openshell.yaml" +ENVOY_HELM_INSTALLED=0 +ENVOY_GATEWAY_CONFIG_APPLIED=0 VAULT_FIXTURE_DEPLOYED=0 VAULT_NAMESPACE="${OPENSHELL_E2E_VAULT_NAMESPACE:-openbao}" VAULT_RELEASE_NAME="${OPENSHELL_E2E_VAULT_RELEASE_NAME:-openbao}" @@ -181,6 +187,122 @@ deploy_postgres_fixture() { --from-literal=uri="${pg_uri}" } +use_envoy_gateway() { + case "${OPENSHELL_E2E_KUBE_USE_ENVOY:-0}" in + 1 | true | TRUE | yes | YES) return 0 ;; + *) return 1 ;; + esac +} + +install_envoy_gateway() { + echo "Installing Envoy Gateway (${ENVOY_CHART_VERSION})..." + helmctl upgrade --install "${ENVOY_RELEASE_NAME}" \ + oci://docker.io/envoyproxy/gateway-helm \ + --version "${ENVOY_CHART_VERSION}" \ + --namespace "${ENVOY_NAMESPACE}" --create-namespace \ + --wait --timeout 5m + ENVOY_HELM_INSTALLED=1 + + if ! kctl get namespace "${NAMESPACE}" >/dev/null 2>&1; then + kctl create namespace "${NAMESPACE}" + fi + + kctl apply -f "${ENVOY_GATEWAY_MANIFEST}" + ENVOY_GATEWAY_CONFIG_APPLIED=1 +} + +wait_for_envoy_service() { + local svc_ref="" + local svc_namespace="" + + for _ in $(seq 1 60); do + svc_ref="$(kctl get svc -A \ + -l "gateway.envoyproxy.io/owning-gateway-name=${RELEASE_NAME},gateway.envoyproxy.io/owning-gateway-namespace=${NAMESPACE}" \ + -o jsonpath='{range .items[0]}{.metadata.namespace}{"/"}{.metadata.name}{end}' \ + 2>/dev/null || true)" + if [ -n "${svc_ref}" ]; then + svc_namespace="${svc_ref%%/*}" + if kctl -n "${svc_namespace}" wait --for=condition=Ready pod \ + -l "gateway.envoyproxy.io/owning-gateway-name=${RELEASE_NAME},gateway.envoyproxy.io/owning-gateway-namespace=${NAMESPACE}" \ + --timeout=5s >/dev/null 2>&1; then + printf '%s\n' "${svc_ref}" + return 0 + fi + fi + sleep 2 + done + + echo "ERROR: Envoy proxy Service for Gateway ${RELEASE_NAME} was not ready." >&2 + kctl -n "${NAMESPACE}" get gateway,grpcroute -o wide >&2 || true + kctl get svc -A \ + -l "gateway.envoyproxy.io/owning-gateway-name=${RELEASE_NAME},gateway.envoyproxy.io/owning-gateway-namespace=${NAMESPACE}" \ + -o wide >&2 || true + kctl get pods -A \ + -l "gateway.envoyproxy.io/owning-gateway-name=${RELEASE_NAME},gateway.envoyproxy.io/owning-gateway-namespace=${NAMESPACE}" \ + -o wide >&2 || true + return 1 +} + +start_gateway_portforward() { + local elapsed=0 + local pf_timeout=30 + local target_port=8080 + local target_namespace="${NAMESPACE}" + local target_service="${RELEASE_NAME}" + local target_service_ref="" + + LOCAL_PORT="$(e2e_pick_port)" + if use_envoy_gateway; then + target_service_ref="$(wait_for_envoy_service)" + target_namespace="${target_service_ref%%/*}" + target_service="${target_service_ref#*/}" + target_port=80 + echo "Starting kubectl port-forward -n ${target_namespace} svc/${target_service} ${LOCAL_PORT}:${target_port} (Envoy Gateway)..." + else + echo "Starting kubectl port-forward svc/${target_service} ${LOCAL_PORT}:${target_port}..." + fi + + kctl -n "${target_namespace}" port-forward "svc/${target_service}" \ + "${LOCAL_PORT}:${target_port}" >"${PORTFORWARD_LOG}" 2>&1 & + PORTFORWARD_PID=$! + + while [ "${elapsed}" -lt "${pf_timeout}" ]; do + if ! kill -0 "${PORTFORWARD_PID}" 2>/dev/null; then + echo "ERROR: kubectl port-forward exited before becoming reachable" >&2 + cat "${PORTFORWARD_LOG}" >&2 || true + return 1 + fi + if curl -s -o /dev/null --connect-timeout 1 "http://127.0.0.1:${LOCAL_PORT}"; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + + echo "ERROR: port-forward did not accept TCP within ${pf_timeout}s" >&2 + cat "${PORTFORWARD_LOG}" >&2 || true + return 1 +} + +stop_gateway_portforward() { + local pid + local pid_var + for pid_var in PORTFORWARD_PID PORTFORWARD_HEALTH_PID; do + pid="${!pid_var}" + [ -n "${pid}" ] || continue + kill "${pid}" >/dev/null 2>&1 || true + for _ in $(seq 1 10); do + if ! kill -0 "${pid}" >/dev/null 2>&1; then + break + fi + sleep 0.5 + done + kill -KILL "${pid}" >/dev/null 2>&1 || true + wait "${pid}" >/dev/null 2>&1 || true + printf -v "${pid_var}" '%s' "" + done +} + cleanup_postgres_fixture() { local secret_name="$1" @@ -320,15 +442,7 @@ cleanup_vault_fixture() { cleanup() { local exit_code=$? - if [ -n "${PORTFORWARD_PID}" ]; then - kill "${PORTFORWARD_PID}" >/dev/null 2>&1 || true - wait "${PORTFORWARD_PID}" >/dev/null 2>&1 || true - fi - - if [ -n "${PORTFORWARD_HEALTH_PID}" ]; then - kill "${PORTFORWARD_HEALTH_PID}" >/dev/null 2>&1 || true - wait "${PORTFORWARD_HEALTH_PID}" >/dev/null 2>&1 || true - fi + stop_gateway_portforward if [ "${exit_code}" -ne 0 ] && [ -n "${KUBE_CONTEXT}" ] && [ -n "${NAMESPACE}" ]; then if command -v kubectl >/dev/null 2>&1 \ @@ -390,6 +504,17 @@ cleanup() { cleanup_vault_fixture fi + if [ "${ENVOY_GATEWAY_CONFIG_APPLIED}" = "1" ] && [ -n "${KUBE_CONTEXT}" ]; then + if command -v kubectl >/dev/null 2>&1; then + kctl -n "${NAMESPACE}" delete backendtrafficpolicy.gateway.envoyproxy.io \ + openshell-grpc-timeouts --ignore-not-found --wait=false \ + >/dev/null 2>&1 || true + kctl delete gatewayclass.gateway.networking.k8s.io eg \ + --ignore-not-found --wait=false >/dev/null 2>&1 || true + fi + ENVOY_GATEWAY_CONFIG_APPLIED=0 + fi + if [ "${CORPORATE_PROXY_FIXTURE_DEPLOYED}" = "1" ]; then kctl -n "${NAMESPACE}" delete secret "${CORPORATE_PROXY_FIXTURE_SECRET}" \ --ignore-not-found >/dev/null 2>&1 || true @@ -438,6 +563,18 @@ cleanup() { fi fi + if [ "${ENVOY_HELM_INSTALLED}" = "1" ] && [ -n "${KUBE_CONTEXT}" ]; then + if command -v helm >/dev/null 2>&1; then + helmctl uninstall "${ENVOY_RELEASE_NAME}" --namespace "${ENVOY_NAMESPACE}" \ + --wait --timeout 60s >/dev/null 2>&1 || true + fi + if command -v kubectl >/dev/null 2>&1; then + kctl delete namespace "${ENVOY_NAMESPACE}" --wait=true --timeout=60s \ + --ignore-not-found >/dev/null 2>&1 || true + fi + ENVOY_HELM_INSTALLED=0 + fi + if [ "${CLUSTER_CREATED_BY_US}" = "1" ] && [ -n "${CLUSTER_NAME}" ]; then if command -v k3d >/dev/null 2>&1 && k3d cluster list "${CLUSTER_NAME}" \ >/dev/null 2>&1; then @@ -453,16 +590,7 @@ trap cleanup EXIT # --- DB-scenario helpers (used only when OPENSHELL_E2E_KUBE_DB_SCENARIOS=1) --- scenario_stop_portforward() { - if [ -n "${PORTFORWARD_PID}" ]; then - kill "${PORTFORWARD_PID}" >/dev/null 2>&1 || true - wait "${PORTFORWARD_PID}" >/dev/null 2>&1 || true - PORTFORWARD_PID="" - fi - if [ -n "${PORTFORWARD_HEALTH_PID}" ]; then - kill "${PORTFORWARD_HEALTH_PID}" >/dev/null 2>&1 || true - wait "${PORTFORWARD_HEALTH_PID}" >/dev/null 2>&1 || true - PORTFORWARD_HEALTH_PID="" - fi + stop_gateway_portforward } scenario_cleanup_release() { @@ -537,7 +665,7 @@ run_scenario() { fi else # Vanilla Kubernetes: reach the gateway in plaintext over port-forward. - if ! start_grpc_portforward; then + if ! start_gateway_portforward; then scenario_record_failure "${scenario_label}" "port-forward failed" return fi @@ -564,6 +692,9 @@ run_scenario() { export CONTAINER_ENGINE="${CONTAINER_ENGINE:-docker}" export OPENSHELL_E2E_KUBE_CONTEXT_ACTIVE="${KUBE_CONTEXT}" export OPENSHELL_E2E_SANDBOX_NAMESPACE="${NAMESPACE}" + export OPENSHELL_E2E_KUBE_CONTEXT="${KUBE_CONTEXT}" + export OPENSHELL_E2E_KUBE_NAMESPACE="${NAMESPACE}" + export OPENSHELL_E2E_KUBE_RELEASE="${RELEASE_NAME}" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-300}" e2e_import_example_provider_profiles \ @@ -1122,6 +1253,10 @@ if [ -n "${OPENSHELL_E2E_KUBE_EXTRA_VALUES:-}" ]; then helm_values_args+=(--values "${values_file}") done fi +if use_envoy_gateway; then + helm_values_args+=(--values "${ROOT}/deploy/helm/openshell/ci/values-gateway.yaml") + install_envoy_gateway +fi if [ "${OPENSHELL_E2E_KUBE_DB_SCENARIOS:-0}" = "1" ]; then # --- Multi-scenario mode: test all database backends --- @@ -1189,7 +1324,7 @@ else openshift_register_route_gateway || exit 1 else # Vanilla Kubernetes: reach the gateway in plaintext over port-forward. - start_grpc_portforward || exit 1 + start_gateway_portforward || exit 1 GATEWAY_NAME="openshell-e2e-kube-${LOCAL_PORT}" GATEWAY_ENDPOINT="http://127.0.0.1:${LOCAL_PORT}" e2e_register_plaintext_gateway \ @@ -1210,6 +1345,9 @@ else export CONTAINER_ENGINE="${CONTAINER_ENGINE:-docker}" export OPENSHELL_E2E_KUBE_CONTEXT_ACTIVE="${KUBE_CONTEXT}" export OPENSHELL_E2E_SANDBOX_NAMESPACE="${NAMESPACE}" + export OPENSHELL_E2E_KUBE_CONTEXT="${KUBE_CONTEXT}" + export OPENSHELL_E2E_KUBE_NAMESPACE="${NAMESPACE}" + export OPENSHELL_E2E_KUBE_RELEASE="${RELEASE_NAME}" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-300}" e2e_import_example_provider_profiles \ diff --git a/examples/codex-app-server/Dockerfile b/examples/codex-app-server/Dockerfile new file mode 100644 index 000000000..195666572 --- /dev/null +++ b/examples/codex-app-server/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + nodejs \ + npm \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd \ + --create-home \ + --home-dir /sandbox \ + --shell /bin/bash \ + sandbox + +RUN npm install --global --force "@openai/codex@latest" \ + && npm cache clean --force \ + && codex --version + +COPY --chmod=0755 start-codex-app-server /usr/local/bin/start-codex-app-server + +ENV CODEX_APP_SERVER_PORT=4500 +ENV HOME=/sandbox + +USER sandbox +WORKDIR /sandbox diff --git a/examples/codex-app-server/README.md b/examples/codex-app-server/README.md new file mode 100644 index 000000000..706d94870 --- /dev/null +++ b/examples/codex-app-server/README.md @@ -0,0 +1,112 @@ +# Codex app server in an OpenShell sandbox + +This example builds a sandbox image with Codex, runs its app server inside an +OpenShell sandbox, exposes the WebSocket service through the local gateway, and +connects a Codex client running on the host. + +Use this example only with a local, loopback-bound OpenShell gateway. The app +server does not use its own bearer-token authentication, and the exposed URL is +reachable by other local processes. + +## Prerequisites + +- A running local OpenShell gateway backed by Docker +- `docker`, `openshell`, the latest Codex client, and `jq` on the host +- A host Codex login in `$HOME/.codex/auth.json` + +Run the following commands from the example directory: + +```shell +cd examples/codex-app-server +``` + +## 1. Build the image + +This installs the latest published Codex version in the image. Keep the host +client current as well to avoid app-server protocol incompatibilities. + +```shell +docker build --pull --no-cache --tag openshell/codex-app-server:local --file Dockerfile . +``` + +## 2. Import the provider profile + +The gateway starts with an empty provider-profile catalog. Validate and import +the profile included with this example before creating the provider: + +```shell +openshell provider profile lint --file codex.yaml +``` + +```shell +openshell provider profile import --file codex.yaml +``` + +## 3. Create the provider + +These commands create a provider from the current host login, move the refresh +token into gateway-only refresh material, and rotate the access token once. The +sandbox receives opaque handles for only the access token and account ID; it +never receives the refresh token. Run these commands once per workspace. + +```shell +openshell provider create \ + --name codex \ + --type codex \ + --credential "CODEX_AUTH_ACCESS_TOKEN=$(jq -er '.tokens.access_token' "$HOME/.codex/auth.json")" \ + --credential "CODEX_AUTH_ACCOUNT_ID=$(jq -er '.tokens.account_id' "$HOME/.codex/auth.json")" +``` + +```shell +env "CODEX_AUTH_REFRESH_TOKEN=$(jq -er '.tokens.refresh_token' "$HOME/.codex/auth.json")" \ + openshell provider refresh configure codex \ + --credential-key CODEX_AUTH_ACCESS_TOKEN \ + --strategy oauth2-refresh-token \ + --material client_id=app_EMoamEEZ73f0CkXaXp7hrann \ + --secret-material-env refresh_token=CODEX_AUTH_REFRESH_TOKEN +``` + +```shell +openshell provider refresh rotate codex \ + --credential-key CODEX_AUTH_ACCESS_TOKEN +``` + +## 4. Launch the sandbox + +```shell +openshell sandbox create \ + --name codex-app-server \ + --from openshell/codex-app-server:local \ + --expose 4500 \ + --detach \ + --no-tty \ + --provider codex \ + --output json \ + -- start-codex-app-server +``` + +The create result includes the exposed endpoint: + +```json +{ + "service_urls": { + "": "http://default--codex-app-server.openshell.localhost:/" + } +} +``` + +Convert the returned URL to its WebSocket scheme and connect the local client, +replacing `` with the port from the create result: + +```shell +codex --remote ws://default--codex-app-server.openshell.localhost:/ --no-alt-screen +``` + +## Clean up + +```shell +openshell sandbox delete codex-app-server +openshell provider delete codex +openshell provider profile delete codex +docker image rm openshell/codex-app-server:local +``` diff --git a/examples/codex-app-server/codex.yaml b/examples/codex-app-server/codex.yaml new file mode 100644 index 000000000..53a621dcf --- /dev/null +++ b/examples/codex-app-server/codex.yaml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Provider profile for the Codex app-server example. The gateway owns refresh +# material and injects opaque handles for the access token and account ID. + +id: codex +display_name: Codex +description: OpenAI Codex CLI with gateway-managed token refresh +category: agent +inference_capable: true +credentials: + - name: access_token + description: Codex OAuth access token refreshed by the gateway + env_vars: [CODEX_AUTH_ACCESS_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: https://auth.openai.com/oauth/token + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: client_id + description: Codex OAuth client ID + required: true + - name: refresh_token + description: Codex OAuth refresh token + required: true + secret: true + - name: refresh_token + description: Codex OAuth refresh token for clients that manage their own refresh + env_vars: [CODEX_AUTH_REFRESH_TOKEN] + - name: account_id + description: Codex account identifier + env_vars: [CODEX_AUTH_ACCOUNT_ID] + required: true + - name: id_token + description: Codex OAuth ID token + env_vars: [CODEX_AUTH_ID_TOKEN] +discovery: + credentials: [access_token, refresh_token, account_id, id_token] +endpoints: + - host: api.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: auth.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: chatgpt.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: ab.chatgpt.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: + - /usr/bin/codex + - /usr/local/bin/codex + - /usr/lib/node_modules/@openai/** + - /usr/local/lib/node_modules/@openai/** diff --git a/examples/codex-app-server/start-codex-app-server b/examples/codex-app-server/start-codex-app-server new file mode 100644 index 000000000..00522856e --- /dev/null +++ b/examples/codex-app-server/start-codex-app-server @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); + +const required = [ + "CODEX_AUTH_ACCESS_TOKEN", + "CODEX_AUTH_ACCOUNT_ID", +]; +for (const name of required) { + if (!process.env[name]) { + throw new Error(`missing required provider credential: ${name}`); + } +} + +const b64u = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); +const now = Math.floor(Date.now() / 1000); +const idToken = [ + b64u({ alg: "none", typ: "JWT" }), + b64u({ + iss: "https://auth.openai.com", + aud: "codex", + sub: "openshell-app-server", + email: "app-server@openshell.local", + iat: now, + exp: now + 3600, + }), + "placeholder", +].join("."); + +const codexHome = path.join(process.env.HOME, ".codex"); +fs.mkdirSync(codexHome, { recursive: true }); +fs.writeFileSync(path.join(codexHome, "auth.json"), JSON.stringify({ + // The gateway owns token refresh; Codex must not attempt to refresh these + // externally managed tokens itself. + auth_mode: "chatgptAuthTokens", + OPENAI_API_KEY: null, + tokens: { + // Provider values are opaque stable handles. Codex parses the ID token + // locally, so use a short-lived, non-secret identity placeholder for it. + id_token: idToken, + access_token: process.env.CODEX_AUTH_ACCESS_TOKEN, + refresh_token: "", + account_id: process.env.CODEX_AUTH_ACCOUNT_ID, + }, + last_refresh: new Date().toISOString(), +}, null, 2), { mode: 0o600 }); + +const port = process.env.CODEX_APP_SERVER_PORT || "4500"; +const server = spawn( + "codex", + ["app-server", "--listen", `ws://127.0.0.1:${port}`], + { stdio: "inherit" }, +); +server.on("error", (error) => { + console.error(error); + process.exit(1); +}); +server.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } +}); diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index bf528f6c5..8a4b061b8 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -6,7 +6,7 @@ how an interceptor can vend provider profiles and make them the gateway's authoritative profile source. - provider profile YAML lives in `profiles/*.yaml` -- `provider list-profiles` shows only the profiles vended by this interceptor +- `profile list` shows only the profiles vended by this interceptor - providers can only be created with a `type` that matches one of those vended profile IDs - every vended provider profile gets governance annotations for its hash, @@ -78,7 +78,7 @@ YAML files do not need an `id` field; if one is present, the filename still wins The interceptor advertises `provider_profiles = true` in its manifest and vends the current profile set through `SnapshotProviderProfiles`. The gateway config selects the interceptor as its only provider profile source, so -`provider list-profiles` shows only `github` and `slack`; the user source is +`profile list` shows only `github` and `slack`; the user source is omitted, so imported profiles do not appear beside them. The example signs each profile's canonical protobuf payload and exposes the JWT under `annotations["openshell.nvidia.com/profile-signature"]`; the signed hash and key diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index e4ddcbca8..d91aed79b 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -286,7 +286,7 @@ policy_signature_for_sandbox() { profile_signature_for_profile() { local profile_id="$1" - "${CLI[@]}" provider profile export "$profile_id" -o json \ + "${CLI[@]}" profile export "$profile_id" -o json \ | awk -F'"' '/"openshell.nvidia.com\/profile-signature":/ { print $4; exit }' } @@ -300,7 +300,7 @@ wait_for_profile() { } >>"$SETUP_LOG" for _ in {1..60}; do - if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then + if "${CLI[@]}" profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then printf 'INFO %s\n' "$label" return fi @@ -438,8 +438,8 @@ configure_gateway() { } run_suite() { - expect_output_contains "lists github profile" "github" "${CLI[@]}" provider list-profiles - expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles + expect_output_contains "lists github profile" "github" "${CLI[@]}" profile list + expect_output_contains "lists slack profile" "slack" "${CLI[@]}" profile list # The interceptor is the only configured source, so a profile imported into # the user source stays out of the catalog. cat >"$TMPDIR/unvended-profile.yaml" <<'EOF' @@ -451,10 +451,10 @@ endpoints: port: 443 binaries: [/usr/bin/curl] EOF - "${CLI[@]}" provider profile import -f "$TMPDIR/unvended-profile.yaml" --global >/dev/null 2>&1 || true - expect_output_not_contains "hides profiles the interceptor does not vend" "unvended-api" "${CLI[@]}" provider list-profiles - expect_output_contains "github profile has governance profile signature" "openshell.nvidia.com/profile-signature" "${CLI[@]}" provider profile export github -o json - expect_output_contains "github profile has governance profile hash" "openshell.nvidia.com/profile-hash" "${CLI[@]}" provider profile export github -o json + "${CLI[@]}" profile import -f "$TMPDIR/unvended-profile.yaml" --global >/dev/null 2>&1 || true + expect_output_not_contains "hides profiles the interceptor does not vend" "unvended-api" "${CLI[@]}" profile list + expect_output_contains "github profile has governance profile signature" "openshell.nvidia.com/profile-signature" "${CLI[@]}" profile export github -o json + expect_output_contains "github profile has governance profile hash" "openshell.nvidia.com/profile-hash" "${CLI[@]}" profile export github -o json cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' id: custom-slack @@ -466,8 +466,8 @@ endpoints: [] binaries: [] EOF - expect_failure "denies provider profile delete" "${CLI[@]}" provider profile delete slack - expect_failure "denies disallowed provider profile import" "${CLI[@]}" provider profile import -f "$TMPDIR/disallowed-profile.yaml" + expect_failure "denies provider profile delete" "${CLI[@]}" profile delete slack + expect_failure "denies disallowed provider profile import" "${CLI[@]}" profile import -f "$TMPDIR/disallowed-profile.yaml" run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy run_step "allows slack provider create" "${CLI[@]}" provider create --name slack --type slack --credential SLACK_BOT_TOKEN=dummy @@ -593,7 +593,7 @@ endpoints: enforcement: enforce binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] EOF - wait_for_output_contains "gateway sees github profile reload" "profile-reload.example" "${CLI[@]}" provider profile export github -o yaml + wait_for_output_contains "gateway sees github profile reload" "profile-reload.example" "${CLI[@]}" profile export github -o yaml wait_for_output_contains "effective policy has reloaded github profile" "profile-reload.example" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json local reloaded_github_profile_signature="" { diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 96a9942e3..48116c73c 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -337,6 +337,12 @@ impl GovernanceInterceptorService { ), ], expected_audience: String::new(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::GatewayInterceptor, + "openshell/provider-governance", + openshell_core::VERSION, + [], + )), } } @@ -549,9 +555,17 @@ impl GovernanceInterceptorService { impl GatewayInterceptor for GovernanceInterceptorService { async fn describe( &self, - _request: Request, + request: Request, ) -> Result, Status> { - Ok(Response::new(self.manifest())) + let manifest = self.manifest(); + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::GatewayInterceptor, + "provider-governance", + manifest.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(manifest)) } async fn evaluate( diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index 1df9c2572..640a3e286 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -12,6 +12,27 @@ fn service() -> GovernanceInterceptorService { GovernanceInterceptorService::from_profiles(profiles).unwrap() } +#[tokio::test] +async fn describe_rejects_incompatible_gateway_metadata() { + let service = service(); + let mut gateway = openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::GatewayInterceptor, + ); + gateway.protocol_version.as_mut().unwrap().major = 2; + + let error = GatewayInterceptor::describe( + &service, + Request::new(DescribeRequest { + gateway: Some(gateway), + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("unsupported protocol")); +} + fn evaluation( method: &str, phase: GatewayInterceptorPhase, diff --git a/examples/spiffe-token-exchange-demo/README.md b/examples/spiffe-token-exchange-demo/README.md index 89ddc09e6..12294fd80 100644 --- a/examples/spiffe-token-exchange-demo/README.md +++ b/examples/spiffe-token-exchange-demo/README.md @@ -128,7 +128,7 @@ Then run: ```bash export GATEWAY=https://127.0.0.1:8097 -openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider profile import \ +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" profile import \ -f "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/provider-profile.yaml" openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider create \ diff --git a/examples/spiffe-token-exchange-demo/demo.sh b/examples/spiffe-token-exchange-demo/demo.sh index a123f554a..651688cb4 100755 --- a/examples/spiffe-token-exchange-demo/demo.sh +++ b/examples/spiffe-token-exchange-demo/demo.sh @@ -202,10 +202,10 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true -"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true +"${OS[@]}" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" -run "${OS[@]}" provider profile import -f "$PROFILE_FILE" +run "${OS[@]}" profile lint -f "$PROFILE_FILE" +run "${OS[@]}" profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" diff --git a/examples/spiffe-token-exchange-demo/podman/demo.sh b/examples/spiffe-token-exchange-demo/podman/demo.sh index b12176c30..228cfe910 100755 --- a/examples/spiffe-token-exchange-demo/podman/demo.sh +++ b/examples/spiffe-token-exchange-demo/podman/demo.sh @@ -640,10 +640,10 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true -"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true +"${OS[@]}" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" provider profile lint -f "$RENDERED_PROFILE" -run "${OS[@]}" provider profile import -f "$RENDERED_PROFILE" +run "${OS[@]}" profile lint -f "$RENDERED_PROFILE" +run "${OS[@]}" profile import -f "$RENDERED_PROFILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" diff --git a/examples/spiffe-token-grant-demo/README.md b/examples/spiffe-token-grant-demo/README.md index df4597c66..4e10a1f67 100644 --- a/examples/spiffe-token-grant-demo/README.md +++ b/examples/spiffe-token-grant-demo/README.md @@ -59,7 +59,7 @@ Then run: export XDG_CONFIG_HOME="$(mktemp -d)" export GATEWAY=http://127.0.0.1:8097 -openshell --gateway-endpoint "$GATEWAY" provider profile import \ +openshell --gateway-endpoint "$GATEWAY" profile import \ -f examples/spiffe-token-grant-demo/provider-profile.yaml openshell --gateway-endpoint "$GATEWAY" provider create \ diff --git a/examples/spiffe-token-grant-demo/demo.sh b/examples/spiffe-token-grant-demo/demo.sh index 8013755a7..29df965fe 100755 --- a/examples/spiffe-token-grant-demo/demo.sh +++ b/examples/spiffe-token-grant-demo/demo.sh @@ -109,10 +109,10 @@ wait_for_port_forward "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true -"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true +"${OS[@]}" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" -run "${OS[@]}" provider profile import -f "$PROFILE_FILE" +run "${OS[@]}" profile lint -f "$PROFILE_FILE" +run "${OS[@]}" profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --runtime-credentials run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --no-tty -- echo "sandbox ready" diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index c6489d742..dbf949425 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -17,7 +17,7 @@ use openshell_core::proto::{ Decision, Finding, HttpRequestEvaluation, HttpRequestResult, HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, HttpResponsePreflightInspect, HttpResponsePreflightResult, - HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, + HttpResponseTrailersResult, MiddlewareBinding, MiddlewareDescribeRequest, MiddlewareManifest, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, WebSocketPreflightAction, WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, @@ -230,9 +230,9 @@ impl SupervisorMiddleware for ContentGuard { async fn describe( &self, - _request: Request<()>, + request: Request, ) -> Result, Status> { - Ok(Response::new(MiddlewareManifest { + let manifest = MiddlewareManifest { name: MANIFEST_NAME.into(), service_version: env!("CARGO_PKG_VERSION").into(), bindings: vec![ @@ -256,7 +256,21 @@ impl SupervisorMiddleware for ContentGuard { }, ], expected_audience: String::new(), - })) + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + MANIFEST_NAME, + openshell_core::VERSION, + [], + )), + }; + openshell_core::extension_protocol::validate_gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + MANIFEST_NAME, + manifest.extension.as_ref(), + request.into_inner().gateway, + ) + .map_err(|error| Status::failed_precondition(error.to_string()))?; + Ok(Response::new(manifest)) } async fn validate_config( @@ -678,10 +692,17 @@ mod tests { #[tokio::test] async fn manifest_advertises_request_response_and_websocket_bindings() { - let manifest = SupervisorMiddleware::describe(&ContentGuard, Request::new(())) - .await - .expect("describe") - .into_inner(); + let manifest = SupervisorMiddleware::describe( + &ContentGuard, + Request::new(MiddlewareDescribeRequest { + gateway: Some(openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, + )), + }), + ) + .await + .expect("describe") + .into_inner(); assert_eq!(manifest.bindings.len(), 3); assert_eq!( @@ -800,6 +821,23 @@ mod tests { } } + #[tokio::test] + async fn describe_rejects_missing_gateway_metadata() { + let error = SupervisorMiddleware::describe( + &ContentGuard, + Request::new(MiddlewareDescribeRequest::default()), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!( + error + .message() + .contains("gateway did not provide protocol metadata") + ); + } + #[tokio::test] async fn websocket_stream_redacts_text_messages() { let events = tokio_stream::iter([ diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index c2f438513..36fa4eeb7 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -7,6 +7,7 @@ package openshell.compute.v1; import "google/protobuf/struct.proto"; import "google/protobuf/timestamp.proto"; +import "extension.proto"; import "options.proto"; import "sandbox.proto"; @@ -64,7 +65,10 @@ service ComputeDriver { rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse); } -message GetCapabilitiesRequest {} +message GetCapabilitiesRequest { + // Gateway protocol metadata. Drivers must reject unmet requirements. + openshell.extension.v1.PeerMetadata gateway = 1; +} message GetCapabilitiesResponse { reserved 4, 5; @@ -72,7 +76,7 @@ message GetCapabilitiesResponse { // Human-readable driver name. string driver_name = 1; - // Driver implementation version string. + // Deprecated diagnostic compatibility field. Use extension.implementation_version. string driver_version = 2; // Default sandbox image recommended by the driver. string default_image = 3; @@ -94,6 +98,8 @@ 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; + // Compute extension protocol metadata. Required for protocol negotiation. + openshell.extension.v1.PeerMetadata extension = 12; } message AuthenticateSandboxRequest { diff --git a/proto/credential_driver.proto b/proto/credential_driver.proto index 3ab9c13c5..765099d71 100644 --- a/proto/credential_driver.proto +++ b/proto/credential_driver.proto @@ -7,6 +7,7 @@ package openshell.credentials.v1; import "datamodel.proto"; import "google/protobuf/timestamp.proto"; +import "extension.proto"; // Internal credential-driver contract used by the gateway. // @@ -33,12 +34,15 @@ service CredentialDriver { rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse); } -message GetCredentialDriverCapabilitiesRequest {} +message GetCredentialDriverCapabilitiesRequest { + // Gateway protocol metadata. Drivers must reject unmet requirements. + openshell.extension.v1.PeerMetadata gateway = 1; +} message GetCredentialDriverCapabilitiesResponse { // Human-readable driver name. string driver_name = 1; - // Driver implementation version string. + // Deprecated diagnostic compatibility field. Use extension.implementation_version. string driver_version = 2; // Backend kind, such as "kubernetes-secrets" or "vault". string backend_kind = 3; @@ -46,6 +50,8 @@ message GetCredentialDriverCapabilitiesResponse { bool supports_list = 4; // True when ResolveCredentials may return expiration_time values. bool supports_expires_at = 5; + // Credential-driver protocol metadata. Required for protocol negotiation. + openshell.extension.v1.PeerMetadata extension = 6; } message StoreCredentialRequest { diff --git a/proto/extension.proto b/proto/extension.proto new file mode 100644 index 000000000..7c505f1ae --- /dev/null +++ b/proto/extension.proto @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.extension.v1; + +// Version of an OpenShell extension protocol contract. +// +// The major version changes when peers cannot safely interoperate. Minor +// versions add optional fields or capabilities and remain compatible when both +// peers' required capabilities are satisfied. +message ProtocolVersion { + uint32 major = 1; + uint32 minor = 2; +} + +// Non-secret metadata exchanged by the gateway and an extension before use. +message PeerMetadata { + // Extension-family protocol contract version, distinct from the build. + ProtocolVersion protocol_version = 1; + + // Stable human-readable implementation identity, such as "openshell/docker". + string implementation_name = 2; + + // Implementation or build version used only for diagnostics. + string implementation_version = 3; + + // Optional namespaced capabilities this peer understands. + repeated string supported_capabilities = 4; + + // Capabilities that the opposite peer must advertise. + repeated string required_capabilities = 5; +} diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index 72fec9019..1bd12d9d6 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.gateway_interceptor.v1; import "google/protobuf/struct.proto"; +import "extension.proto"; import "openshell.proto"; // GatewayInterceptor lets an external governance service evaluate gateway @@ -25,7 +26,10 @@ service GatewayInterceptor { rpc Evaluate(InterceptorEvaluation) returns (InterceptorResult); } -message DescribeRequest {} +message DescribeRequest { + // Gateway protocol metadata. Interceptors must reject unmet requirements. + openshell.extension.v1.PeerMetadata gateway = 1; +} message ProviderProfileSnapshotRequest {} @@ -106,6 +110,8 @@ message InterceptorManifest { // an incorrect audience before returning this manifest. Empty skips this // post-authentication consistency check. string expected_audience = 5; + // Gateway-interceptor protocol metadata. Required for negotiation. + openshell.extension.v1.PeerMetadata extension = 6; } message ProviderProfileSnapshot { diff --git a/proto/openshell.proto b/proto/openshell.proto index 50ec3c799..232314e85 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -587,6 +587,41 @@ service OpenShell { }; } + // Internal gateway-to-gateway relay forwarding. + // + // A gateway replica that receives a user request for a sandbox whose + // supervisor session is owned by a different replica opens this stream to the + // owner. The first frame carries PeerRelayInit; subsequent frames carry raw + // bytes in either direction. This RPC is authenticated as a gateway peer, not + // as a user or sandbox supervisor. + rpc PeerRelay(stream PeerRelayFrame) returns (stream PeerRelayFrame) { + option (openshell.options.v1.authorization) = { + auth_mode: "peer" + }; + } + + // Internal gateway-to-owner forwarding for supervisor-bound state. + rpc PeerReportProviderReadiness(ReportProviderReadinessRequest) + returns (ReportProviderReadinessResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "peer" + }; + } + + rpc PeerReportEndpointStatus(ReportEndpointStatusRequest) + returns (ReportEndpointStatusResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "peer" + }; + } + + rpc PeerGetSandboxProviderStatus(GetSandboxProviderStatusRequest) + returns (GetSandboxProviderStatusResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "peer" + }; + } + // Watch a sandbox and stream updates. // // This stream can include: @@ -879,6 +914,34 @@ message GetGatewayInfoResponse { // Compute driver runtimes initialized by this gateway. Current gateways // return exactly one entry. repeated ComputeDriverInfo compute_drivers = 3; + + // Negotiated non-secret metadata for every initialized extension. + repeated NegotiatedExtensionInfo extensions = 4; +} + +enum ExtensionKind { + EXTENSION_KIND_UNSPECIFIED = 0; + EXTENSION_KIND_COMPUTE_DRIVER = 1; + EXTENSION_KIND_CREDENTIAL_DRIVER = 2; + EXTENSION_KIND_GATEWAY_INTERCEPTOR = 3; + EXTENSION_KIND_SUPERVISOR_MIDDLEWARE = 4; +} + +// Public, non-secret snapshot of one successful startup negotiation. +message NegotiatedExtensionInfo { + ExtensionKind kind = 1; + // Gateway/operator-selected registration name. + string configured_name = 2; + // Extension-reported implementation identity. + string implementation_name = 3; + // Extension build version, distinct from the protocol version. + string implementation_version = 4; + uint32 protocol_major = 5; + uint32 protocol_minor = 6; + // Extension-supported optional capabilities, sorted for stable output. + repeated string supported_capabilities = 7; + // Capabilities the extension requires from the gateway. + repeated string required_capabilities = 8; } // Info for one initialized compute driver runtime. @@ -1194,6 +1257,10 @@ message CreateSandboxRequest { // Optional nonzero UUID for durable at-most-once admission. Successful results // can be replayed for 24 hours; see the API errors and retries reference. string request_id = 8; + // HTTP services to expose when the sandbox is created. Endpoints are + // registered after the sandbox has been persisted and route only while the + // sandbox is ready. + repeated SandboxServiceExposure service_exposures = 9; } message CreateSandboxTemplateRequest { @@ -1378,6 +1445,9 @@ message StartSandboxRequest { // Sandbox response. message SandboxResponse { Sandbox sandbox = 1; + // Service URLs created by CreateSandbox, keyed by service name. The empty + // key identifies the unnamed service. Other sandbox RPCs return an empty map. + map service_urls = 2; } // List sandboxes response. @@ -2844,6 +2914,9 @@ message SupervisorHello { string sandbox_id = 1; // Supervisor instance ID (e.g. boot id or process epoch). string instance_id = 2; + // Monotonic counter scoped to instance_id. Incremented for each reconnect so + // gateways can distinguish a fresh supervisor connection from stale cleanup. + uint64 connection_epoch = 3; // The supervisor can report credential, policy, and launch-environment installation. bool supports_provider_readiness = 4; } @@ -2937,6 +3010,25 @@ message RelayFrame { } } +// Initial frame for gateway peer relay forwarding. +message PeerRelayInit { + // Stable sandbox UUID whose supervisor relay should be opened. + string sandbox_id = 1; + // Relay target to ask the owning gateway to open on its local supervisor + // session. The channel_id is assigned by the forwarding gateway. + RelayOpen relay_open = 2; + // Gateway replica id that initiated the peer relay. + string requester_replica_id = 3; +} + +// A single frame on the gateway-to-gateway peer relay RPC. +message PeerRelayFrame { + oneof payload { + PeerRelayInit init = 1; + bytes data = 2; + } +} + // Supervisor reports the result of a relay open request. message RelayOpenResult { // Channel identifier from the RelayOpen request. @@ -3598,3 +3690,11 @@ message SandboxProvisioning { string attachment_change_id = 10; google.protobuf.Timestamp attachment_change_time = 11; } + +// Create-time request to expose one loopback HTTP service in a sandbox. +message SandboxServiceExposure { + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 1; + // Loopback TCP port inside the sandbox. + uint32 target_port = 2; +} diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 642caa1b8..52793d905 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -5,16 +5,16 @@ syntax = "proto3"; package openshell.middleware.v1; -import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; import "google/protobuf/duration.proto"; +import "extension.proto"; // SupervisorMiddleware discovers and configures one operator-run middleware. // It evaluates HTTP requests and WebSocket messages before credentials. // Phase-specific services share the same registration. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. - rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); + rpc Describe(MiddlewareDescribeRequest) returns (MiddlewareManifest); // ValidateConfig checks service-specific configuration for one binding. rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); @@ -34,6 +34,12 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } +message MiddlewareDescribeRequest { + // Gateway or supervisor protocol metadata. Middleware must reject unmet + // requirements before accepting traffic. + openshell.extension.v1.PeerMetadata gateway = 1; +} + // HttpResponsePreReturn evaluates one response for one middleware stage before // OpenShell returns it to the sandbox. service HttpResponsePreReturn { @@ -49,8 +55,8 @@ message MiddlewareManifest { // Human-readable middleware service name used only for diagnostics. This is // not required to match an operator-owned registration name. string name = 1; - // Release version of the middleware service implementation, used for - // diagnostics. + // Deprecated diagnostic compatibility field. Use + // extension.implementation_version. string service_version = 2; // Bindings exposed by this middleware service. repeated MiddlewareBinding bindings = 3; @@ -60,6 +66,8 @@ message MiddlewareManifest { // reject an incorrect audience before returning this manifest. Empty skips // this post-authentication consistency check. string expected_audience = 4; + // Supervisor-middleware protocol metadata. Required for negotiation. + openshell.extension.v1.PeerMetadata extension = 5; } // MiddlewareBinding declares one operation and phase supported by a service. diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 8cfc8cb33..8d0d20f57 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -21,6 +21,7 @@ from .sandbox import ( SandboxStatusRef, SandboxTemplateClient, SandboxWorkloadTemplateProvenanceRef, + ServiceExposure, TlsConfig, WorkspaceClient, WorkspaceRef, @@ -52,6 +53,7 @@ __all__ = [ "SandboxStatusRef", "SandboxTemplateClient", "SandboxWorkloadTemplateProvenanceRef", + "ServiceExposure", "TlsConfig", "WorkspaceClient", "WorkspaceRef", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index ec309d87a..e30403849 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -80,6 +80,18 @@ def _all_workspaces_scope() -> datamodel_pb2.WorkspaceSelector: return datamodel_pb2.WorkspaceSelector(all_workspaces=datamodel_pb2.AllWorkspaces()) +def _service_exposure_messages( + exposures: Sequence[ServiceExposure] | None, +) -> list[openshell_pb2.SandboxServiceExposure]: + return [ + openshell_pb2.SandboxServiceExposure( + service=exposure.service, + target_port=exposure.target_port, + ) + for exposure in exposures or () + ] + + class _ClientCallDetails(_ClientCallDetailsBase, grpc.ClientCallDetails): pass @@ -410,6 +422,14 @@ class SandboxStatusRef: exit_code: int | None = None +@dataclass(frozen=True) +class ServiceExposure: + """A loopback HTTP service to expose during sandbox creation.""" + + target_port: int + service: str = "" + + class _ImmutableLabels(dict[str, str]): """A read-only, copy- and pickle-safe label mapping.""" @@ -451,9 +471,14 @@ class SandboxRef: # immutable mapping remains safe for deepcopy, pickle, and asdict. labels: Mapping[str, str] = field(default_factory=_ImmutableLabels, compare=False) created_from_workload_template: SandboxWorkloadTemplateProvenanceRef | None = None + # Populated by create operations. The empty key identifies the unnamed service. + service_urls: Mapping[str, str] = field( + default_factory=_ImmutableLabels, compare=False + ) def __post_init__(self) -> None: object.__setattr__(self, "labels", _ImmutableLabels(self.labels)) + object.__setattr__(self, "service_urls", _ImmutableLabels(self.service_urls)) @property def phase(self) -> int: @@ -764,6 +789,7 @@ class SandboxClient: spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, + service_exposures: Sequence[ServiceExposure] | None = None, ) -> SandboxRef: request_spec = spec if spec is not None else _default_spec() response = self._stub.CreateSandbox( @@ -772,10 +798,11 @@ class SandboxClient: spec=request_spec, name=name or "", labels=dict(labels) if labels else {}, + service_exposures=_service_exposure_messages(service_exposures), ), timeout=self._timeout, ) - sandbox_ref = _sandbox_ref(response.sandbox) + sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls) if sandbox_ref.id == "": raise SandboxError("CreateSandbox returned empty sandbox id") return sandbox_ref @@ -788,6 +815,7 @@ class SandboxClient: spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, + service_exposures: Sequence[ServiceExposure] | None = None, ) -> SandboxRef: if not workload_template.strip(): raise SandboxError("workload_template is required") @@ -799,10 +827,11 @@ class SandboxClient: name=name or "", labels=dict(labels) if labels else {}, workload_template=workload_template, + service_exposures=_service_exposure_messages(service_exposures), ), timeout=self._timeout, ) - sandbox_ref = _sandbox_ref(response.sandbox) + sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls) if sandbox_ref.id == "": raise SandboxError("CreateSandbox returned empty sandbox id") return sandbox_ref @@ -814,9 +843,17 @@ class SandboxClient: spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, + service_exposures: Sequence[ServiceExposure] | None = None, ) -> SandboxSession: return SandboxSession( - self, self.create(workspace=workspace, spec=spec, name=name, labels=labels) + self, + self.create( + workspace=workspace, + spec=spec, + name=name, + labels=labels, + service_exposures=service_exposures, + ), ) def create_session_from_template( @@ -827,6 +864,7 @@ class SandboxClient: spec: openshell_pb2.SandboxSpec | None = None, name: str | None = None, labels: Mapping[str, str] | None = None, + service_exposures: Sequence[ServiceExposure] | None = None, ) -> SandboxSession: return SandboxSession( self, @@ -836,6 +874,7 @@ class SandboxClient: spec=spec, name=name, labels=labels, + service_exposures=service_exposures, ), ) @@ -1677,7 +1716,10 @@ def _serialize_python_callable( return base64.b64encode(payload).decode("ascii") -def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: +def _sandbox_ref( + sandbox: openshell_pb2.Sandbox, + service_urls: Mapping[str, str] | None = None, +) -> SandboxRef: status = sandbox.status if sandbox.HasField("status") else None provenance = ( SandboxWorkloadTemplateProvenanceRef( @@ -1700,6 +1742,7 @@ def _sandbox_ref(sandbox: openshell_pb2.Sandbox) -> SandboxRef: ), labels=sandbox.metadata.labels if sandbox.metadata else {}, created_from_workload_template=provenance, + service_urls=service_urls or {}, ) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index d06c2c2ce..2980450b7 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -32,6 +32,7 @@ from openshell.sandbox import ( SandboxRef, SandboxStatusRef, SandboxTemplateClient, + ServiceExposure, TlsConfig, _atomic_replace, _BearerAuthInterceptor, @@ -2073,7 +2074,11 @@ class _FakeSandboxStub: request.name or "generated", dict(request.labels), workspace=_request_workspace(request) or "default", - ) + ), + service_urls={ + exposure.service: f"https://{exposure.service}.example.test/" + for exposure in request.service_exposures + }, ) def ListSandboxes( @@ -2205,6 +2210,30 @@ def test_create_forwards_name_and_labels() -> None: assert dict(ref.labels) == {"aiq": "deep-research"} +def test_create_forwards_service_exposures() -> None: + stub = _FakeSandboxStub() + client = _client_with_fake_stub(stub) + + ref = client.create( + workspace="default", + name="app-server", + service_exposures=[ + ServiceExposure(target_port=4500), + ServiceExposure(service="metrics", target_port=9090), + ], + ) + + assert stub.create_request is not None + assert [ + (exposure.service, exposure.target_port) + for exposure in stub.create_request.service_exposures + ] == [("", 4500), ("metrics", 9090)] + assert dict(ref.service_urls) == { + "": "https://.example.test/", + "metrics": "https://metrics.example.test/", + } + + def test_create_from_template_forwards_workload_template() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) diff --git a/scripts/agents/run.sh b/scripts/agents/run.sh index d3792b6c1..75f96565b 100755 --- a/scripts/agents/run.sh +++ b/scripts/agents/run.sh @@ -326,12 +326,12 @@ import_provider_profile() { local profile_file="$2" local import_output current_profile resource_version update_dir update_file - openshell_cmd provider profile delete "$profile_id" >/dev/null 2>&1 || true - if import_output="$(openshell_cmd provider profile import --file "$profile_file" 2>&1)"; then + openshell_cmd profile delete "$profile_id" >/dev/null 2>&1 || true + if import_output="$(openshell_cmd profile import --file "$profile_file" 2>&1)"; then return 0 fi if [[ "$import_output" == *"already exists"* ]]; then - if ! current_profile="$(openshell_cmd provider profile export \ + if ! current_profile="$(openshell_cmd profile export \ --output json "$profile_id")"; then echo "failed to export existing provider profile: $profile_id" >&2 return 1 @@ -351,7 +351,7 @@ profile = YAML.load_file(profile_file) || {} profile["resource_version"] = Integer(resource_version, 10) File.write(update_file, YAML.dump(profile)) RUBY - if openshell_cmd provider profile update "$profile_id" \ + if openshell_cmd profile update "$profile_id" \ --file "$update_file" >/dev/null; then rm -f "$update_file" rmdir "$update_dir" diff --git a/scripts/update_license_headers.py b/scripts/update_license_headers.py index 49a76f4f3..43746f249 100755 --- a/scripts/update_license_headers.py +++ b/scripts/update_license_headers.py @@ -60,9 +60,10 @@ FILE_COMMENT_STYLES: dict[str, str] = { "scripts/bin/openshell": "#", } -# Strict JSON does not support comments. Keep SPDX data in REUSE-compatible -# sidecars so these files remain valid inputs to their consumers. +# Some consumer formats do not support comments. Keep SPDX data in +# REUSE-compatible sidecars so these files remain valid inputs. SIDECAR_LICENSE_FILES: set[str] = { + "deploy/deb/control.in", "scripts/keycloak-realm.json", "sdk/conformance/oauth-client-credentials.json", "sdk/typescript/biome.json", diff --git a/sdk/go/docs/src/api/sandboxes.md b/sdk/go/docs/src/api/sandboxes.md index f9504227c..6981adf65 100644 --- a/sdk/go/docs/src/api/sandboxes.md +++ b/sdk/go/docs/src/api/sandboxes.md @@ -17,9 +17,18 @@ sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSp Providers: []string{"openai"}, }, map[string]string{ "team": "platform", -}) +}, v1.CreateOptions{ServiceExposures: []v1.ServiceExposure{ + {TargetPort: 8080}, +}}) +fmt.Println(sb.ServiceURLs[""]) ``` +Create-time service exposures register loopback HTTP endpoints with the +sandbox. Leave `Service` empty for the unnamed endpoint or set it to create a +named endpoint. Routing begins when the sandbox is ready. +`ServiceURLs` returns the routed URLs keyed by service name; the empty key is +the unnamed endpoint. + Set `GPU: true` to request the active driver's default GPU assignment. Set `GPUCount` when the sandbox needs a specific GPU count; a non-nil `GPUCount` also implies `GPU`. diff --git a/sdk/go/openshell/v1/fake/health.go b/sdk/go/openshell/v1/fake/health.go index aa581ecb9..304107a50 100644 --- a/sdk/go/openshell/v1/fake/health.go +++ b/sdk/go/openshell/v1/fake/health.go @@ -89,6 +89,14 @@ func copyGatewayInfo(info *types.GatewayInfo) *types.GatewayInfo { cp.ComputeDrivers = make([]types.ComputeDriverInfo, len(info.ComputeDrivers)) copy(cp.ComputeDrivers, info.ComputeDrivers) } + if info.Extensions != nil { + cp.Extensions = make([]types.ExtensionInfo, len(info.Extensions)) + for index, extension := range info.Extensions { + cp.Extensions[index] = extension + cp.Extensions[index].SupportedCapabilities = copyStringSlice(extension.SupportedCapabilities) + cp.Extensions[index].RequiredCapabilities = copyStringSlice(extension.RequiredCapabilities) + } + } return &cp } diff --git a/sdk/go/openshell/v1/fake/health_test.go b/sdk/go/openshell/v1/fake/health_test.go index 8a5ddeba7..63656ba3f 100644 --- a/sdk/go/openshell/v1/fake/health_test.go +++ b/sdk/go/openshell/v1/fake/health_test.go @@ -84,13 +84,21 @@ func TestHealth_GetGatewayInfo_DeepCopy(t *testing.T) { ComputeDrivers: []types.ComputeDriverInfo{ {Name: "k8s"}, }, + Extensions: []types.ExtensionInfo{{ + ConfiguredName: "k8s", + SupportedCapabilities: []string{"openshell.compute.contract"}, + }}, })) info1, _ := fc.Health().GetGatewayInfo(context.Background()) info1.ComputeDrivers[0].Name = "mutated" + info1.Extensions[0].ConfiguredName = "mutated" + info1.Extensions[0].SupportedCapabilities[0] = "mutated" info2, _ := fc.Health().GetGatewayInfo(context.Background()) assert.Equal(t, "k8s", info2.ComputeDrivers[0].Name) + assert.Equal(t, "k8s", info2.Extensions[0].ConfiguredName) + assert.Equal(t, "openshell.compute.contract", info2.Extensions[0].SupportedCapabilities[0]) } func TestHealth_GetCurrentUser_Default(t *testing.T) { diff --git a/sdk/go/openshell/v1/health.go b/sdk/go/openshell/v1/health.go index 4230ca6cb..ac0533746 100644 --- a/sdk/go/openshell/v1/health.go +++ b/sdk/go/openshell/v1/health.go @@ -18,6 +18,21 @@ type GatewayInfo = types.GatewayInfo // ComputeDriverInfo describes a compute backend available on the gateway. type ComputeDriverInfo = types.ComputeDriverInfo +// ExtensionInfo describes one successful gateway/extension negotiation. +type ExtensionInfo = types.ExtensionInfo + +// ExtensionKind identifies one supported extension family. +type ExtensionKind = types.ExtensionKind + +// ExtensionKind constants re-exported from the types package. +const ( + ExtensionKindComputeDriver = types.ExtensionKindComputeDriver + ExtensionKindCredentialDriver = types.ExtensionKindCredentialDriver + ExtensionKindGatewayInterceptor = types.ExtensionKindGatewayInterceptor + ExtensionKindSupervisorMiddleware = types.ExtensionKindSupervisorMiddleware + ExtensionKindUnknown = types.ExtensionKindUnknown +) + // ServiceStatus describes the health state of the gateway. type ServiceStatus = types.ServiceStatus diff --git a/sdk/go/openshell/v1/health_client_test.go b/sdk/go/openshell/v1/health_client_test.go index 688639120..dc82fe1e4 100644 --- a/sdk/go/openshell/v1/health_client_test.go +++ b/sdk/go/openshell/v1/health_client_test.go @@ -31,6 +31,14 @@ type mockHealthServer struct { currentUserErr error } +func TestExtensionKindConstantsAreReexported(t *testing.T) { + assert.Equal(t, ExtensionKind("ComputeDriver"), ExtensionKindComputeDriver) + assert.Equal(t, ExtensionKind("CredentialDriver"), ExtensionKindCredentialDriver) + assert.Equal(t, ExtensionKind("GatewayInterceptor"), ExtensionKindGatewayInterceptor) + assert.Equal(t, ExtensionKind("SupervisorMiddleware"), ExtensionKindSupervisorMiddleware) + assert.Equal(t, ExtensionKind("Unknown"), ExtensionKindUnknown) +} + func (s *mockHealthServer) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { if s.err != nil { return nil, s.err diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go index 63ab8c329..8e1c2b98f 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -18,11 +18,46 @@ func GatewayInfoFromProto(resp *pb.GetGatewayInfoResponse) *types.GatewayInfo { for _, d := range resp.GetComputeDrivers() { drivers = append(drivers, ComputeDriverInfoFromProto(d)) } + extensions := make([]types.ExtensionInfo, 0, len(resp.GetExtensions())) + for _, extension := range resp.GetExtensions() { + extensions = append(extensions, ExtensionInfoFromProto(extension)) + } return &types.GatewayInfo{ Status: ServiceStatusFromProto(resp.GetStatus()), Version: resp.GetGatewayVersion(), ComputeDrivers: drivers, + Extensions: extensions, + } +} + +// ExtensionInfoFromProto converts a negotiated extension snapshot. +func ExtensionInfoFromProto(extension *pb.NegotiatedExtensionInfo) types.ExtensionInfo { + return types.ExtensionInfo{ + Kind: ExtensionKindFromProto(extension.GetKind()), + ConfiguredName: extension.GetConfiguredName(), + ImplementationName: extension.GetImplementationName(), + ImplementationVersion: extension.GetImplementationVersion(), + ProtocolMajor: extension.GetProtocolMajor(), + ProtocolMinor: extension.GetProtocolMinor(), + SupportedCapabilities: CopyStringSlice(extension.GetSupportedCapabilities()), + RequiredCapabilities: CopyStringSlice(extension.GetRequiredCapabilities()), + } +} + +// ExtensionKindFromProto converts the public extension family enum. +func ExtensionKindFromProto(kind pb.ExtensionKind) types.ExtensionKind { + switch kind { + case pb.ExtensionKind_EXTENSION_KIND_COMPUTE_DRIVER: + return types.ExtensionKindComputeDriver + case pb.ExtensionKind_EXTENSION_KIND_CREDENTIAL_DRIVER: + return types.ExtensionKindCredentialDriver + case pb.ExtensionKind_EXTENSION_KIND_GATEWAY_INTERCEPTOR: + return types.ExtensionKindGatewayInterceptor + case pb.ExtensionKind_EXTENSION_KIND_SUPERVISOR_MIDDLEWARE: + return types.ExtensionKindSupervisorMiddleware + default: + return types.ExtensionKindUnknown } } diff --git a/sdk/go/openshell/v1/internal/converter/health_test.go b/sdk/go/openshell/v1/internal/converter/health_test.go index d0360a9b6..ab8e0c241 100644 --- a/sdk/go/openshell/v1/internal/converter/health_test.go +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -32,6 +32,17 @@ func TestGatewayInfoFromProto(t *testing.T) { }, }, }, + Extensions: []*pb.NegotiatedExtensionInfo{ + { + Kind: pb.ExtensionKind_EXTENSION_KIND_COMPUTE_DRIVER, + ConfiguredName: "k8s", + ImplementationName: "openshell/kubernetes", + ImplementationVersion: "1.5.0", + ProtocolMajor: 1, + SupportedCapabilities: []string{"openshell.compute.contract"}, + RequiredCapabilities: []string{"openshell.compute.contract"}, + }, + }, } info := GatewayInfoFromProto(proto) @@ -45,6 +56,10 @@ func TestGatewayInfoFromProto(t *testing.T) { assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) assert.Equal(t, "docker", info.ComputeDrivers[1].Name) assert.Equal(t, "docker-engine", info.ComputeDrivers[1].DriverName) + require.Len(t, info.Extensions, 1) + assert.Equal(t, v1.ExtensionKindComputeDriver, info.Extensions[0].Kind) + assert.Equal(t, "openshell/kubernetes", info.Extensions[0].ImplementationName) + assert.Equal(t, uint32(1), info.Extensions[0].ProtocolMajor) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -72,12 +87,20 @@ func TestGatewayInfoFromProto_DeepCopy(t *testing.T) { ComputeDrivers: []*pb.ComputeDriverInfo{ {Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{DriverName: "kubernetes"}}, }, + Extensions: []*pb.NegotiatedExtensionInfo{{ + ConfiguredName: "k8s", + SupportedCapabilities: []string{"openshell.compute.contract"}, + }}, } info := GatewayInfoFromProto(proto) proto.ComputeDrivers[0].Name = "mutated" + proto.Extensions[0].ConfiguredName = "mutated" + proto.Extensions[0].SupportedCapabilities[0] = "mutated" assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) + assert.Equal(t, "k8s", info.Extensions[0].ConfiguredName) + assert.Equal(t, "openshell.compute.contract", info.Extensions[0].SupportedCapabilities[0]) } func TestServiceStatusFromProto(t *testing.T) { diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index 8fce5a0df..2d22fd62f 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -41,12 +41,17 @@ func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec } if len(opts) > 0 { req.Annotations = converter.CopyStringMap(opts[0].Annotations) + req.ServiceExposures = serviceExposuresToProto(opts[0].ServiceExposures) } resp, err := s.client.CreateSandbox(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } - return converter.SandboxFromProto(resp.GetSandbox()), nil + sandbox := converter.SandboxFromProto(resp.GetSandbox()) + if sandbox != nil { + sandbox.ServiceURLs = converter.CopyStringMap(resp.GetServiceUrls()) + } + return sandbox, nil } func (s *sandboxClient) CreateFromTemplate(ctx context.Context, workspace, name, templateName string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) { @@ -69,12 +74,28 @@ func (s *sandboxClient) CreateFromTemplate(ctx context.Context, workspace, name, } if len(opts) > 0 { req.Annotations = converter.CopyStringMap(opts[0].Annotations) + req.ServiceExposures = serviceExposuresToProto(opts[0].ServiceExposures) } resp, err := s.client.CreateSandbox(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } - return converter.SandboxFromProto(resp.GetSandbox()), nil + sandbox := converter.SandboxFromProto(resp.GetSandbox()) + if sandbox != nil { + sandbox.ServiceURLs = converter.CopyStringMap(resp.GetServiceUrls()) + } + return sandbox, nil +} + +func serviceExposuresToProto(exposures []types.ServiceExposure) []*pb.SandboxServiceExposure { + result := make([]*pb.SandboxServiceExposure, 0, len(exposures)) + for _, exposure := range exposures { + result = append(result, &pb.SandboxServiceExposure{ + Service: exposure.Service, + TargetPort: exposure.TargetPort, + }) + } + return result } func validateTemplateCreateSpec(spec *SandboxSpec) error { diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index b9a63ceec..c7c2421e0 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -78,7 +78,11 @@ func (s *mockSandboxServer) CreateSandbox(_ context.Context, req *pb.CreateSandb Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, } s.sandboxes[req.GetName()] = sb - return &pb.SandboxResponse{Sandbox: sb}, nil + serviceURLs := make(map[string]string, len(req.GetServiceExposures())) + for _, exposure := range req.GetServiceExposures() { + serviceURLs[exposure.GetService()] = "https://" + exposure.GetService() + ".example.test/" + } + return &pb.SandboxResponse{Sandbox: sb, ServiceUrls: serviceURLs}, nil } func (s *mockSandboxServer) GetSandbox(_ context.Context, req *pb.GetSandboxRequest) (*pb.SandboxResponse, error) { @@ -287,7 +291,17 @@ func TestSandboxCreate(t *testing.T) { } labels := map[string]string{"env": "dev"} - result, err := client.Create(context.Background(), "default", "my-sandbox", spec, labels) + result, err := client.Create( + context.Background(), + "default", + "my-sandbox", + spec, + labels, + CreateOptions{ServiceExposures: []ServiceExposure{ + {TargetPort: 4500}, + {Service: "metrics", TargetPort: 9090}, + }}, + ) require.NoError(t, err) require.NotNil(t, result) @@ -295,6 +309,13 @@ func TestSandboxCreate(t *testing.T) { assert.Equal(t, "sb-my-sandbox", result.ID) assert.Equal(t, map[string]string{"env": "dev"}, result.Labels) assert.Equal(t, SandboxProvisioning, result.Status.Phase) + assert.Equal(t, map[string]string{ + "": "https://.example.test/", + "metrics": "https://metrics.example.test/", + }, result.ServiceURLs) + require.Len(t, mock.createRequest.GetServiceExposures(), 2) + assert.Equal(t, uint32(4500), mock.createRequest.GetServiceExposures()[0].GetTargetPort()) + assert.Equal(t, "metrics", mock.createRequest.GetServiceExposures()[1].GetService()) } func TestSandboxCreate_DefaultGPURequest(t *testing.T) { diff --git a/sdk/go/openshell/v1/service.go b/sdk/go/openshell/v1/service.go index 3d53069af..3ef3bea2f 100644 --- a/sdk/go/openshell/v1/service.go +++ b/sdk/go/openshell/v1/service.go @@ -12,6 +12,9 @@ import ( // ServiceEndpoint represents an exposed HTTP service endpoint within a sandbox. type ServiceEndpoint = types.ServiceEndpoint +// ServiceExposure describes a loopback HTTP service to expose during sandbox creation. +type ServiceExposure = types.ServiceExposure + // ServiceInterface defines operations for managing sandbox service endpoints. type ServiceInterface interface { Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 1db3ec387..3039f1c80 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -25,6 +25,31 @@ type GatewayInfo struct { Status ServiceStatus Version string ComputeDrivers []ComputeDriverInfo + Extensions []ExtensionInfo +} + +// ExtensionKind identifies one supported extension family. +type ExtensionKind string + +// Extension kind constants. +const ( + ExtensionKindComputeDriver ExtensionKind = "ComputeDriver" + ExtensionKindCredentialDriver ExtensionKind = "CredentialDriver" + ExtensionKindGatewayInterceptor ExtensionKind = "GatewayInterceptor" + ExtensionKindSupervisorMiddleware ExtensionKind = "SupervisorMiddleware" + ExtensionKindUnknown ExtensionKind = "Unknown" +) + +// ExtensionInfo describes one successful gateway/extension negotiation. +type ExtensionInfo struct { + Kind ExtensionKind + ConfiguredName string + ImplementationName string + ImplementationVersion string + ProtocolMajor uint32 + ProtocolMinor uint32 + SupportedCapabilities []string + RequiredCapabilities []string } // ComputeDriverInfo describes a compute backend available on the gateway. diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go index def5ca0ff..d514b8d12 100644 --- a/sdk/go/openshell/v1/types/options.go +++ b/sdk/go/openshell/v1/types/options.go @@ -8,6 +8,8 @@ import "time" // CreateOptions configures resource creation. type CreateOptions struct { Annotations map[string]string + // ServiceExposures are loopback HTTP services registered with the sandbox. + ServiceExposures []ServiceExposure } // ListOptions configures resource listing with pagination and filtering. diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index 4b945a4ec..1218bb622 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -18,6 +18,9 @@ type Sandbox struct { CreatedFromWorkloadTemplate *SandboxWorkloadTemplateProvenance Spec SandboxSpec Status SandboxStatus + // ServiceURLs is populated by create operations and keyed by service name. + // The empty key identifies the unnamed service. + ServiceURLs map[string]string } // SandboxSpec holds the desired state of a sandbox. diff --git a/sdk/go/openshell/v1/types/service.go b/sdk/go/openshell/v1/types/service.go index 37b6ddf2f..9cccff825 100644 --- a/sdk/go/openshell/v1/types/service.go +++ b/sdk/go/openshell/v1/types/service.go @@ -3,6 +3,12 @@ package types +// ServiceExposure describes a loopback HTTP service to expose during sandbox creation. +type ServiceExposure struct { + Service string + TargetPort uint32 +} + // ServiceEndpoint represents an exposed HTTP service on a sandbox. type ServiceEndpoint struct { ID string diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index aab21beff..cc6fc19a1 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -30,6 +30,61 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type ExtensionKind int32 + +const ( + ExtensionKind_EXTENSION_KIND_UNSPECIFIED ExtensionKind = 0 + ExtensionKind_EXTENSION_KIND_COMPUTE_DRIVER ExtensionKind = 1 + ExtensionKind_EXTENSION_KIND_CREDENTIAL_DRIVER ExtensionKind = 2 + ExtensionKind_EXTENSION_KIND_GATEWAY_INTERCEPTOR ExtensionKind = 3 + ExtensionKind_EXTENSION_KIND_SUPERVISOR_MIDDLEWARE ExtensionKind = 4 +) + +// Enum value maps for ExtensionKind. +var ( + ExtensionKind_name = map[int32]string{ + 0: "EXTENSION_KIND_UNSPECIFIED", + 1: "EXTENSION_KIND_COMPUTE_DRIVER", + 2: "EXTENSION_KIND_CREDENTIAL_DRIVER", + 3: "EXTENSION_KIND_GATEWAY_INTERCEPTOR", + 4: "EXTENSION_KIND_SUPERVISOR_MIDDLEWARE", + } + ExtensionKind_value = map[string]int32{ + "EXTENSION_KIND_UNSPECIFIED": 0, + "EXTENSION_KIND_COMPUTE_DRIVER": 1, + "EXTENSION_KIND_CREDENTIAL_DRIVER": 2, + "EXTENSION_KIND_GATEWAY_INTERCEPTOR": 3, + "EXTENSION_KIND_SUPERVISOR_MIDDLEWARE": 4, + } +) + +func (x ExtensionKind) Enum() *ExtensionKind { + p := new(ExtensionKind) + *p = x + return p +} + +func (x ExtensionKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExtensionKind) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[0].Descriptor() +} + +func (ExtensionKind) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[0] +} + +func (x ExtensionKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExtensionKind.Descriptor instead. +func (ExtensionKind) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{0} +} + // High-level sandbox lifecycle phase derived by the gateway. // // Clients should rely on this normalized lifecycle summary for readiness and @@ -89,11 +144,11 @@ func (x SandboxPhase) String() string { } func (SandboxPhase) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[0].Descriptor() + return file_openshell_proto_enumTypes[1].Descriptor() } func (SandboxPhase) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[0] + return &file_openshell_proto_enumTypes[1] } func (x SandboxPhase) Number() protoreflect.EnumNumber { @@ -102,7 +157,7 @@ func (x SandboxPhase) Number() protoreflect.EnumNumber { // Deprecated: Use SandboxPhase.Descriptor instead. func (SandboxPhase) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{0} + return file_openshell_proto_rawDescGZIP(), []int{1} } // Operation whose installed authority is tracked by a receipt. @@ -146,11 +201,11 @@ func (x ProviderMutationKind) String() string { } func (ProviderMutationKind) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[1].Descriptor() + return file_openshell_proto_enumTypes[2].Descriptor() } func (ProviderMutationKind) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[1] + return &file_openshell_proto_enumTypes[2] } func (x ProviderMutationKind) Number() protoreflect.EnumNumber { @@ -159,7 +214,7 @@ func (x ProviderMutationKind) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderMutationKind.Descriptor instead. func (ProviderMutationKind) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{1} + return file_openshell_proto_rawDescGZIP(), []int{2} } // Readiness states describe persisted intent separately from installed state. @@ -211,11 +266,11 @@ func (x ProviderReadinessState) String() string { } func (ProviderReadinessState) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[2].Descriptor() + return file_openshell_proto_enumTypes[3].Descriptor() } func (ProviderReadinessState) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[2] + return &file_openshell_proto_enumTypes[3] } func (x ProviderReadinessState) Number() protoreflect.EnumNumber { @@ -224,7 +279,7 @@ func (x ProviderReadinessState) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderReadinessState.Descriptor instead. func (ProviderReadinessState) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{2} + return file_openshell_proto_rawDescGZIP(), []int{3} } // Closed reason categories are safe to display. Raw installation errors are @@ -301,11 +356,11 @@ func (x ProviderReadinessReason) String() string { } func (ProviderReadinessReason) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[3].Descriptor() + return file_openshell_proto_enumTypes[4].Descriptor() } func (ProviderReadinessReason) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[3] + return &file_openshell_proto_enumTypes[4] } func (x ProviderReadinessReason) Number() protoreflect.EnumNumber { @@ -314,7 +369,7 @@ func (x ProviderReadinessReason) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderReadinessReason.Descriptor instead. func (ProviderReadinessReason) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{3} + return file_openshell_proto_rawDescGZIP(), []int{4} } // Component whose desired state is tracked by a durable update operation. @@ -351,11 +406,11 @@ func (x ConfigComponent) String() string { } func (ConfigComponent) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[4].Descriptor() + return file_openshell_proto_enumTypes[5].Descriptor() } func (ConfigComponent) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[4] + return &file_openshell_proto_enumTypes[5] } func (x ConfigComponent) Number() protoreflect.EnumNumber { @@ -364,7 +419,7 @@ func (x ConfigComponent) Number() protoreflect.EnumNumber { // Deprecated: Use ConfigComponent.Descriptor instead. func (ConfigComponent) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{4} + return file_openshell_proto_rawDescGZIP(), []int{5} } // Result of applying a component revision at its owning runtime boundary. @@ -419,11 +474,11 @@ func (x ConfigApplyOutcome) String() string { } func (ConfigApplyOutcome) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[5].Descriptor() + return file_openshell_proto_enumTypes[6].Descriptor() } func (ConfigApplyOutcome) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[5] + return &file_openshell_proto_enumTypes[6] } func (x ConfigApplyOutcome) Number() protoreflect.EnumNumber { @@ -432,7 +487,7 @@ func (x ConfigApplyOutcome) Number() protoreflect.EnumNumber { // Deprecated: Use ConfigApplyOutcome.Descriptor instead. func (ConfigApplyOutcome) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{5} + return file_openshell_proto_rawDescGZIP(), []int{6} } // Durable lifecycle of one desired-state update operation. @@ -481,11 +536,11 @@ func (x ConfigUpdateOperationState) String() string { } func (ConfigUpdateOperationState) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[6].Descriptor() + return file_openshell_proto_enumTypes[7].Descriptor() } func (ConfigUpdateOperationState) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[6] + return &file_openshell_proto_enumTypes[7] } func (x ConfigUpdateOperationState) Number() protoreflect.EnumNumber { @@ -494,7 +549,7 @@ func (x ConfigUpdateOperationState) Number() protoreflect.EnumNumber { // Deprecated: Use ConfigUpdateOperationState.Descriptor instead. func (ConfigUpdateOperationState) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{6} + return file_openshell_proto_rawDescGZIP(), []int{7} } // Provider credential token grant configuration. @@ -532,11 +587,11 @@ func (x ProviderCredentialTokenGrantType) String() string { } func (ProviderCredentialTokenGrantType) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[7].Descriptor() + return file_openshell_proto_enumTypes[8].Descriptor() } func (ProviderCredentialTokenGrantType) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[7] + return &file_openshell_proto_enumTypes[8] } func (x ProviderCredentialTokenGrantType) Number() protoreflect.EnumNumber { @@ -545,7 +600,7 @@ func (x ProviderCredentialTokenGrantType) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderCredentialTokenGrantType.Descriptor instead. func (ProviderCredentialTokenGrantType) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{7} + return file_openshell_proto_rawDescGZIP(), []int{8} } type ProviderCredentialRefreshStrategy int32 @@ -593,11 +648,11 @@ func (x ProviderCredentialRefreshStrategy) String() string { } func (ProviderCredentialRefreshStrategy) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[8].Descriptor() + return file_openshell_proto_enumTypes[9].Descriptor() } func (ProviderCredentialRefreshStrategy) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[8] + return &file_openshell_proto_enumTypes[9] } func (x ProviderCredentialRefreshStrategy) Number() protoreflect.EnumNumber { @@ -606,7 +661,7 @@ func (x ProviderCredentialRefreshStrategy) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderCredentialRefreshStrategy.Descriptor instead. func (ProviderCredentialRefreshStrategy) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{8} + return file_openshell_proto_rawDescGZIP(), []int{9} } // Stable provider profile categories used by clients for grouping and filtering. @@ -658,11 +713,11 @@ func (x ProviderProfileCategory) String() string { } func (ProviderProfileCategory) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[9].Descriptor() + return file_openshell_proto_enumTypes[10].Descriptor() } func (ProviderProfileCategory) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[9] + return &file_openshell_proto_enumTypes[10] } func (x ProviderProfileCategory) Number() protoreflect.EnumNumber { @@ -671,7 +726,7 @@ func (x ProviderProfileCategory) Number() protoreflect.EnumNumber { // Deprecated: Use ProviderProfileCategory.Descriptor instead. func (ProviderProfileCategory) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{9} + return file_openshell_proto_rawDescGZIP(), []int{10} } type ConfigurationAdmissionState int32 @@ -710,11 +765,11 @@ func (x ConfigurationAdmissionState) String() string { } func (ConfigurationAdmissionState) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[10].Descriptor() + return file_openshell_proto_enumTypes[11].Descriptor() } func (ConfigurationAdmissionState) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[10] + return &file_openshell_proto_enumTypes[11] } func (x ConfigurationAdmissionState) Number() protoreflect.EnumNumber { @@ -723,7 +778,7 @@ func (x ConfigurationAdmissionState) Number() protoreflect.EnumNumber { // Deprecated: Use ConfigurationAdmissionState.Descriptor instead. func (ConfigurationAdmissionState) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{10} + return file_openshell_proto_rawDescGZIP(), []int{11} } // Policy load status. @@ -772,11 +827,11 @@ func (x PolicyStatus) String() string { } func (PolicyStatus) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[11].Descriptor() + return file_openshell_proto_enumTypes[12].Descriptor() } func (PolicyStatus) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[11] + return &file_openshell_proto_enumTypes[12] } func (x PolicyStatus) Number() protoreflect.EnumNumber { @@ -785,7 +840,7 @@ func (x PolicyStatus) Number() protoreflect.EnumNumber { // Deprecated: Use PolicyStatus.Descriptor instead. func (PolicyStatus) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{11} + return file_openshell_proto_rawDescGZIP(), []int{12} } // Service status enum. @@ -825,11 +880,11 @@ func (x ServiceStatus) String() string { } func (ServiceStatus) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[12].Descriptor() + return file_openshell_proto_enumTypes[13].Descriptor() } func (ServiceStatus) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[12] + return &file_openshell_proto_enumTypes[13] } func (x ServiceStatus) Number() protoreflect.EnumNumber { @@ -838,7 +893,7 @@ func (x ServiceStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ServiceStatus.Descriptor instead. func (ServiceStatus) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{12} + return file_openshell_proto_rawDescGZIP(), []int{13} } // Workspace-scoped role for members. @@ -875,11 +930,11 @@ func (x WorkspaceRole) String() string { } func (WorkspaceRole) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[13].Descriptor() + return file_openshell_proto_enumTypes[14].Descriptor() } func (WorkspaceRole) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[13] + return &file_openshell_proto_enumTypes[14] } func (x WorkspaceRole) Number() protoreflect.EnumNumber { @@ -888,7 +943,7 @@ func (x WorkspaceRole) Number() protoreflect.EnumNumber { // Deprecated: Use WorkspaceRole.Descriptor instead. func (WorkspaceRole) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{13} + return file_openshell_proto_rawDescGZIP(), []int{14} } // Stable recovery action for the most recent provider credential refresh @@ -934,11 +989,11 @@ func (x ProviderCredentialRefreshRecoveryAction) String() string { } func (ProviderCredentialRefreshRecoveryAction) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[14].Descriptor() + return file_openshell_proto_enumTypes[15].Descriptor() } func (ProviderCredentialRefreshRecoveryAction) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[14] + return &file_openshell_proto_enumTypes[15] } func (x ProviderCredentialRefreshRecoveryAction) Number() protoreflect.EnumNumber { @@ -947,7 +1002,7 @@ func (x ProviderCredentialRefreshRecoveryAction) Number() protoreflect.EnumNumbe // Deprecated: Use ProviderCredentialRefreshRecoveryAction.Descriptor instead. func (ProviderCredentialRefreshRecoveryAction) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{14} + return file_openshell_proto_rawDescGZIP(), []int{15} } // Result of a public delete, membership removal, or session revocation. @@ -997,11 +1052,11 @@ func (x DeletionOutcome) String() string { } func (DeletionOutcome) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[15].Descriptor() + return file_openshell_proto_enumTypes[16].Descriptor() } func (DeletionOutcome) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[15] + return &file_openshell_proto_enumTypes[16] } func (x DeletionOutcome) Number() protoreflect.EnumNumber { @@ -1010,7 +1065,7 @@ func (x DeletionOutcome) Number() protoreflect.EnumNumber { // Deprecated: Use DeletionOutcome.Descriptor instead. func (DeletionOutcome) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{15} + return file_openshell_proto_rawDescGZIP(), []int{16} } // Last observed network result for a configured external tool endpoint. @@ -1071,11 +1126,11 @@ func (x EndpointResult) String() string { } func (EndpointResult) Descriptor() protoreflect.EnumDescriptor { - return file_openshell_proto_enumTypes[16].Descriptor() + return file_openshell_proto_enumTypes[17].Descriptor() } func (EndpointResult) Type() protoreflect.EnumType { - return &file_openshell_proto_enumTypes[16] + return &file_openshell_proto_enumTypes[17] } func (x EndpointResult) Number() protoreflect.EnumNumber { @@ -1084,7 +1139,7 @@ func (x EndpointResult) Number() protoreflect.EnumNumber { // Deprecated: Use EndpointResult.Descriptor instead. func (EndpointResult) EnumDescriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{16} + return file_openshell_proto_rawDescGZIP(), []int{17} } // IssueSandboxToken request. Empty body; identity is established by the @@ -1594,8 +1649,10 @@ type GetGatewayInfoResponse struct { // Compute driver runtimes initialized by this gateway. Current gateways // return exactly one entry. ComputeDrivers []*ComputeDriverInfo `protobuf:"bytes,3,rep,name=compute_drivers,json=computeDrivers,proto3" json:"compute_drivers,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Negotiated non-secret metadata for every initialized extension. + Extensions []*NegotiatedExtensionInfo `protobuf:"bytes,4,rep,name=extensions,proto3" json:"extensions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetGatewayInfoResponse) Reset() { @@ -1649,6 +1706,119 @@ func (x *GetGatewayInfoResponse) GetComputeDrivers() []*ComputeDriverInfo { return nil } +func (x *GetGatewayInfoResponse) GetExtensions() []*NegotiatedExtensionInfo { + if x != nil { + return x.Extensions + } + return nil +} + +// Public, non-secret snapshot of one successful startup negotiation. +type NegotiatedExtensionInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind ExtensionKind `protobuf:"varint,1,opt,name=kind,proto3,enum=openshell.v1.ExtensionKind" json:"kind,omitempty"` + // Gateway/operator-selected registration name. + ConfiguredName string `protobuf:"bytes,2,opt,name=configured_name,json=configuredName,proto3" json:"configured_name,omitempty"` + // Extension-reported implementation identity. + ImplementationName string `protobuf:"bytes,3,opt,name=implementation_name,json=implementationName,proto3" json:"implementation_name,omitempty"` + // Extension build version, distinct from the protocol version. + ImplementationVersion string `protobuf:"bytes,4,opt,name=implementation_version,json=implementationVersion,proto3" json:"implementation_version,omitempty"` + ProtocolMajor uint32 `protobuf:"varint,5,opt,name=protocol_major,json=protocolMajor,proto3" json:"protocol_major,omitempty"` + ProtocolMinor uint32 `protobuf:"varint,6,opt,name=protocol_minor,json=protocolMinor,proto3" json:"protocol_minor,omitempty"` + // Extension-supported optional capabilities, sorted for stable output. + SupportedCapabilities []string `protobuf:"bytes,7,rep,name=supported_capabilities,json=supportedCapabilities,proto3" json:"supported_capabilities,omitempty"` + // Capabilities the extension requires from the gateway. + RequiredCapabilities []string `protobuf:"bytes,8,rep,name=required_capabilities,json=requiredCapabilities,proto3" json:"required_capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NegotiatedExtensionInfo) Reset() { + *x = NegotiatedExtensionInfo{} + mi := &file_openshell_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NegotiatedExtensionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NegotiatedExtensionInfo) ProtoMessage() {} + +func (x *NegotiatedExtensionInfo) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[10] + 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 NegotiatedExtensionInfo.ProtoReflect.Descriptor instead. +func (*NegotiatedExtensionInfo) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{10} +} + +func (x *NegotiatedExtensionInfo) GetKind() ExtensionKind { + if x != nil { + return x.Kind + } + return ExtensionKind_EXTENSION_KIND_UNSPECIFIED +} + +func (x *NegotiatedExtensionInfo) GetConfiguredName() string { + if x != nil { + return x.ConfiguredName + } + return "" +} + +func (x *NegotiatedExtensionInfo) GetImplementationName() string { + if x != nil { + return x.ImplementationName + } + return "" +} + +func (x *NegotiatedExtensionInfo) GetImplementationVersion() string { + if x != nil { + return x.ImplementationVersion + } + return "" +} + +func (x *NegotiatedExtensionInfo) GetProtocolMajor() uint32 { + if x != nil { + return x.ProtocolMajor + } + return 0 +} + +func (x *NegotiatedExtensionInfo) GetProtocolMinor() uint32 { + if x != nil { + return x.ProtocolMinor + } + return 0 +} + +func (x *NegotiatedExtensionInfo) GetSupportedCapabilities() []string { + if x != nil { + return x.SupportedCapabilities + } + return nil +} + +func (x *NegotiatedExtensionInfo) GetRequiredCapabilities() []string { + if x != nil { + return x.RequiredCapabilities + } + return nil +} + // Info for one initialized compute driver runtime. type ComputeDriverInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1662,7 +1832,7 @@ type ComputeDriverInfo struct { func (x *ComputeDriverInfo) Reset() { *x = ComputeDriverInfo{} - mi := &file_openshell_proto_msgTypes[10] + mi := &file_openshell_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1674,7 +1844,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[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1687,7 +1857,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{11} } func (x *ComputeDriverInfo) GetName() string { @@ -1719,7 +1889,7 @@ type ComputeDriverCapabilities struct { func (x *ComputeDriverCapabilities) Reset() { *x = ComputeDriverCapabilities{} - mi := &file_openshell_proto_msgTypes[11] + mi := &file_openshell_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1731,7 +1901,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[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1744,7 +1914,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{12} } func (x *ComputeDriverCapabilities) GetDriverName() string { @@ -1781,7 +1951,7 @@ type ResourceCapabilities struct { func (x *ResourceCapabilities) Reset() { *x = ResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[12] + mi := &file_openshell_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1793,7 +1963,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[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1806,7 +1976,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{13} } func (x *ResourceCapabilities) GetCpu() *CpuResourceCapabilities { @@ -1840,7 +2010,7 @@ type CpuResourceCapabilities struct { func (x *CpuResourceCapabilities) Reset() { *x = CpuResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[13] + mi := &file_openshell_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1852,7 +2022,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[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1865,7 +2035,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{14} } func (x *CpuResourceCapabilities) GetLimitSupported() bool { @@ -1885,7 +2055,7 @@ type MemoryResourceCapabilities struct { func (x *MemoryResourceCapabilities) Reset() { *x = MemoryResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[14] + mi := &file_openshell_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1897,7 +2067,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[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1910,7 +2080,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{15} } func (x *MemoryResourceCapabilities) GetLimitSupported() bool { @@ -1932,7 +2102,7 @@ type GpuResourceCapabilities struct { func (x *GpuResourceCapabilities) Reset() { *x = GpuResourceCapabilities{} - mi := &file_openshell_proto_msgTypes[15] + mi := &file_openshell_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1944,7 +2114,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[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1957,7 +2127,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{16} } func (x *GpuResourceCapabilities) GetDefaultSelectionSupported() bool { @@ -1998,7 +2168,7 @@ type Sandbox struct { func (x *Sandbox) Reset() { *x = Sandbox{} - mi := &file_openshell_proto_msgTypes[16] + mi := &file_openshell_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2010,7 +2180,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[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2023,7 +2193,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{17} } func (x *Sandbox) GetMetadata() *datamodelv1.ObjectMeta { @@ -2085,7 +2255,7 @@ type SandboxSpec struct { func (x *SandboxSpec) Reset() { *x = SandboxSpec{} - mi := &file_openshell_proto_msgTypes[17] + mi := &file_openshell_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2097,7 +2267,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[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2110,7 +2280,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{18} } func (x *SandboxSpec) GetLogLevel() string { @@ -2186,7 +2356,7 @@ type ResourceRequirements struct { func (x *ResourceRequirements) Reset() { *x = ResourceRequirements{} - mi := &file_openshell_proto_msgTypes[18] + mi := &file_openshell_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2198,7 +2368,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[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2211,7 +2381,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{19} } func (x *ResourceRequirements) GetGpu() *GpuResourceRequirements { @@ -2233,7 +2403,7 @@ type GpuResourceRequirements struct { func (x *GpuResourceRequirements) Reset() { *x = GpuResourceRequirements{} - mi := &file_openshell_proto_msgTypes[19] + mi := &file_openshell_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2245,7 +2415,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[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2258,7 +2428,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{20} } func (x *GpuResourceRequirements) GetCount() uint32 { @@ -2307,7 +2477,7 @@ type SandboxTemplate struct { func (x *SandboxTemplate) Reset() { *x = SandboxTemplate{} - mi := &file_openshell_proto_msgTypes[20] + mi := &file_openshell_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2319,7 +2489,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[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2332,7 +2502,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{21} } func (x *SandboxTemplate) GetImage() string { @@ -2416,7 +2586,7 @@ type SandboxWorkloadTemplate struct { func (x *SandboxWorkloadTemplate) Reset() { *x = SandboxWorkloadTemplate{} - mi := &file_openshell_proto_msgTypes[21] + mi := &file_openshell_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2428,7 +2598,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[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2441,7 +2611,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{22} } func (x *SandboxWorkloadTemplate) GetMetadata() *datamodelv1.ObjectMeta { @@ -2472,7 +2642,7 @@ type SandboxWorkloadTemplateSpec struct { func (x *SandboxWorkloadTemplateSpec) Reset() { *x = SandboxWorkloadTemplateSpec{} - mi := &file_openshell_proto_msgTypes[22] + mi := &file_openshell_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2484,7 +2654,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[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2497,7 +2667,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{23} } func (x *SandboxWorkloadTemplateSpec) GetWorkload() *SandboxWorkloadConfig { @@ -2535,7 +2705,7 @@ type SandboxWorkloadConfig struct { func (x *SandboxWorkloadConfig) Reset() { *x = SandboxWorkloadConfig{} - mi := &file_openshell_proto_msgTypes[23] + mi := &file_openshell_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2547,7 +2717,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2560,7 +2730,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{24} } func (x *SandboxWorkloadConfig) GetImage() string { @@ -2600,7 +2770,7 @@ type SandboxResources struct { func (x *SandboxResources) Reset() { *x = SandboxResources{} - mi := &file_openshell_proto_msgTypes[24] + mi := &file_openshell_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2612,7 +2782,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[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2625,7 +2795,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{25} } func (x *SandboxResources) GetCpu() string { @@ -2658,7 +2828,7 @@ type SandboxServiceLevel struct { func (x *SandboxServiceLevel) Reset() { *x = SandboxServiceLevel{} - mi := &file_openshell_proto_msgTypes[25] + mi := &file_openshell_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2670,7 +2840,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[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2683,7 +2853,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{26} } func (x *SandboxServiceLevel) GetStartup() *SandboxStartup { @@ -2703,7 +2873,7 @@ type SandboxStartup struct { func (x *SandboxStartup) Reset() { *x = SandboxStartup{} - mi := &file_openshell_proto_msgTypes[26] + mi := &file_openshell_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2715,7 +2885,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[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2728,7 +2898,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{27} } func (x *SandboxStartup) GetReadyWithin() *durationpb.Duration { @@ -2755,7 +2925,7 @@ type SandboxWorkloadTemplateProvenance struct { func (x *SandboxWorkloadTemplateProvenance) Reset() { *x = SandboxWorkloadTemplateProvenance{} - mi := &file_openshell_proto_msgTypes[27] + mi := &file_openshell_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2767,7 +2937,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[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2780,7 +2950,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{28} } func (x *SandboxWorkloadTemplateProvenance) GetName() string { @@ -2837,7 +3007,7 @@ type SandboxStatus struct { func (x *SandboxStatus) Reset() { *x = SandboxStatus{} - mi := &file_openshell_proto_msgTypes[28] + mi := &file_openshell_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2849,7 +3019,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[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2862,7 +3032,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{29} } func (x *SandboxStatus) GetAgentPod() string { @@ -2968,7 +3138,7 @@ type SandboxCondition struct { func (x *SandboxCondition) Reset() { *x = SandboxCondition{} - mi := &file_openshell_proto_msgTypes[29] + mi := &file_openshell_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2980,7 +3150,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[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2993,7 +3163,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{30} } func (x *SandboxCondition) GetType() string { @@ -3052,7 +3222,7 @@ type PlatformEvent struct { func (x *PlatformEvent) Reset() { *x = PlatformEvent{} - mi := &file_openshell_proto_msgTypes[30] + mi := &file_openshell_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3064,7 +3234,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[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3077,7 +3247,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{31} } func (x *PlatformEvent) GetEventTime() *timestamppb.Timestamp { @@ -3142,14 +3312,18 @@ type CreateSandboxRequest struct { WorkloadTemplate string `protobuf:"bytes,6,opt,name=workload_template,json=workloadTemplate,proto3" json:"workload_template,omitempty"` // Optional nonzero UUID for durable at-most-once admission. Successful results // can be replayed for 24 hours; see the API errors and retries reference. - RequestId string `protobuf:"bytes,8,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RequestId string `protobuf:"bytes,8,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // HTTP services to expose when the sandbox is created. Endpoints are + // registered after the sandbox has been persisted and route only while the + // sandbox is ready. + ServiceExposures []*SandboxServiceExposure `protobuf:"bytes,9,rep,name=service_exposures,json=serviceExposures,proto3" json:"service_exposures,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxRequest) Reset() { *x = CreateSandboxRequest{} - mi := &file_openshell_proto_msgTypes[31] + mi := &file_openshell_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3161,7 +3335,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[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3174,7 +3348,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{32} } func (x *CreateSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3233,6 +3407,13 @@ func (x *CreateSandboxRequest) GetRequestId() string { return "" } +func (x *CreateSandboxRequest) GetServiceExposures() []*SandboxServiceExposure { + if x != nil { + return x.ServiceExposures + } + return nil +} + type CreateSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Workspace scope. Only a named workspace selection is accepted. @@ -3247,7 +3428,7 @@ type CreateSandboxTemplateRequest struct { func (x *CreateSandboxTemplateRequest) Reset() { *x = CreateSandboxTemplateRequest{} - mi := &file_openshell_proto_msgTypes[32] + mi := &file_openshell_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3259,7 +3440,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[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3272,7 +3453,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{33} } func (x *CreateSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3307,7 +3488,7 @@ type GetSandboxTemplateRequest struct { func (x *GetSandboxTemplateRequest) Reset() { *x = GetSandboxTemplateRequest{} - mi := &file_openshell_proto_msgTypes[33] + mi := &file_openshell_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3319,7 +3500,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[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3332,7 +3513,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{34} } func (x *GetSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3367,7 +3548,7 @@ type ListSandboxTemplatesRequest struct { func (x *ListSandboxTemplatesRequest) Reset() { *x = ListSandboxTemplatesRequest{} - mi := &file_openshell_proto_msgTypes[34] + mi := &file_openshell_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3379,7 +3560,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[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3392,7 +3573,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{35} } func (x *ListSandboxTemplatesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3439,7 +3620,7 @@ type DeleteSandboxTemplateRequest struct { func (x *DeleteSandboxTemplateRequest) Reset() { *x = DeleteSandboxTemplateRequest{} - mi := &file_openshell_proto_msgTypes[35] + mi := &file_openshell_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3451,7 +3632,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[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3464,7 +3645,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{36} } func (x *DeleteSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3504,7 +3685,7 @@ type SandboxTemplateResponse struct { func (x *SandboxTemplateResponse) Reset() { *x = SandboxTemplateResponse{} - mi := &file_openshell_proto_msgTypes[36] + mi := &file_openshell_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3516,7 +3697,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[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3529,7 +3710,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{37} } func (x *SandboxTemplateResponse) GetTemplate() *SandboxWorkloadTemplate { @@ -3550,7 +3731,7 @@ type ListSandboxTemplatesResponse struct { func (x *ListSandboxTemplatesResponse) Reset() { *x = ListSandboxTemplatesResponse{} - mi := &file_openshell_proto_msgTypes[37] + mi := &file_openshell_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3562,7 +3743,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[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3575,7 +3756,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{38} } func (x *ListSandboxTemplatesResponse) GetTemplates() []*SandboxWorkloadTemplate { @@ -3601,7 +3782,7 @@ type DeleteSandboxTemplateResponse struct { func (x *DeleteSandboxTemplateResponse) Reset() { *x = DeleteSandboxTemplateResponse{} - mi := &file_openshell_proto_msgTypes[38] + mi := &file_openshell_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3613,7 +3794,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[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3626,7 +3807,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{39} } func (x *DeleteSandboxTemplateResponse) GetOutcome() DeletionOutcome { @@ -3653,7 +3834,7 @@ type BeginRootfsTarStagingRequest struct { func (x *BeginRootfsTarStagingRequest) Reset() { *x = BeginRootfsTarStagingRequest{} - mi := &file_openshell_proto_msgTypes[39] + mi := &file_openshell_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3665,7 +3846,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[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3678,7 +3859,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{40} } func (x *BeginRootfsTarStagingRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3721,7 +3902,7 @@ type BeginRootfsTarStagingResponse struct { func (x *BeginRootfsTarStagingResponse) Reset() { *x = BeginRootfsTarStagingResponse{} - mi := &file_openshell_proto_msgTypes[40] + mi := &file_openshell_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3733,7 +3914,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[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3746,7 +3927,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{41} } func (x *BeginRootfsTarStagingResponse) GetStagingToken() string { @@ -3789,7 +3970,7 @@ type GetSandboxRequest struct { func (x *GetSandboxRequest) Reset() { *x = GetSandboxRequest{} - mi := &file_openshell_proto_msgTypes[41] + mi := &file_openshell_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3801,7 +3982,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[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3814,7 +3995,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{42} } func (x *GetSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3850,7 +4031,7 @@ type ListSandboxesRequest struct { func (x *ListSandboxesRequest) Reset() { *x = ListSandboxesRequest{} - mi := &file_openshell_proto_msgTypes[42] + mi := &file_openshell_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3862,7 +4043,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[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3875,7 +4056,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{43} } func (x *ListSandboxesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3918,7 +4099,7 @@ type ListSandboxProvidersRequest struct { func (x *ListSandboxProvidersRequest) Reset() { *x = ListSandboxProvidersRequest{} - mi := &file_openshell_proto_msgTypes[43] + mi := &file_openshell_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3930,7 +4111,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[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3943,7 +4124,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{44} } func (x *ListSandboxProvidersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -3982,7 +4163,7 @@ type AttachSandboxProviderRequest struct { func (x *AttachSandboxProviderRequest) Reset() { *x = AttachSandboxProviderRequest{} - mi := &file_openshell_proto_msgTypes[44] + mi := &file_openshell_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3994,7 +4175,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[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4007,7 +4188,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{45} } func (x *AttachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -4067,7 +4248,7 @@ type DetachSandboxProviderRequest struct { func (x *DetachSandboxProviderRequest) Reset() { *x = DetachSandboxProviderRequest{} - mi := &file_openshell_proto_msgTypes[45] + mi := &file_openshell_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4079,7 +4260,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[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4092,7 +4273,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{46} } func (x *DetachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -4149,7 +4330,7 @@ type DeleteSandboxRequest struct { func (x *DeleteSandboxRequest) Reset() { *x = DeleteSandboxRequest{} - mi := &file_openshell_proto_msgTypes[46] + mi := &file_openshell_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4161,7 +4342,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[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4174,7 +4355,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{47} } func (x *DeleteSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -4220,7 +4401,7 @@ type StopSandboxRequest struct { func (x *StopSandboxRequest) Reset() { *x = StopSandboxRequest{} - mi := &file_openshell_proto_msgTypes[47] + mi := &file_openshell_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4232,7 +4413,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[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4245,7 +4426,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{48} } func (x *StopSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -4284,7 +4465,7 @@ type StartSandboxRequest struct { func (x *StartSandboxRequest) Reset() { *x = StartSandboxRequest{} - mi := &file_openshell_proto_msgTypes[48] + mi := &file_openshell_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4296,7 +4477,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[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4309,7 +4490,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{49} } func (x *StartSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -4335,15 +4516,18 @@ func (x *StartSandboxRequest) GetRequestId() string { // Sandbox response. type SandboxResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service URLs created by CreateSandbox, keyed by service name. The empty + // key identifies the unnamed service. Other sandbox RPCs return an empty map. + ServiceUrls map[string]string `protobuf:"bytes,2,rep,name=service_urls,json=serviceUrls,proto3" json:"service_urls,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SandboxResponse) Reset() { *x = SandboxResponse{} - mi := &file_openshell_proto_msgTypes[49] + mi := &file_openshell_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4355,7 +4539,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[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4368,7 +4552,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{50} } func (x *SandboxResponse) GetSandbox() *Sandbox { @@ -4378,6 +4562,13 @@ func (x *SandboxResponse) GetSandbox() *Sandbox { return nil } +func (x *SandboxResponse) GetServiceUrls() map[string]string { + if x != nil { + return x.ServiceUrls + } + return nil +} + // List sandboxes response. type ListSandboxesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4390,7 +4581,7 @@ type ListSandboxesResponse struct { func (x *ListSandboxesResponse) Reset() { *x = ListSandboxesResponse{} - mi := &file_openshell_proto_msgTypes[50] + mi := &file_openshell_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4402,7 +4593,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[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4415,7 +4606,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{51} } func (x *ListSandboxesResponse) GetSandboxes() []*Sandbox { @@ -4442,7 +4633,7 @@ type ListSandboxProvidersResponse struct { func (x *ListSandboxProvidersResponse) Reset() { *x = ListSandboxProvidersResponse{} - mi := &file_openshell_proto_msgTypes[51] + mi := &file_openshell_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4454,7 +4645,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[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4467,7 +4658,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{52} } func (x *ListSandboxProvidersResponse) GetProviders() []*datamodelv1.Provider { @@ -4491,7 +4682,7 @@ type AttachSandboxProviderResponse struct { func (x *AttachSandboxProviderResponse) Reset() { *x = AttachSandboxProviderResponse{} - mi := &file_openshell_proto_msgTypes[52] + mi := &file_openshell_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4503,7 +4694,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[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4516,7 +4707,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{53} } func (x *AttachSandboxProviderResponse) GetSandbox() *Sandbox { @@ -4554,7 +4745,7 @@ type DetachSandboxProviderResponse struct { func (x *DetachSandboxProviderResponse) Reset() { *x = DetachSandboxProviderResponse{} - mi := &file_openshell_proto_msgTypes[53] + mi := &file_openshell_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4566,7 +4757,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[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4579,7 +4770,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{54} } func (x *DetachSandboxProviderResponse) GetSandbox() *Sandbox { @@ -4621,7 +4812,7 @@ type ProviderDesiredIdentity struct { func (x *ProviderDesiredIdentity) Reset() { *x = ProviderDesiredIdentity{} - mi := &file_openshell_proto_msgTypes[54] + mi := &file_openshell_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4633,7 +4824,7 @@ func (x *ProviderDesiredIdentity) String() string { func (*ProviderDesiredIdentity) ProtoMessage() {} func (x *ProviderDesiredIdentity) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[54] + mi := &file_openshell_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4646,7 +4837,7 @@ func (x *ProviderDesiredIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderDesiredIdentity.ProtoReflect.Descriptor instead. func (*ProviderDesiredIdentity) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{54} + return file_openshell_proto_rawDescGZIP(), []int{55} } func (x *ProviderDesiredIdentity) GetSandboxId() string { @@ -4721,7 +4912,7 @@ type ConfigSnapshotRevision struct { func (x *ConfigSnapshotRevision) Reset() { *x = ConfigSnapshotRevision{} - mi := &file_openshell_proto_msgTypes[55] + mi := &file_openshell_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4733,7 +4924,7 @@ func (x *ConfigSnapshotRevision) String() string { func (*ConfigSnapshotRevision) ProtoMessage() {} func (x *ConfigSnapshotRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[55] + mi := &file_openshell_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4746,7 +4937,7 @@ func (x *ConfigSnapshotRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigSnapshotRevision.ProtoReflect.Descriptor instead. func (*ConfigSnapshotRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{55} + return file_openshell_proto_rawDescGZIP(), []int{56} } func (x *ConfigSnapshotRevision) GetComponent() isConfigSnapshotRevision_Component { @@ -4823,7 +5014,7 @@ type SandboxConfigRevision struct { func (x *SandboxConfigRevision) Reset() { *x = SandboxConfigRevision{} - mi := &file_openshell_proto_msgTypes[56] + mi := &file_openshell_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4835,7 +5026,7 @@ func (x *SandboxConfigRevision) String() string { func (*SandboxConfigRevision) ProtoMessage() {} func (x *SandboxConfigRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[56] + mi := &file_openshell_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4848,7 +5039,7 @@ func (x *SandboxConfigRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxConfigRevision.ProtoReflect.Descriptor instead. func (*SandboxConfigRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{56} + return file_openshell_proto_rawDescGZIP(), []int{57} } func (x *SandboxConfigRevision) GetConfigRevision() uint64 { @@ -4907,7 +5098,7 @@ type ConfigUpdateOperation struct { func (x *ConfigUpdateOperation) Reset() { *x = ConfigUpdateOperation{} - mi := &file_openshell_proto_msgTypes[57] + mi := &file_openshell_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4919,7 +5110,7 @@ func (x *ConfigUpdateOperation) String() string { func (*ConfigUpdateOperation) ProtoMessage() {} func (x *ConfigUpdateOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[57] + mi := &file_openshell_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4932,7 +5123,7 @@ func (x *ConfigUpdateOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigUpdateOperation.ProtoReflect.Descriptor instead. func (*ConfigUpdateOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{57} + return file_openshell_proto_rawDescGZIP(), []int{58} } func (x *ConfigUpdateOperation) GetOperationId() string { @@ -5022,7 +5213,7 @@ type ProviderMutationReceipt struct { func (x *ProviderMutationReceipt) Reset() { *x = ProviderMutationReceipt{} - mi := &file_openshell_proto_msgTypes[58] + mi := &file_openshell_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5034,7 +5225,7 @@ func (x *ProviderMutationReceipt) String() string { func (*ProviderMutationReceipt) ProtoMessage() {} func (x *ProviderMutationReceipt) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[58] + mi := &file_openshell_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5047,7 +5238,7 @@ func (x *ProviderMutationReceipt) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderMutationReceipt.ProtoReflect.Descriptor instead. func (*ProviderMutationReceipt) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{58} + return file_openshell_proto_rawDescGZIP(), []int{59} } func (x *ProviderMutationReceipt) GetReceiptId() string { @@ -5123,7 +5314,7 @@ type ProviderReadinessObservation struct { func (x *ProviderReadinessObservation) Reset() { *x = ProviderReadinessObservation{} - mi := &file_openshell_proto_msgTypes[59] + mi := &file_openshell_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5135,7 +5326,7 @@ func (x *ProviderReadinessObservation) String() string { func (*ProviderReadinessObservation) ProtoMessage() {} func (x *ProviderReadinessObservation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[59] + mi := &file_openshell_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5148,7 +5339,7 @@ func (x *ProviderReadinessObservation) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderReadinessObservation.ProtoReflect.Descriptor instead. func (*ProviderReadinessObservation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{59} + return file_openshell_proto_rawDescGZIP(), []int{60} } func (x *ProviderReadinessObservation) GetSessionId() string { @@ -5247,7 +5438,7 @@ type ProviderReadinessStatus struct { func (x *ProviderReadinessStatus) Reset() { *x = ProviderReadinessStatus{} - mi := &file_openshell_proto_msgTypes[60] + mi := &file_openshell_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5259,7 +5450,7 @@ func (x *ProviderReadinessStatus) String() string { func (*ProviderReadinessStatus) ProtoMessage() {} func (x *ProviderReadinessStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[60] + mi := &file_openshell_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5272,7 +5463,7 @@ func (x *ProviderReadinessStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderReadinessStatus.ProtoReflect.Descriptor instead. func (*ProviderReadinessStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{60} + return file_openshell_proto_rawDescGZIP(), []int{61} } func (x *ProviderReadinessStatus) GetReceipt() *ProviderMutationReceipt { @@ -5346,7 +5537,7 @@ type GetSandboxProviderStatusRequest struct { func (x *GetSandboxProviderStatusRequest) Reset() { *x = GetSandboxProviderStatusRequest{} - mi := &file_openshell_proto_msgTypes[61] + mi := &file_openshell_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5358,7 +5549,7 @@ func (x *GetSandboxProviderStatusRequest) String() string { func (*GetSandboxProviderStatusRequest) ProtoMessage() {} func (x *GetSandboxProviderStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[61] + mi := &file_openshell_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5371,7 +5562,7 @@ func (x *GetSandboxProviderStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxProviderStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxProviderStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{61} + return file_openshell_proto_rawDescGZIP(), []int{62} } func (x *GetSandboxProviderStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -5411,7 +5602,7 @@ type GetSandboxProviderStatusResponse struct { func (x *GetSandboxProviderStatusResponse) Reset() { *x = GetSandboxProviderStatusResponse{} - mi := &file_openshell_proto_msgTypes[62] + mi := &file_openshell_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5423,7 +5614,7 @@ func (x *GetSandboxProviderStatusResponse) String() string { func (*GetSandboxProviderStatusResponse) ProtoMessage() {} func (x *GetSandboxProviderStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[62] + mi := &file_openshell_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5436,7 +5627,7 @@ func (x *GetSandboxProviderStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxProviderStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxProviderStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{62} + return file_openshell_proto_rawDescGZIP(), []int{63} } func (x *GetSandboxProviderStatusResponse) GetStatus() *ProviderReadinessStatus { @@ -5458,7 +5649,7 @@ type ReportProviderReadinessRequest struct { func (x *ReportProviderReadinessRequest) Reset() { *x = ReportProviderReadinessRequest{} - mi := &file_openshell_proto_msgTypes[63] + mi := &file_openshell_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5470,7 +5661,7 @@ func (x *ReportProviderReadinessRequest) String() string { func (*ReportProviderReadinessRequest) ProtoMessage() {} func (x *ReportProviderReadinessRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[63] + mi := &file_openshell_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5483,7 +5674,7 @@ func (x *ReportProviderReadinessRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportProviderReadinessRequest.ProtoReflect.Descriptor instead. func (*ReportProviderReadinessRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{63} + return file_openshell_proto_rawDescGZIP(), []int{64} } func (x *ReportProviderReadinessRequest) GetSandboxId() string { @@ -5513,7 +5704,7 @@ type ReportProviderReadinessResponse struct { func (x *ReportProviderReadinessResponse) Reset() { *x = ReportProviderReadinessResponse{} - mi := &file_openshell_proto_msgTypes[64] + mi := &file_openshell_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5525,7 +5716,7 @@ func (x *ReportProviderReadinessResponse) String() string { func (*ReportProviderReadinessResponse) ProtoMessage() {} func (x *ReportProviderReadinessResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[64] + mi := &file_openshell_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5538,7 +5729,7 @@ func (x *ReportProviderReadinessResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportProviderReadinessResponse.ProtoReflect.Descriptor instead. func (*ReportProviderReadinessResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{64} + return file_openshell_proto_rawDescGZIP(), []int{65} } func (x *ReportProviderReadinessResponse) GetAcceptedSequence() uint64 { @@ -5575,7 +5766,7 @@ type DeleteSandboxResponse struct { func (x *DeleteSandboxResponse) Reset() { *x = DeleteSandboxResponse{} - mi := &file_openshell_proto_msgTypes[65] + mi := &file_openshell_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5587,7 +5778,7 @@ func (x *DeleteSandboxResponse) String() string { func (*DeleteSandboxResponse) ProtoMessage() {} func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[65] + mi := &file_openshell_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5600,7 +5791,7 @@ func (x *DeleteSandboxResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSandboxResponse.ProtoReflect.Descriptor instead. func (*DeleteSandboxResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{65} + return file_openshell_proto_rawDescGZIP(), []int{66} } func (x *DeleteSandboxResponse) GetOutcome() DeletionOutcome { @@ -5629,7 +5820,7 @@ type CreateSshSessionRequest struct { func (x *CreateSshSessionRequest) Reset() { *x = CreateSshSessionRequest{} - mi := &file_openshell_proto_msgTypes[66] + mi := &file_openshell_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5641,7 +5832,7 @@ func (x *CreateSshSessionRequest) String() string { func (*CreateSshSessionRequest) ProtoMessage() {} func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[66] + mi := &file_openshell_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5654,7 +5845,7 @@ func (x *CreateSshSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSshSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSshSessionRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{66} + return file_openshell_proto_rawDescGZIP(), []int{67} } func (x *CreateSshSessionRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -5704,7 +5895,7 @@ type CreateSshSessionResponse struct { func (x *CreateSshSessionResponse) Reset() { *x = CreateSshSessionResponse{} - mi := &file_openshell_proto_msgTypes[67] + mi := &file_openshell_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5716,7 +5907,7 @@ func (x *CreateSshSessionResponse) String() string { func (*CreateSshSessionResponse) ProtoMessage() {} func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[67] + mi := &file_openshell_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5729,7 +5920,7 @@ func (x *CreateSshSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSshSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSshSessionResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{67} + return file_openshell_proto_rawDescGZIP(), []int{68} } func (x *CreateSshSessionResponse) GetSandboxId() string { @@ -5802,7 +5993,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_openshell_proto_msgTypes[68] + mi := &file_openshell_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5814,7 +6005,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[68] + mi := &file_openshell_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5827,7 +6018,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{68} + return file_openshell_proto_rawDescGZIP(), []int{69} } func (x *ExposeServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -5886,7 +6077,7 @@ type GetServiceRequest struct { func (x *GetServiceRequest) Reset() { *x = GetServiceRequest{} - mi := &file_openshell_proto_msgTypes[69] + mi := &file_openshell_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5898,7 +6089,7 @@ func (x *GetServiceRequest) String() string { func (*GetServiceRequest) ProtoMessage() {} func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[69] + mi := &file_openshell_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5911,7 +6102,7 @@ func (x *GetServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetServiceRequest.ProtoReflect.Descriptor instead. func (*GetServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{69} + return file_openshell_proto_rawDescGZIP(), []int{70} } func (x *GetServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -5954,7 +6145,7 @@ type ListServicesRequest struct { func (x *ListServicesRequest) Reset() { *x = ListServicesRequest{} - mi := &file_openshell_proto_msgTypes[70] + mi := &file_openshell_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5966,7 +6157,7 @@ func (x *ListServicesRequest) String() string { func (*ListServicesRequest) ProtoMessage() {} func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[70] + mi := &file_openshell_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5979,7 +6170,7 @@ func (x *ListServicesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServicesRequest.ProtoReflect.Descriptor instead. func (*ListServicesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{70} + return file_openshell_proto_rawDescGZIP(), []int{71} } func (x *ListServicesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -6022,7 +6213,7 @@ type ListServicesResponse struct { func (x *ListServicesResponse) Reset() { *x = ListServicesResponse{} - mi := &file_openshell_proto_msgTypes[71] + mi := &file_openshell_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6034,7 +6225,7 @@ func (x *ListServicesResponse) String() string { func (*ListServicesResponse) ProtoMessage() {} func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[71] + mi := &file_openshell_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6047,7 +6238,7 @@ func (x *ListServicesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServicesResponse.ProtoReflect.Descriptor instead. func (*ListServicesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{71} + return file_openshell_proto_rawDescGZIP(), []int{72} } func (x *ListServicesResponse) GetServices() []*ServiceEndpointResponse { @@ -6082,7 +6273,7 @@ type DeleteServiceRequest struct { func (x *DeleteServiceRequest) Reset() { *x = DeleteServiceRequest{} - mi := &file_openshell_proto_msgTypes[72] + mi := &file_openshell_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6094,7 +6285,7 @@ func (x *DeleteServiceRequest) String() string { func (*DeleteServiceRequest) ProtoMessage() {} func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[72] + mi := &file_openshell_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6107,7 +6298,7 @@ func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceRequest.ProtoReflect.Descriptor instead. func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{72} + return file_openshell_proto_rawDescGZIP(), []int{73} } func (x *DeleteServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -6155,7 +6346,7 @@ type DeleteServiceResponse struct { func (x *DeleteServiceResponse) Reset() { *x = DeleteServiceResponse{} - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6167,7 +6358,7 @@ func (x *DeleteServiceResponse) String() string { func (*DeleteServiceResponse) ProtoMessage() {} func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[73] + mi := &file_openshell_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6180,7 +6371,7 @@ func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceResponse.ProtoReflect.Descriptor instead. func (*DeleteServiceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{73} + return file_openshell_proto_rawDescGZIP(), []int{74} } func (x *DeleteServiceResponse) GetOutcome() DeletionOutcome { @@ -6211,7 +6402,7 @@ type ServiceEndpoint struct { func (x *ServiceEndpoint) Reset() { *x = ServiceEndpoint{} - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6223,7 +6414,7 @@ func (x *ServiceEndpoint) String() string { func (*ServiceEndpoint) ProtoMessage() {} func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[74] + mi := &file_openshell_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6236,7 +6427,7 @@ func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceEndpoint.ProtoReflect.Descriptor instead. func (*ServiceEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{74} + return file_openshell_proto_rawDescGZIP(), []int{75} } func (x *ServiceEndpoint) GetMetadata() *datamodelv1.ObjectMeta { @@ -6292,7 +6483,7 @@ type ServiceEndpointResponse struct { func (x *ServiceEndpointResponse) Reset() { *x = ServiceEndpointResponse{} - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6304,7 +6495,7 @@ func (x *ServiceEndpointResponse) String() string { func (*ServiceEndpointResponse) ProtoMessage() {} func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[75] + mi := &file_openshell_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6317,7 +6508,7 @@ func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceEndpointResponse.ProtoReflect.Descriptor instead. func (*ServiceEndpointResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{75} + return file_openshell_proto_rawDescGZIP(), []int{76} } func (x *ServiceEndpointResponse) GetEndpoint() *ServiceEndpoint { @@ -6348,7 +6539,7 @@ type RevokeSshSessionRequest struct { func (x *RevokeSshSessionRequest) Reset() { *x = RevokeSshSessionRequest{} - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6360,7 +6551,7 @@ func (x *RevokeSshSessionRequest) String() string { func (*RevokeSshSessionRequest) ProtoMessage() {} func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[76] + mi := &file_openshell_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6373,7 +6564,7 @@ func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSshSessionRequest.ProtoReflect.Descriptor instead. func (*RevokeSshSessionRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{76} + return file_openshell_proto_rawDescGZIP(), []int{77} } func (x *RevokeSshSessionRequest) GetToken() string { @@ -6400,7 +6591,7 @@ type RevokeSshSessionResponse struct { func (x *RevokeSshSessionResponse) Reset() { *x = RevokeSshSessionResponse{} - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6412,7 +6603,7 @@ func (x *RevokeSshSessionResponse) String() string { func (*RevokeSshSessionResponse) ProtoMessage() {} func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[77] + mi := &file_openshell_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6425,7 +6616,7 @@ func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSshSessionResponse.ProtoReflect.Descriptor instead. func (*RevokeSshSessionResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{77} + return file_openshell_proto_rawDescGZIP(), []int{78} } func (x *RevokeSshSessionResponse) GetOutcome() DeletionOutcome { @@ -6470,7 +6661,7 @@ type ExecSandboxRequest struct { func (x *ExecSandboxRequest) Reset() { *x = ExecSandboxRequest{} - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6482,7 +6673,7 @@ func (x *ExecSandboxRequest) String() string { func (*ExecSandboxRequest) ProtoMessage() {} func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[78] + mi := &file_openshell_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6495,7 +6686,7 @@ func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxRequest.ProtoReflect.Descriptor instead. func (*ExecSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{78} + return file_openshell_proto_rawDescGZIP(), []int{79} } func (x *ExecSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -6585,7 +6776,7 @@ type ExecSandboxStdout struct { func (x *ExecSandboxStdout) Reset() { *x = ExecSandboxStdout{} - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6597,7 +6788,7 @@ func (x *ExecSandboxStdout) String() string { func (*ExecSandboxStdout) ProtoMessage() {} func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[79] + mi := &file_openshell_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6610,7 +6801,7 @@ func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxStdout.ProtoReflect.Descriptor instead. func (*ExecSandboxStdout) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{79} + return file_openshell_proto_rawDescGZIP(), []int{80} } func (x *ExecSandboxStdout) GetData() []byte { @@ -6630,7 +6821,7 @@ type ExecSandboxStderr struct { func (x *ExecSandboxStderr) Reset() { *x = ExecSandboxStderr{} - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6642,7 +6833,7 @@ func (x *ExecSandboxStderr) String() string { func (*ExecSandboxStderr) ProtoMessage() {} func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[80] + mi := &file_openshell_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6655,7 +6846,7 @@ func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxStderr.ProtoReflect.Descriptor instead. func (*ExecSandboxStderr) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{80} + return file_openshell_proto_rawDescGZIP(), []int{81} } func (x *ExecSandboxStderr) GetData() []byte { @@ -6675,7 +6866,7 @@ type ExecSandboxExit struct { func (x *ExecSandboxExit) Reset() { *x = ExecSandboxExit{} - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6687,7 +6878,7 @@ func (x *ExecSandboxExit) String() string { func (*ExecSandboxExit) ProtoMessage() {} func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[81] + mi := &file_openshell_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6700,7 +6891,7 @@ func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxExit.ProtoReflect.Descriptor instead. func (*ExecSandboxExit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{81} + return file_openshell_proto_rawDescGZIP(), []int{82} } func (x *ExecSandboxExit) GetExitCode() int32 { @@ -6725,7 +6916,7 @@ type ExecSandboxEvent struct { func (x *ExecSandboxEvent) Reset() { *x = ExecSandboxEvent{} - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6737,7 +6928,7 @@ func (x *ExecSandboxEvent) String() string { func (*ExecSandboxEvent) ProtoMessage() {} func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[82] + mi := &file_openshell_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6750,7 +6941,7 @@ func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxEvent.ProtoReflect.Descriptor instead. func (*ExecSandboxEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{82} + return file_openshell_proto_rawDescGZIP(), []int{83} } func (x *ExecSandboxEvent) GetPayload() isExecSandboxEvent_Payload { @@ -6832,7 +7023,7 @@ type TcpForwardInit struct { func (x *TcpForwardInit) Reset() { *x = TcpForwardInit{} - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6844,7 +7035,7 @@ func (x *TcpForwardInit) String() string { func (*TcpForwardInit) ProtoMessage() {} func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[83] + mi := &file_openshell_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6857,7 +7048,7 @@ func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpForwardInit.ProtoReflect.Descriptor instead. func (*TcpForwardInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{83} + return file_openshell_proto_rawDescGZIP(), []int{84} } func (x *TcpForwardInit) GetSandbox() string { @@ -6943,7 +7134,7 @@ type TcpForwardFrame struct { func (x *TcpForwardFrame) Reset() { *x = TcpForwardFrame{} - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6955,7 +7146,7 @@ func (x *TcpForwardFrame) String() string { func (*TcpForwardFrame) ProtoMessage() {} func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[84] + mi := &file_openshell_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6968,7 +7159,7 @@ func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpForwardFrame.ProtoReflect.Descriptor instead. func (*TcpForwardFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{84} + return file_openshell_proto_rawDescGZIP(), []int{85} } func (x *TcpForwardFrame) GetPayload() isTcpForwardFrame_Payload { @@ -7027,7 +7218,7 @@ type ExecSandboxInput struct { func (x *ExecSandboxInput) Reset() { *x = ExecSandboxInput{} - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7039,7 +7230,7 @@ func (x *ExecSandboxInput) String() string { func (*ExecSandboxInput) ProtoMessage() {} func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[85] + mi := &file_openshell_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7052,7 +7243,7 @@ func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxInput.ProtoReflect.Descriptor instead. func (*ExecSandboxInput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{85} + return file_openshell_proto_rawDescGZIP(), []int{86} } func (x *ExecSandboxInput) GetPayload() isExecSandboxInput_Payload { @@ -7125,7 +7316,7 @@ type ExecSandboxWindowResize struct { func (x *ExecSandboxWindowResize) Reset() { *x = ExecSandboxWindowResize{} - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7137,7 +7328,7 @@ func (x *ExecSandboxWindowResize) String() string { func (*ExecSandboxWindowResize) ProtoMessage() {} func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[86] + mi := &file_openshell_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7150,7 +7341,7 @@ func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecSandboxWindowResize.ProtoReflect.Descriptor instead. func (*ExecSandboxWindowResize) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{86} + return file_openshell_proto_rawDescGZIP(), []int{87} } func (x *ExecSandboxWindowResize) GetCols() uint32 { @@ -7186,7 +7377,7 @@ type SshSession struct { func (x *SshSession) Reset() { *x = SshSession{} - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7198,7 +7389,7 @@ func (x *SshSession) String() string { func (*SshSession) ProtoMessage() {} func (x *SshSession) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[87] + mi := &file_openshell_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7211,7 +7402,7 @@ func (x *SshSession) ProtoReflect() protoreflect.Message { // Deprecated: Use SshSession.ProtoReflect.Descriptor instead. func (*SshSession) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{87} + return file_openshell_proto_rawDescGZIP(), []int{88} } func (x *SshSession) GetMetadata() *datamodelv1.ObjectMeta { @@ -7282,7 +7473,7 @@ type WatchSandboxRequest struct { func (x *WatchSandboxRequest) Reset() { *x = WatchSandboxRequest{} - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7294,7 +7485,7 @@ func (x *WatchSandboxRequest) String() string { func (*WatchSandboxRequest) ProtoMessage() {} func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[88] + mi := &file_openshell_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7307,7 +7498,7 @@ func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSandboxRequest.ProtoReflect.Descriptor instead. func (*WatchSandboxRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{88} + return file_openshell_proto_rawDescGZIP(), []int{89} } func (x *WatchSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -7404,7 +7595,7 @@ type SandboxStreamEvent struct { func (x *SandboxStreamEvent) Reset() { *x = SandboxStreamEvent{} - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7416,7 +7607,7 @@ func (x *SandboxStreamEvent) String() string { func (*SandboxStreamEvent) ProtoMessage() {} func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[89] + mi := &file_openshell_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7429,7 +7620,7 @@ func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStreamEvent.ProtoReflect.Descriptor instead. func (*SandboxStreamEvent) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{89} + return file_openshell_proto_rawDescGZIP(), []int{90} } func (x *SandboxStreamEvent) GetPayload() isSandboxStreamEvent_Payload { @@ -7542,7 +7733,7 @@ type SandboxLogLine struct { func (x *SandboxLogLine) Reset() { *x = SandboxLogLine{} - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7554,7 +7745,7 @@ func (x *SandboxLogLine) String() string { func (*SandboxLogLine) ProtoMessage() {} func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[90] + mi := &file_openshell_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7567,7 +7758,7 @@ func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxLogLine.ProtoReflect.Descriptor instead. func (*SandboxLogLine) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{90} + return file_openshell_proto_rawDescGZIP(), []int{91} } func (x *SandboxLogLine) GetSandboxId() string { @@ -7628,7 +7819,7 @@ type SandboxStreamWarning struct { func (x *SandboxStreamWarning) Reset() { *x = SandboxStreamWarning{} - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7640,7 +7831,7 @@ func (x *SandboxStreamWarning) String() string { func (*SandboxStreamWarning) ProtoMessage() {} func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[91] + mi := &file_openshell_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7653,7 +7844,7 @@ func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxStreamWarning.ProtoReflect.Descriptor instead. func (*SandboxStreamWarning) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{91} + return file_openshell_proto_rawDescGZIP(), []int{92} } func (x *SandboxStreamWarning) GetMessage() string { @@ -7678,7 +7869,7 @@ type CreateProviderRequest struct { func (x *CreateProviderRequest) Reset() { *x = CreateProviderRequest{} - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7690,7 +7881,7 @@ func (x *CreateProviderRequest) String() string { func (*CreateProviderRequest) ProtoMessage() {} func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[92] + mi := &file_openshell_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7703,7 +7894,7 @@ func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateProviderRequest.ProtoReflect.Descriptor instead. func (*CreateProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{92} + return file_openshell_proto_rawDescGZIP(), []int{93} } func (x *CreateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -7739,7 +7930,7 @@ type GetProviderRequest struct { func (x *GetProviderRequest) Reset() { *x = GetProviderRequest{} - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7751,7 +7942,7 @@ func (x *GetProviderRequest) String() string { func (*GetProviderRequest) ProtoMessage() {} func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[93] + mi := &file_openshell_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7764,7 +7955,7 @@ func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRequest.ProtoReflect.Descriptor instead. func (*GetProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{93} + return file_openshell_proto_rawDescGZIP(), []int{94} } func (x *GetProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -7798,7 +7989,7 @@ type ListProvidersRequest struct { func (x *ListProvidersRequest) Reset() { *x = ListProvidersRequest{} - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7810,7 +8001,7 @@ func (x *ListProvidersRequest) String() string { func (*ListProvidersRequest) ProtoMessage() {} func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[94] + mi := &file_openshell_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7823,7 +8014,7 @@ func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProvidersRequest.ProtoReflect.Descriptor instead. func (*ListProvidersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{94} + return file_openshell_proto_rawDescGZIP(), []int{95} } func (x *ListProvidersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -7868,7 +8059,7 @@ type UpdateProviderRequest struct { func (x *UpdateProviderRequest) Reset() { *x = UpdateProviderRequest{} - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7880,7 +8071,7 @@ func (x *UpdateProviderRequest) String() string { func (*UpdateProviderRequest) ProtoMessage() {} func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[95] + mi := &file_openshell_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7893,7 +8084,7 @@ func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{95} + return file_openshell_proto_rawDescGZIP(), []int{96} } func (x *UpdateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -7947,7 +8138,7 @@ type DeleteProviderRequest struct { func (x *DeleteProviderRequest) Reset() { *x = DeleteProviderRequest{} - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7959,7 +8150,7 @@ func (x *DeleteProviderRequest) String() string { func (*DeleteProviderRequest) ProtoMessage() {} func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[96] + mi := &file_openshell_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7972,7 +8163,7 @@ func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{96} + return file_openshell_proto_rawDescGZIP(), []int{97} } func (x *DeleteProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -8018,7 +8209,7 @@ type ProviderResponse struct { func (x *ProviderResponse) Reset() { *x = ProviderResponse{} - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8030,7 +8221,7 @@ func (x *ProviderResponse) String() string { func (*ProviderResponse) ProtoMessage() {} func (x *ProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[97] + mi := &file_openshell_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8043,7 +8234,7 @@ func (x *ProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderResponse.ProtoReflect.Descriptor instead. func (*ProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{97} + return file_openshell_proto_rawDescGZIP(), []int{98} } func (x *ProviderResponse) GetProvider() *datamodelv1.Provider { @@ -8079,7 +8270,7 @@ type ListProvidersResponse struct { func (x *ListProvidersResponse) Reset() { *x = ListProvidersResponse{} - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8091,7 +8282,7 @@ func (x *ListProvidersResponse) String() string { func (*ListProvidersResponse) ProtoMessage() {} func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[98] + mi := &file_openshell_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8104,7 +8295,7 @@ func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProvidersResponse.ProtoReflect.Descriptor instead. func (*ListProvidersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{98} + return file_openshell_proto_rawDescGZIP(), []int{99} } func (x *ListProvidersResponse) GetProviders() []*datamodelv1.Provider { @@ -8138,7 +8329,7 @@ type ListProviderProfilesRequest struct { func (x *ListProviderProfilesRequest) Reset() { *x = ListProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8150,7 +8341,7 @@ func (x *ListProviderProfilesRequest) String() string { func (*ListProviderProfilesRequest) ProtoMessage() {} func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[99] + mi := &file_openshell_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8163,7 +8354,7 @@ func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{99} + return file_openshell_proto_rawDescGZIP(), []int{100} } func (x *ListProviderProfilesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -8199,7 +8390,7 @@ type GetProviderProfileRequest struct { func (x *GetProviderProfileRequest) Reset() { *x = GetProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8211,7 +8402,7 @@ func (x *GetProviderProfileRequest) String() string { func (*GetProviderProfileRequest) ProtoMessage() {} func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[100] + mi := &file_openshell_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8224,7 +8415,7 @@ func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderProfileRequest.ProtoReflect.Descriptor instead. func (*GetProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{100} + return file_openshell_proto_rawDescGZIP(), []int{101} } func (x *GetProviderProfileRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -8252,7 +8443,7 @@ type ProviderProfileImportItem struct { func (x *ProviderProfileImportItem) Reset() { *x = ProviderProfileImportItem{} - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8264,7 +8455,7 @@ func (x *ProviderProfileImportItem) String() string { func (*ProviderProfileImportItem) ProtoMessage() {} func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[101] + mi := &file_openshell_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8277,7 +8468,7 @@ func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileImportItem.ProtoReflect.Descriptor instead. func (*ProviderProfileImportItem) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{101} + return file_openshell_proto_rawDescGZIP(), []int{102} } func (x *ProviderProfileImportItem) GetProfile() *ProviderProfile { @@ -8308,7 +8499,7 @@ type ProviderProfileDiagnostic struct { func (x *ProviderProfileDiagnostic) Reset() { *x = ProviderProfileDiagnostic{} - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8320,7 +8511,7 @@ func (x *ProviderProfileDiagnostic) String() string { func (*ProviderProfileDiagnostic) ProtoMessage() {} func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[102] + mi := &file_openshell_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8333,7 +8524,7 @@ func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiagnostic.ProtoReflect.Descriptor instead. func (*ProviderProfileDiagnostic) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{102} + return file_openshell_proto_rawDescGZIP(), []int{103} } func (x *ProviderProfileDiagnostic) GetSource() string { @@ -8390,7 +8581,7 @@ type ProviderCredentialTokenGrantAudienceOverride struct { func (x *ProviderCredentialTokenGrantAudienceOverride) Reset() { *x = ProviderCredentialTokenGrantAudienceOverride{} - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8402,7 +8593,7 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) String() string { func (*ProviderCredentialTokenGrantAudienceOverride) ProtoMessage() {} func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[103] + mi := &file_openshell_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8415,7 +8606,7 @@ func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protorefle // Deprecated: Use ProviderCredentialTokenGrantAudienceOverride.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrantAudienceOverride) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{103} + return file_openshell_proto_rawDescGZIP(), []int{104} } func (x *ProviderCredentialTokenGrantAudienceOverride) GetHost() string { @@ -8469,7 +8660,7 @@ type ProviderCredentialTokenGrantSubjectToken struct { func (x *ProviderCredentialTokenGrantSubjectToken) Reset() { *x = ProviderCredentialTokenGrantSubjectToken{} - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8481,7 +8672,7 @@ func (x *ProviderCredentialTokenGrantSubjectToken) String() string { func (*ProviderCredentialTokenGrantSubjectToken) ProtoMessage() {} func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[104] + mi := &file_openshell_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8494,7 +8685,7 @@ func (x *ProviderCredentialTokenGrantSubjectToken) ProtoReflect() protoreflect.M // Deprecated: Use ProviderCredentialTokenGrantSubjectToken.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrantSubjectToken) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{104} + return file_openshell_proto_rawDescGZIP(), []int{105} } func (x *ProviderCredentialTokenGrantSubjectToken) GetSource() string { @@ -8550,7 +8741,7 @@ type ProviderCredentialTokenGrant struct { func (x *ProviderCredentialTokenGrant) Reset() { *x = ProviderCredentialTokenGrant{} - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8562,7 +8753,7 @@ func (x *ProviderCredentialTokenGrant) String() string { func (*ProviderCredentialTokenGrant) ProtoMessage() {} func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[105] + mi := &file_openshell_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8575,7 +8766,7 @@ func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialTokenGrant.ProtoReflect.Descriptor instead. func (*ProviderCredentialTokenGrant) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{105} + return file_openshell_proto_rawDescGZIP(), []int{106} } func (x *ProviderCredentialTokenGrant) GetTokenEndpoint() string { @@ -8667,7 +8858,7 @@ type ProviderProfileCredential struct { func (x *ProviderProfileCredential) Reset() { *x = ProviderProfileCredential{} - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8679,7 +8870,7 @@ func (x *ProviderProfileCredential) String() string { func (*ProviderProfileCredential) ProtoMessage() {} func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[106] + mi := &file_openshell_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8692,7 +8883,7 @@ func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileCredential.ProtoReflect.Descriptor instead. func (*ProviderProfileCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{106} + return file_openshell_proto_rawDescGZIP(), []int{107} } func (x *ProviderProfileCredential) GetName() string { @@ -8777,7 +8968,7 @@ type ProviderCredentialRefreshMaterial struct { func (x *ProviderCredentialRefreshMaterial) Reset() { *x = ProviderCredentialRefreshMaterial{} - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8789,7 +8980,7 @@ func (x *ProviderCredentialRefreshMaterial) String() string { func (*ProviderCredentialRefreshMaterial) ProtoMessage() {} func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[107] + mi := &file_openshell_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8802,7 +8993,7 @@ func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message // Deprecated: Use ProviderCredentialRefreshMaterial.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshMaterial) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{107} + return file_openshell_proto_rawDescGZIP(), []int{108} } func (x *ProviderCredentialRefreshMaterial) GetName() string { @@ -8847,7 +9038,7 @@ type ProviderCredentialRefreshOutput struct { func (x *ProviderCredentialRefreshOutput) Reset() { *x = ProviderCredentialRefreshOutput{} - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8859,7 +9050,7 @@ func (x *ProviderCredentialRefreshOutput) String() string { func (*ProviderCredentialRefreshOutput) ProtoMessage() {} func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[108] + mi := &file_openshell_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8872,7 +9063,7 @@ func (x *ProviderCredentialRefreshOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshOutput.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshOutput) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{108} + return file_openshell_proto_rawDescGZIP(), []int{109} } func (x *ProviderCredentialRefreshOutput) GetOutput() string { @@ -8904,7 +9095,7 @@ type ProviderCredentialRefresh struct { func (x *ProviderCredentialRefresh) Reset() { *x = ProviderCredentialRefresh{} - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8916,7 +9107,7 @@ func (x *ProviderCredentialRefresh) String() string { func (*ProviderCredentialRefresh) ProtoMessage() {} func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[109] + mi := &file_openshell_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8929,7 +9120,7 @@ func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefresh.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefresh) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{109} + return file_openshell_proto_rawDescGZIP(), []int{110} } func (x *ProviderCredentialRefresh) GetStrategy() ProviderCredentialRefreshStrategy { @@ -9010,7 +9201,7 @@ type ProviderCredentialRefreshStatus struct { func (x *ProviderCredentialRefreshStatus) Reset() { *x = ProviderCredentialRefreshStatus{} - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9022,7 +9213,7 @@ func (x *ProviderCredentialRefreshStatus) String() string { func (*ProviderCredentialRefreshStatus) ProtoMessage() {} func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[110] + mi := &file_openshell_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9035,7 +9226,7 @@ func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderCredentialRefreshStatus.ProtoReflect.Descriptor instead. func (*ProviderCredentialRefreshStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{110} + return file_openshell_proto_rawDescGZIP(), []int{111} } func (x *ProviderCredentialRefreshStatus) GetProvider() string { @@ -9140,7 +9331,7 @@ type ProviderProfileDiscovery struct { func (x *ProviderProfileDiscovery) Reset() { *x = ProviderProfileDiscovery{} - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9152,7 +9343,7 @@ func (x *ProviderProfileDiscovery) String() string { func (*ProviderProfileDiscovery) ProtoMessage() {} func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[111] + mi := &file_openshell_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9165,7 +9356,7 @@ func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileDiscovery.ProtoReflect.Descriptor instead. func (*ProviderProfileDiscovery) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{111} + return file_openshell_proto_rawDescGZIP(), []int{112} } func (x *ProviderProfileDiscovery) GetCredentials() []string { @@ -9187,7 +9378,7 @@ type GetProviderRefreshStatusRequest struct { func (x *GetProviderRefreshStatusRequest) Reset() { *x = GetProviderRefreshStatusRequest{} - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9199,7 +9390,7 @@ func (x *GetProviderRefreshStatusRequest) String() string { func (*GetProviderRefreshStatusRequest) ProtoMessage() {} func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[112] + mi := &file_openshell_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9212,7 +9403,7 @@ func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusRequest.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{112} + return file_openshell_proto_rawDescGZIP(), []int{113} } func (x *GetProviderRefreshStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -9245,7 +9436,7 @@ type GetProviderRefreshStatusResponse struct { func (x *GetProviderRefreshStatusResponse) Reset() { *x = GetProviderRefreshStatusResponse{} - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9257,7 +9448,7 @@ func (x *GetProviderRefreshStatusResponse) String() string { func (*GetProviderRefreshStatusResponse) ProtoMessage() {} func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[113] + mi := &file_openshell_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9270,7 +9461,7 @@ func (x *GetProviderRefreshStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProviderRefreshStatusResponse.ProtoReflect.Descriptor instead. func (*GetProviderRefreshStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{113} + return file_openshell_proto_rawDescGZIP(), []int{114} } func (x *GetProviderRefreshStatusResponse) GetCredentials() []*ProviderCredentialRefreshStatus { @@ -9302,7 +9493,7 @@ type ConfigureProviderRefreshRequest struct { func (x *ConfigureProviderRefreshRequest) Reset() { *x = ConfigureProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9314,7 +9505,7 @@ func (x *ConfigureProviderRefreshRequest) String() string { func (*ConfigureProviderRefreshRequest) ProtoMessage() {} func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[114] + mi := &file_openshell_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9327,7 +9518,7 @@ func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{114} + return file_openshell_proto_rawDescGZIP(), []int{115} } func (x *ConfigureProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -9395,7 +9586,7 @@ type ConfigureProviderRefreshResponse struct { func (x *ConfigureProviderRefreshResponse) Reset() { *x = ConfigureProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9407,7 +9598,7 @@ func (x *ConfigureProviderRefreshResponse) String() string { func (*ConfigureProviderRefreshResponse) ProtoMessage() {} func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[115] + mi := &file_openshell_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9420,7 +9611,7 @@ func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*ConfigureProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{115} + return file_openshell_proto_rawDescGZIP(), []int{116} } func (x *ConfigureProviderRefreshResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -9445,7 +9636,7 @@ type RotateProviderCredentialRequest struct { func (x *RotateProviderCredentialRequest) Reset() { *x = RotateProviderCredentialRequest{} - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9457,7 +9648,7 @@ func (x *RotateProviderCredentialRequest) String() string { func (*RotateProviderCredentialRequest) ProtoMessage() {} func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[116] + mi := &file_openshell_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9470,7 +9661,7 @@ func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialRequest.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{116} + return file_openshell_proto_rawDescGZIP(), []int{117} } func (x *RotateProviderCredentialRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -9510,7 +9701,7 @@ type RotateProviderCredentialResponse struct { func (x *RotateProviderCredentialResponse) Reset() { *x = RotateProviderCredentialResponse{} - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9522,7 +9713,7 @@ func (x *RotateProviderCredentialResponse) String() string { func (*RotateProviderCredentialResponse) ProtoMessage() {} func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[117] + mi := &file_openshell_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9535,7 +9726,7 @@ func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RotateProviderCredentialResponse.ProtoReflect.Descriptor instead. func (*RotateProviderCredentialResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{117} + return file_openshell_proto_rawDescGZIP(), []int{118} } func (x *RotateProviderCredentialResponse) GetStatus() *ProviderCredentialRefreshStatus { @@ -9561,7 +9752,7 @@ type DeleteProviderRefreshRequest struct { func (x *DeleteProviderRefreshRequest) Reset() { *x = DeleteProviderRefreshRequest{} - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9573,7 +9764,7 @@ func (x *DeleteProviderRefreshRequest) String() string { func (*DeleteProviderRefreshRequest) ProtoMessage() {} func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[118] + mi := &file_openshell_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9586,7 +9777,7 @@ func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{118} + return file_openshell_proto_rawDescGZIP(), []int{119} } func (x *DeleteProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -9633,7 +9824,7 @@ type DeleteProviderRefreshResponse struct { func (x *DeleteProviderRefreshResponse) Reset() { *x = DeleteProviderRefreshResponse{} - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9645,7 +9836,7 @@ func (x *DeleteProviderRefreshResponse) String() string { func (*DeleteProviderRefreshResponse) ProtoMessage() {} func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[119] + mi := &file_openshell_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9658,7 +9849,7 @@ func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderRefreshResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderRefreshResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{119} + return file_openshell_proto_rawDescGZIP(), []int{120} } func (x *DeleteProviderRefreshResponse) GetOutcome() DeletionOutcome { @@ -9698,7 +9889,7 @@ type ProviderProfile struct { func (x *ProviderProfile) Reset() { *x = ProviderProfile{} - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9710,7 +9901,7 @@ func (x *ProviderProfile) String() string { func (*ProviderProfile) ProtoMessage() {} func (x *ProviderProfile) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[120] + mi := &file_openshell_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9723,7 +9914,7 @@ func (x *ProviderProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfile.ProtoReflect.Descriptor instead. func (*ProviderProfile) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{120} + return file_openshell_proto_rawDescGZIP(), []int{121} } func (x *ProviderProfile) GetId() string { @@ -9827,7 +10018,7 @@ type ProviderProfileResponse struct { func (x *ProviderProfileResponse) Reset() { *x = ProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9839,7 +10030,7 @@ func (x *ProviderProfileResponse) String() string { func (*ProviderProfileResponse) ProtoMessage() {} func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[121] + mi := &file_openshell_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9852,7 +10043,7 @@ func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderProfileResponse.ProtoReflect.Descriptor instead. func (*ProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{121} + return file_openshell_proto_rawDescGZIP(), []int{122} } func (x *ProviderProfileResponse) GetProfile() *ProviderProfile { @@ -9874,7 +10065,7 @@ type ListProviderProfilesResponse struct { func (x *ListProviderProfilesResponse) Reset() { *x = ListProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9886,7 +10077,7 @@ func (x *ListProviderProfilesResponse) String() string { func (*ListProviderProfilesResponse) ProtoMessage() {} func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[122] + mi := &file_openshell_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9899,7 +10090,7 @@ func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{122} + return file_openshell_proto_rawDescGZIP(), []int{123} } func (x *ListProviderProfilesResponse) GetProfiles() []*ProviderProfile { @@ -9931,7 +10122,7 @@ type ImportProviderProfilesRequest struct { func (x *ImportProviderProfilesRequest) Reset() { *x = ImportProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9943,7 +10134,7 @@ func (x *ImportProviderProfilesRequest) String() string { func (*ImportProviderProfilesRequest) ProtoMessage() {} func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[123] + mi := &file_openshell_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9956,7 +10147,7 @@ func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{123} + return file_openshell_proto_rawDescGZIP(), []int{124} } func (x *ImportProviderProfilesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -9992,7 +10183,7 @@ type ImportProviderProfilesResponse struct { func (x *ImportProviderProfilesResponse) Reset() { *x = ImportProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10004,7 +10195,7 @@ func (x *ImportProviderProfilesResponse) String() string { func (*ImportProviderProfilesResponse) ProtoMessage() {} func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[124] + mi := &file_openshell_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10017,7 +10208,7 @@ func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*ImportProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{124} + return file_openshell_proto_rawDescGZIP(), []int{125} } func (x *ImportProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -10063,7 +10254,7 @@ type UpdateProviderProfilesRequest struct { func (x *UpdateProviderProfilesRequest) Reset() { *x = UpdateProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10075,7 +10266,7 @@ func (x *UpdateProviderProfilesRequest) String() string { func (*UpdateProviderProfilesRequest) ProtoMessage() {} func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[125] + mi := &file_openshell_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10088,7 +10279,7 @@ func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{125} + return file_openshell_proto_rawDescGZIP(), []int{126} } func (x *UpdateProviderProfilesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -10138,7 +10329,7 @@ type UpdateProviderProfilesResponse struct { func (x *UpdateProviderProfilesResponse) Reset() { *x = UpdateProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10150,7 +10341,7 @@ func (x *UpdateProviderProfilesResponse) String() string { func (*UpdateProviderProfilesResponse) ProtoMessage() {} func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[126] + mi := &file_openshell_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10163,7 +10354,7 @@ func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*UpdateProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{126} + return file_openshell_proto_rawDescGZIP(), []int{127} } func (x *UpdateProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -10199,7 +10390,7 @@ type LintProviderProfilesRequest struct { func (x *LintProviderProfilesRequest) Reset() { *x = LintProviderProfilesRequest{} - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[128] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10211,7 +10402,7 @@ func (x *LintProviderProfilesRequest) String() string { func (*LintProviderProfilesRequest) ProtoMessage() {} func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[127] + mi := &file_openshell_proto_msgTypes[128] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10224,7 +10415,7 @@ func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesRequest.ProtoReflect.Descriptor instead. func (*LintProviderProfilesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{127} + return file_openshell_proto_rawDescGZIP(), []int{128} } func (x *LintProviderProfilesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -10252,7 +10443,7 @@ type LintProviderProfilesResponse struct { func (x *LintProviderProfilesResponse) Reset() { *x = LintProviderProfilesResponse{} - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[129] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10264,7 +10455,7 @@ func (x *LintProviderProfilesResponse) String() string { func (*LintProviderProfilesResponse) ProtoMessage() {} func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[128] + mi := &file_openshell_proto_msgTypes[129] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10277,7 +10468,7 @@ func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LintProviderProfilesResponse.ProtoReflect.Descriptor instead. func (*LintProviderProfilesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{128} + return file_openshell_proto_rawDescGZIP(), []int{129} } func (x *LintProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { @@ -10304,7 +10495,7 @@ type DeleteProviderResponse struct { func (x *DeleteProviderResponse) Reset() { *x = DeleteProviderResponse{} - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[130] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10316,7 +10507,7 @@ func (x *DeleteProviderResponse) String() string { func (*DeleteProviderResponse) ProtoMessage() {} func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[129] + mi := &file_openshell_proto_msgTypes[130] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10329,7 +10520,7 @@ func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{129} + return file_openshell_proto_rawDescGZIP(), []int{130} } func (x *DeleteProviderResponse) GetOutcome() DeletionOutcome { @@ -10355,7 +10546,7 @@ type DeleteProviderProfileRequest struct { func (x *DeleteProviderProfileRequest) Reset() { *x = DeleteProviderProfileRequest{} - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[131] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10367,7 +10558,7 @@ func (x *DeleteProviderProfileRequest) String() string { func (*DeleteProviderProfileRequest) ProtoMessage() {} func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[130] + mi := &file_openshell_proto_msgTypes[131] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10380,7 +10571,7 @@ func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileRequest.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{130} + return file_openshell_proto_rawDescGZIP(), []int{131} } func (x *DeleteProviderProfileRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -10421,7 +10612,7 @@ type DeleteProviderProfileResponse struct { func (x *DeleteProviderProfileResponse) Reset() { *x = DeleteProviderProfileResponse{} - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[132] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10433,7 +10624,7 @@ func (x *DeleteProviderProfileResponse) String() string { func (*DeleteProviderProfileResponse) ProtoMessage() {} func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[131] + mi := &file_openshell_proto_msgTypes[132] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10446,7 +10637,7 @@ func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteProviderProfileResponse.ProtoReflect.Descriptor instead. func (*DeleteProviderProfileResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{131} + return file_openshell_proto_rawDescGZIP(), []int{132} } func (x *DeleteProviderProfileResponse) GetOutcome() DeletionOutcome { @@ -10471,7 +10662,7 @@ type GetSandboxProviderEnvironmentRequest struct { func (x *GetSandboxProviderEnvironmentRequest) Reset() { *x = GetSandboxProviderEnvironmentRequest{} - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[133] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10483,7 +10674,7 @@ func (x *GetSandboxProviderEnvironmentRequest) String() string { func (*GetSandboxProviderEnvironmentRequest) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[132] + mi := &file_openshell_proto_msgTypes[133] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10496,7 +10687,7 @@ func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{132} + return file_openshell_proto_rawDescGZIP(), []int{133} } func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string { @@ -10525,7 +10716,7 @@ type StaticCredentialEndpointBinding struct { func (x *StaticCredentialEndpointBinding) Reset() { *x = StaticCredentialEndpointBinding{} - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[134] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10537,7 +10728,7 @@ func (x *StaticCredentialEndpointBinding) String() string { func (*StaticCredentialEndpointBinding) ProtoMessage() {} func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[133] + mi := &file_openshell_proto_msgTypes[134] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10550,7 +10741,7 @@ func (x *StaticCredentialEndpointBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialEndpointBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialEndpointBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{133} + return file_openshell_proto_rawDescGZIP(), []int{134} } func (x *StaticCredentialEndpointBinding) GetHost() string { @@ -10594,7 +10785,7 @@ type StaticCredentialBinding struct { func (x *StaticCredentialBinding) Reset() { *x = StaticCredentialBinding{} - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[135] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10606,7 +10797,7 @@ func (x *StaticCredentialBinding) String() string { func (*StaticCredentialBinding) ProtoMessage() {} func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[134] + mi := &file_openshell_proto_msgTypes[135] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10619,7 +10810,7 @@ func (x *StaticCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticCredentialBinding.ProtoReflect.Descriptor instead. func (*StaticCredentialBinding) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{134} + return file_openshell_proto_rawDescGZIP(), []int{135} } func (x *StaticCredentialBinding) GetEndpoints() []*StaticCredentialEndpointBinding { @@ -10676,7 +10867,7 @@ type GetSandboxProviderEnvironmentResponse struct { func (x *GetSandboxProviderEnvironmentResponse) Reset() { *x = GetSandboxProviderEnvironmentResponse{} - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[136] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10688,7 +10879,7 @@ func (x *GetSandboxProviderEnvironmentResponse) String() string { func (*GetSandboxProviderEnvironmentResponse) ProtoMessage() {} func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[135] + mi := &file_openshell_proto_msgTypes[136] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10701,7 +10892,7 @@ func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead. func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{135} + return file_openshell_proto_rawDescGZIP(), []int{136} } func (x *GetSandboxProviderEnvironmentResponse) GetEnvironment() map[string]string { @@ -10784,7 +10975,7 @@ type ExchangeProviderSubjectTokenRequest struct { func (x *ExchangeProviderSubjectTokenRequest) Reset() { *x = ExchangeProviderSubjectTokenRequest{} - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[137] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10796,7 +10987,7 @@ func (x *ExchangeProviderSubjectTokenRequest) String() string { func (*ExchangeProviderSubjectTokenRequest) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[136] + mi := &file_openshell_proto_msgTypes[137] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10809,7 +11000,7 @@ func (x *ExchangeProviderSubjectTokenRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use ExchangeProviderSubjectTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{136} + return file_openshell_proto_rawDescGZIP(), []int{137} } func (x *ExchangeProviderSubjectTokenRequest) GetSandboxId() string { @@ -10851,7 +11042,7 @@ type ExchangeProviderSubjectTokenResponse struct { func (x *ExchangeProviderSubjectTokenResponse) Reset() { *x = ExchangeProviderSubjectTokenResponse{} - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[138] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10863,7 +11054,7 @@ func (x *ExchangeProviderSubjectTokenResponse) String() string { func (*ExchangeProviderSubjectTokenResponse) ProtoMessage() {} func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[137] + mi := &file_openshell_proto_msgTypes[138] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10876,7 +11067,7 @@ func (x *ExchangeProviderSubjectTokenResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use ExchangeProviderSubjectTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeProviderSubjectTokenResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{137} + return file_openshell_proto_rawDescGZIP(), []int{138} } func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { @@ -10949,7 +11140,7 @@ type UpdateConfigRequest struct { func (x *UpdateConfigRequest) Reset() { *x = UpdateConfigRequest{} - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[139] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10961,7 +11152,7 @@ func (x *UpdateConfigRequest) String() string { func (*UpdateConfigRequest) ProtoMessage() {} func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[138] + mi := &file_openshell_proto_msgTypes[139] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10974,7 +11165,7 @@ func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigRequest.ProtoReflect.Descriptor instead. func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{138} + return file_openshell_proto_rawDescGZIP(), []int{139} } func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -11071,7 +11262,7 @@ type PolicyMergeOperation struct { func (x *PolicyMergeOperation) Reset() { *x = PolicyMergeOperation{} - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[140] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11083,7 +11274,7 @@ func (x *PolicyMergeOperation) String() string { func (*PolicyMergeOperation) ProtoMessage() {} func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[139] + mi := &file_openshell_proto_msgTypes[140] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11096,7 +11287,7 @@ func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyMergeOperation.ProtoReflect.Descriptor instead. func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{139} + return file_openshell_proto_rawDescGZIP(), []int{140} } func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { @@ -11210,7 +11401,7 @@ type AddNetworkRule struct { func (x *AddNetworkRule) Reset() { *x = AddNetworkRule{} - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[141] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11222,7 +11413,7 @@ func (x *AddNetworkRule) String() string { func (*AddNetworkRule) ProtoMessage() {} func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[140] + mi := &file_openshell_proto_msgTypes[141] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11235,7 +11426,7 @@ func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use AddNetworkRule.ProtoReflect.Descriptor instead. func (*AddNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{140} + return file_openshell_proto_rawDescGZIP(), []int{141} } func (x *AddNetworkRule) GetRuleName() string { @@ -11263,7 +11454,7 @@ type RemoveNetworkEndpoint struct { func (x *RemoveNetworkEndpoint) Reset() { *x = RemoveNetworkEndpoint{} - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[142] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11275,7 +11466,7 @@ func (x *RemoveNetworkEndpoint) String() string { func (*RemoveNetworkEndpoint) ProtoMessage() {} func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[141] + mi := &file_openshell_proto_msgTypes[142] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11288,7 +11479,7 @@ func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{141} + return file_openshell_proto_rawDescGZIP(), []int{142} } func (x *RemoveNetworkEndpoint) GetRuleName() string { @@ -11321,7 +11512,7 @@ type RemoveNetworkRule struct { func (x *RemoveNetworkRule) Reset() { *x = RemoveNetworkRule{} - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[143] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11333,7 +11524,7 @@ func (x *RemoveNetworkRule) String() string { func (*RemoveNetworkRule) ProtoMessage() {} func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[142] + mi := &file_openshell_proto_msgTypes[143] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11346,7 +11537,7 @@ func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkRule.ProtoReflect.Descriptor instead. func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{142} + return file_openshell_proto_rawDescGZIP(), []int{143} } func (x *RemoveNetworkRule) GetRuleName() string { @@ -11375,7 +11566,7 @@ type L7RuleTarget struct { func (x *L7RuleTarget) Reset() { *x = L7RuleTarget{} - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[144] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11387,7 +11578,7 @@ func (x *L7RuleTarget) String() string { func (*L7RuleTarget) ProtoMessage() {} func (x *L7RuleTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[143] + mi := &file_openshell_proto_msgTypes[144] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11400,7 +11591,7 @@ func (x *L7RuleTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RuleTarget.ProtoReflect.Descriptor instead. func (*L7RuleTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{143} + return file_openshell_proto_rawDescGZIP(), []int{144} } func (x *L7RuleTarget) GetRuleName() string { @@ -11455,7 +11646,7 @@ type AddDenyRules struct { func (x *AddDenyRules) Reset() { *x = AddDenyRules{} - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[145] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11467,7 +11658,7 @@ func (x *AddDenyRules) String() string { func (*AddDenyRules) ProtoMessage() {} func (x *AddDenyRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[144] + mi := &file_openshell_proto_msgTypes[145] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11480,7 +11671,7 @@ func (x *AddDenyRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddDenyRules.ProtoReflect.Descriptor instead. func (*AddDenyRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{144} + return file_openshell_proto_rawDescGZIP(), []int{145} } func (x *AddDenyRules) GetDenyRules() []*sandboxv1.L7DenyRule { @@ -11507,7 +11698,7 @@ type AddAllowRules struct { func (x *AddAllowRules) Reset() { *x = AddAllowRules{} - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[146] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11519,7 +11710,7 @@ func (x *AddAllowRules) String() string { func (*AddAllowRules) ProtoMessage() {} func (x *AddAllowRules) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[145] + mi := &file_openshell_proto_msgTypes[146] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11532,7 +11723,7 @@ func (x *AddAllowRules) ProtoReflect() protoreflect.Message { // Deprecated: Use AddAllowRules.ProtoReflect.Descriptor instead. func (*AddAllowRules) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{145} + return file_openshell_proto_rawDescGZIP(), []int{146} } func (x *AddAllowRules) GetRules() []*sandboxv1.L7Rule { @@ -11559,7 +11750,7 @@ type RemoveNetworkBinary struct { func (x *RemoveNetworkBinary) Reset() { *x = RemoveNetworkBinary{} - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[147] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11571,7 +11762,7 @@ func (x *RemoveNetworkBinary) String() string { func (*RemoveNetworkBinary) ProtoMessage() {} func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[146] + mi := &file_openshell_proto_msgTypes[147] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11584,7 +11775,7 @@ func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveNetworkBinary.ProtoReflect.Descriptor instead. func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{146} + return file_openshell_proto_rawDescGZIP(), []int{147} } func (x *RemoveNetworkBinary) GetRuleName() string { @@ -11620,7 +11811,7 @@ type UpdateConfigResponse struct { func (x *UpdateConfigResponse) Reset() { *x = UpdateConfigResponse{} - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[148] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11632,7 +11823,7 @@ func (x *UpdateConfigResponse) String() string { func (*UpdateConfigResponse) ProtoMessage() {} func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[147] + mi := &file_openshell_proto_msgTypes[148] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11645,7 +11836,7 @@ func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConfigResponse.ProtoReflect.Descriptor instead. func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{147} + return file_openshell_proto_rawDescGZIP(), []int{148} } func (x *UpdateConfigResponse) GetVersion() uint32 { @@ -11699,7 +11890,7 @@ type GetSandboxPolicyStatusRequest struct { func (x *GetSandboxPolicyStatusRequest) Reset() { *x = GetSandboxPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[149] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11711,7 +11902,7 @@ func (x *GetSandboxPolicyStatusRequest) String() string { func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[148] + mi := &file_openshell_proto_msgTypes[149] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11724,7 +11915,7 @@ func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{148} + return file_openshell_proto_rawDescGZIP(), []int{149} } func (x *GetSandboxPolicyStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -11768,7 +11959,7 @@ type GetSandboxPolicyStatusResponse struct { func (x *GetSandboxPolicyStatusResponse) Reset() { *x = GetSandboxPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[150] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11780,7 +11971,7 @@ func (x *GetSandboxPolicyStatusResponse) String() string { func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[149] + mi := &file_openshell_proto_msgTypes[150] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11793,7 +11984,7 @@ func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{149} + return file_openshell_proto_rawDescGZIP(), []int{150} } func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { @@ -11830,7 +12021,7 @@ type ListSandboxPoliciesRequest struct { func (x *ListSandboxPoliciesRequest) Reset() { *x = ListSandboxPoliciesRequest{} - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[151] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11842,7 +12033,7 @@ func (x *ListSandboxPoliciesRequest) String() string { func (*ListSandboxPoliciesRequest) ProtoMessage() {} func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[150] + mi := &file_openshell_proto_msgTypes[151] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11855,7 +12046,7 @@ func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{150} + return file_openshell_proto_rawDescGZIP(), []int{151} } func (x *ListSandboxPoliciesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -11907,7 +12098,7 @@ type ListSandboxPoliciesResponse struct { func (x *ListSandboxPoliciesResponse) Reset() { *x = ListSandboxPoliciesResponse{} - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[152] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11919,7 +12110,7 @@ func (x *ListSandboxPoliciesResponse) String() string { func (*ListSandboxPoliciesResponse) ProtoMessage() {} func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[151] + mi := &file_openshell_proto_msgTypes[152] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11932,7 +12123,7 @@ func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{151} + return file_openshell_proto_rawDescGZIP(), []int{152} } func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { @@ -11966,7 +12157,7 @@ type ReportPolicyStatusRequest struct { func (x *ReportPolicyStatusRequest) Reset() { *x = ReportPolicyStatusRequest{} - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[153] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -11978,7 +12169,7 @@ func (x *ReportPolicyStatusRequest) String() string { func (*ReportPolicyStatusRequest) ProtoMessage() {} func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[152] + mi := &file_openshell_proto_msgTypes[153] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -11991,7 +12182,7 @@ func (x *ReportPolicyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{152} + return file_openshell_proto_rawDescGZIP(), []int{153} } func (x *ReportPolicyStatusRequest) GetSandboxId() string { @@ -12031,7 +12222,7 @@ type ReportPolicyStatusResponse struct { func (x *ReportPolicyStatusResponse) Reset() { *x = ReportPolicyStatusResponse{} - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[154] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12043,7 +12234,7 @@ func (x *ReportPolicyStatusResponse) String() string { func (*ReportPolicyStatusResponse) ProtoMessage() {} func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[153] + mi := &file_openshell_proto_msgTypes[154] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12056,7 +12247,7 @@ func (x *ReportPolicyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{153} + return file_openshell_proto_rawDescGZIP(), []int{154} } type SandboxConfigurationAdmission struct { @@ -12074,7 +12265,7 @@ type SandboxConfigurationAdmission struct { func (x *SandboxConfigurationAdmission) Reset() { *x = SandboxConfigurationAdmission{} - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[155] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12086,7 +12277,7 @@ func (x *SandboxConfigurationAdmission) String() string { func (*SandboxConfigurationAdmission) ProtoMessage() {} func (x *SandboxConfigurationAdmission) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[154] + mi := &file_openshell_proto_msgTypes[155] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12099,7 +12290,7 @@ func (x *SandboxConfigurationAdmission) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxConfigurationAdmission.ProtoReflect.Descriptor instead. func (*SandboxConfigurationAdmission) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{154} + return file_openshell_proto_rawDescGZIP(), []int{155} } func (x *SandboxConfigurationAdmission) GetInstanceId() string { @@ -12163,7 +12354,7 @@ type ReportSandboxConfigurationRequest struct { func (x *ReportSandboxConfigurationRequest) Reset() { *x = ReportSandboxConfigurationRequest{} - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[156] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12175,7 +12366,7 @@ func (x *ReportSandboxConfigurationRequest) String() string { func (*ReportSandboxConfigurationRequest) ProtoMessage() {} func (x *ReportSandboxConfigurationRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[155] + mi := &file_openshell_proto_msgTypes[156] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12188,7 +12379,7 @@ func (x *ReportSandboxConfigurationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ReportSandboxConfigurationRequest.ProtoReflect.Descriptor instead. func (*ReportSandboxConfigurationRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{155} + return file_openshell_proto_rawDescGZIP(), []int{156} } func (x *ReportSandboxConfigurationRequest) GetSandboxId() string { @@ -12220,7 +12411,7 @@ type ReportSandboxConfigurationResponse struct { func (x *ReportSandboxConfigurationResponse) Reset() { *x = ReportSandboxConfigurationResponse{} - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[157] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12232,7 +12423,7 @@ func (x *ReportSandboxConfigurationResponse) String() string { func (*ReportSandboxConfigurationResponse) ProtoMessage() {} func (x *ReportSandboxConfigurationResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[156] + mi := &file_openshell_proto_msgTypes[157] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12245,7 +12436,7 @@ func (x *ReportSandboxConfigurationResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ReportSandboxConfigurationResponse.ProtoReflect.Descriptor instead. func (*ReportSandboxConfigurationResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{156} + return file_openshell_proto_rawDescGZIP(), []int{157} } // A versioned policy revision with metadata. @@ -12278,7 +12469,7 @@ type SandboxPolicyRevision struct { func (x *SandboxPolicyRevision) Reset() { *x = SandboxPolicyRevision{} - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[158] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12290,7 +12481,7 @@ func (x *SandboxPolicyRevision) String() string { func (*SandboxPolicyRevision) ProtoMessage() {} func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[157] + mi := &file_openshell_proto_msgTypes[158] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12303,7 +12494,7 @@ func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxPolicyRevision.ProtoReflect.Descriptor instead. func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{157} + return file_openshell_proto_rawDescGZIP(), []int{158} } func (x *SandboxPolicyRevision) GetVersion() uint32 { @@ -12383,7 +12574,7 @@ type GetSandboxLogsRequest struct { func (x *GetSandboxLogsRequest) Reset() { *x = GetSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[159] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12395,7 +12586,7 @@ func (x *GetSandboxLogsRequest) String() string { func (*GetSandboxLogsRequest) ProtoMessage() {} func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[158] + mi := &file_openshell_proto_msgTypes[159] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12408,7 +12599,7 @@ func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{158} + return file_openshell_proto_rawDescGZIP(), []int{159} } func (x *GetSandboxLogsRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -12466,7 +12657,7 @@ type PushSandboxLogsRequest struct { func (x *PushSandboxLogsRequest) Reset() { *x = PushSandboxLogsRequest{} - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[160] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12478,7 +12669,7 @@ func (x *PushSandboxLogsRequest) String() string { func (*PushSandboxLogsRequest) ProtoMessage() {} func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[159] + mi := &file_openshell_proto_msgTypes[160] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12491,7 +12682,7 @@ func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsRequest.ProtoReflect.Descriptor instead. func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{159} + return file_openshell_proto_rawDescGZIP(), []int{160} } func (x *PushSandboxLogsRequest) GetSandboxId() string { @@ -12517,7 +12708,7 @@ type PushSandboxLogsResponse struct { func (x *PushSandboxLogsResponse) Reset() { *x = PushSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[161] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12529,7 +12720,7 @@ func (x *PushSandboxLogsResponse) String() string { func (*PushSandboxLogsResponse) ProtoMessage() {} func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[160] + mi := &file_openshell_proto_msgTypes[161] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12542,7 +12733,7 @@ func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PushSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{160} + return file_openshell_proto_rawDescGZIP(), []int{161} } // Get sandbox logs response. @@ -12558,7 +12749,7 @@ type GetSandboxLogsResponse struct { func (x *GetSandboxLogsResponse) Reset() { *x = GetSandboxLogsResponse{} - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[162] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12570,7 +12761,7 @@ func (x *GetSandboxLogsResponse) String() string { func (*GetSandboxLogsResponse) ProtoMessage() {} func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[161] + mi := &file_openshell_proto_msgTypes[162] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12583,7 +12774,7 @@ func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxLogsResponse.ProtoReflect.Descriptor instead. func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{161} + return file_openshell_proto_rawDescGZIP(), []int{162} } func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { @@ -12616,7 +12807,7 @@ type SupervisorMessage struct { func (x *SupervisorMessage) Reset() { *x = SupervisorMessage{} - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[163] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12628,7 +12819,7 @@ func (x *SupervisorMessage) String() string { func (*SupervisorMessage) ProtoMessage() {} func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[162] + mi := &file_openshell_proto_msgTypes[163] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12641,7 +12832,7 @@ func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMessage.ProtoReflect.Descriptor instead. func (*SupervisorMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{162} + return file_openshell_proto_rawDescGZIP(), []int{163} } func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { @@ -12732,7 +12923,7 @@ type GatewayMessage struct { func (x *GatewayMessage) Reset() { *x = GatewayMessage{} - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[164] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12744,7 +12935,7 @@ func (x *GatewayMessage) String() string { func (*GatewayMessage) ProtoMessage() {} func (x *GatewayMessage) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[163] + mi := &file_openshell_proto_msgTypes[164] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12757,7 +12948,7 @@ func (x *GatewayMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayMessage.ProtoReflect.Descriptor instead. func (*GatewayMessage) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{163} + return file_openshell_proto_rawDescGZIP(), []int{164} } func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { @@ -12853,6 +13044,9 @@ type SupervisorHello struct { SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` // Supervisor instance ID (e.g. boot id or process epoch). InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + // Monotonic counter scoped to instance_id. Incremented for each reconnect so + // gateways can distinguish a fresh supervisor connection from stale cleanup. + ConnectionEpoch uint64 `protobuf:"varint,3,opt,name=connection_epoch,json=connectionEpoch,proto3" json:"connection_epoch,omitempty"` // The supervisor can report credential, policy, and launch-environment installation. SupportsProviderReadiness bool `protobuf:"varint,4,opt,name=supports_provider_readiness,json=supportsProviderReadiness,proto3" json:"supports_provider_readiness,omitempty"` unknownFields protoimpl.UnknownFields @@ -12861,7 +13055,7 @@ type SupervisorHello struct { func (x *SupervisorHello) Reset() { *x = SupervisorHello{} - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[165] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12873,7 +13067,7 @@ func (x *SupervisorHello) String() string { func (*SupervisorHello) ProtoMessage() {} func (x *SupervisorHello) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[164] + mi := &file_openshell_proto_msgTypes[165] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12886,7 +13080,7 @@ func (x *SupervisorHello) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHello.ProtoReflect.Descriptor instead. func (*SupervisorHello) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{164} + return file_openshell_proto_rawDescGZIP(), []int{165} } func (x *SupervisorHello) GetSandboxId() string { @@ -12903,6 +13097,13 @@ func (x *SupervisorHello) GetInstanceId() string { return "" } +func (x *SupervisorHello) GetConnectionEpoch() uint64 { + if x != nil { + return x.ConnectionEpoch + } + return 0 +} + func (x *SupervisorHello) GetSupportsProviderReadiness() bool { if x != nil { return x.SupportsProviderReadiness @@ -12923,7 +13124,7 @@ type SessionAccepted struct { func (x *SessionAccepted) Reset() { *x = SessionAccepted{} - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[166] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12935,7 +13136,7 @@ func (x *SessionAccepted) String() string { func (*SessionAccepted) ProtoMessage() {} func (x *SessionAccepted) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[165] + mi := &file_openshell_proto_msgTypes[166] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -12948,7 +13149,7 @@ func (x *SessionAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionAccepted.ProtoReflect.Descriptor instead. func (*SessionAccepted) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{165} + return file_openshell_proto_rawDescGZIP(), []int{166} } func (x *SessionAccepted) GetSessionId() string { @@ -12976,7 +13177,7 @@ type SessionRejected struct { func (x *SessionRejected) Reset() { *x = SessionRejected{} - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[167] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -12988,7 +13189,7 @@ func (x *SessionRejected) String() string { func (*SessionRejected) ProtoMessage() {} func (x *SessionRejected) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[166] + mi := &file_openshell_proto_msgTypes[167] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13001,7 +13202,7 @@ func (x *SessionRejected) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRejected.ProtoReflect.Descriptor instead. func (*SessionRejected) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{166} + return file_openshell_proto_rawDescGZIP(), []int{167} } func (x *SessionRejected) GetReason() string { @@ -13020,7 +13221,7 @@ type SupervisorHeartbeat struct { func (x *SupervisorHeartbeat) Reset() { *x = SupervisorHeartbeat{} - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[168] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13032,7 +13233,7 @@ func (x *SupervisorHeartbeat) String() string { func (*SupervisorHeartbeat) ProtoMessage() {} func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[167] + mi := &file_openshell_proto_msgTypes[168] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13045,7 +13246,7 @@ func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorHeartbeat.ProtoReflect.Descriptor instead. func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{167} + return file_openshell_proto_rawDescGZIP(), []int{168} } // Gateway heartbeat. @@ -13057,7 +13258,7 @@ type GatewayHeartbeat struct { func (x *GatewayHeartbeat) Reset() { *x = GatewayHeartbeat{} - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[169] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13069,7 +13270,7 @@ func (x *GatewayHeartbeat) String() string { func (*GatewayHeartbeat) ProtoMessage() {} func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[168] + mi := &file_openshell_proto_msgTypes[169] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13082,7 +13283,7 @@ func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use GatewayHeartbeat.ProtoReflect.Descriptor instead. func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{168} + return file_openshell_proto_rawDescGZIP(), []int{169} } // Terminal result reported before the supervisor shuts down. A successful RPC @@ -13099,7 +13300,7 @@ type ReportMainProcessExitRequest struct { func (x *ReportMainProcessExitRequest) Reset() { *x = ReportMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[170] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13111,7 +13312,7 @@ func (x *ReportMainProcessExitRequest) String() string { func (*ReportMainProcessExitRequest) ProtoMessage() {} func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[169] + mi := &file_openshell_proto_msgTypes[170] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13124,7 +13325,7 @@ func (x *ReportMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{169} + return file_openshell_proto_rawDescGZIP(), []int{170} } func (x *ReportMainProcessExitRequest) GetSandboxId() string { @@ -13156,7 +13357,7 @@ type ReportMainProcessExitResponse struct { func (x *ReportMainProcessExitResponse) Reset() { *x = ReportMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[171] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13168,7 +13369,7 @@ func (x *ReportMainProcessExitResponse) String() string { func (*ReportMainProcessExitResponse) ProtoMessage() {} func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[170] + mi := &file_openshell_proto_msgTypes[171] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13181,7 +13382,7 @@ func (x *ReportMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*ReportMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{170} + return file_openshell_proto_rawDescGZIP(), []int{171} } // Terminal-delivery completion reported after all expected foreground SSH @@ -13196,7 +13397,7 @@ type FinalizeMainProcessExitRequest struct { func (x *FinalizeMainProcessExitRequest) Reset() { *x = FinalizeMainProcessExitRequest{} - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[172] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13208,7 +13409,7 @@ func (x *FinalizeMainProcessExitRequest) String() string { func (*FinalizeMainProcessExitRequest) ProtoMessage() {} func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[171] + mi := &file_openshell_proto_msgTypes[172] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13221,7 +13422,7 @@ func (x *FinalizeMainProcessExitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitRequest.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{171} + return file_openshell_proto_rawDescGZIP(), []int{172} } func (x *FinalizeMainProcessExitRequest) GetSandboxId() string { @@ -13246,7 +13447,7 @@ type FinalizeMainProcessExitResponse struct { func (x *FinalizeMainProcessExitResponse) Reset() { *x = FinalizeMainProcessExitResponse{} - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[173] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13258,7 +13459,7 @@ func (x *FinalizeMainProcessExitResponse) String() string { func (*FinalizeMainProcessExitResponse) ProtoMessage() {} func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[172] + mi := &file_openshell_proto_msgTypes[173] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13271,7 +13472,7 @@ func (x *FinalizeMainProcessExitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeMainProcessExitResponse.ProtoReflect.Descriptor instead. func (*FinalizeMainProcessExitResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{172} + return file_openshell_proto_rawDescGZIP(), []int{173} } // Gateway requests the supervisor to open a relay channel. @@ -13300,7 +13501,7 @@ type RelayOpen struct { func (x *RelayOpen) Reset() { *x = RelayOpen{} - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[174] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13312,7 +13513,7 @@ func (x *RelayOpen) String() string { func (*RelayOpen) ProtoMessage() {} func (x *RelayOpen) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[173] + mi := &file_openshell_proto_msgTypes[174] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13325,7 +13526,7 @@ func (x *RelayOpen) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpen.ProtoReflect.Descriptor instead. func (*RelayOpen) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{173} + return file_openshell_proto_rawDescGZIP(), []int{174} } func (x *RelayOpen) GetChannelId() string { @@ -13392,7 +13593,7 @@ type SshRelayTarget struct { func (x *SshRelayTarget) Reset() { *x = SshRelayTarget{} - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[175] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13404,7 +13605,7 @@ func (x *SshRelayTarget) String() string { func (*SshRelayTarget) ProtoMessage() {} func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[174] + mi := &file_openshell_proto_msgTypes[175] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13417,7 +13618,7 @@ func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use SshRelayTarget.ProtoReflect.Descriptor instead. func (*SshRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{174} + return file_openshell_proto_rawDescGZIP(), []int{175} } // TCP target dialed by the supervisor from inside the sandbox. @@ -13433,7 +13634,7 @@ type TcpRelayTarget struct { func (x *TcpRelayTarget) Reset() { *x = TcpRelayTarget{} - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[176] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13445,7 +13646,7 @@ func (x *TcpRelayTarget) String() string { func (*TcpRelayTarget) ProtoMessage() {} func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[175] + mi := &file_openshell_proto_msgTypes[176] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13458,7 +13659,7 @@ func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { // Deprecated: Use TcpRelayTarget.ProtoReflect.Descriptor instead. func (*TcpRelayTarget) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{175} + return file_openshell_proto_rawDescGZIP(), []int{176} } func (x *TcpRelayTarget) GetHost() string { @@ -13486,7 +13687,7 @@ type RelayInit struct { func (x *RelayInit) Reset() { *x = RelayInit{} - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[177] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13498,7 +13699,7 @@ func (x *RelayInit) String() string { func (*RelayInit) ProtoMessage() {} func (x *RelayInit) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[176] + mi := &file_openshell_proto_msgTypes[177] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13511,7 +13712,7 @@ func (x *RelayInit) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayInit.ProtoReflect.Descriptor instead. func (*RelayInit) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{176} + return file_openshell_proto_rawDescGZIP(), []int{177} } func (x *RelayInit) GetChannelId() string { @@ -13538,7 +13739,7 @@ type RelayFrame struct { func (x *RelayFrame) Reset() { *x = RelayFrame{} - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[178] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13550,7 +13751,7 @@ func (x *RelayFrame) String() string { func (*RelayFrame) ProtoMessage() {} func (x *RelayFrame) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[177] + mi := &file_openshell_proto_msgTypes[178] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13563,7 +13764,7 @@ func (x *RelayFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayFrame.ProtoReflect.Descriptor instead. func (*RelayFrame) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{177} + return file_openshell_proto_rawDescGZIP(), []int{178} } func (x *RelayFrame) GetPayload() isRelayFrame_Payload { @@ -13607,6 +13808,154 @@ func (*RelayFrame_Init) isRelayFrame_Payload() {} func (*RelayFrame_Data) isRelayFrame_Payload() {} +// Initial frame for gateway peer relay forwarding. +type PeerRelayInit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable sandbox UUID whose supervisor relay should be opened. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Relay target to ask the owning gateway to open on its local supervisor + // session. The channel_id is assigned by the forwarding gateway. + RelayOpen *RelayOpen `protobuf:"bytes,2,opt,name=relay_open,json=relayOpen,proto3" json:"relay_open,omitempty"` + // Gateway replica id that initiated the peer relay. + RequesterReplicaId string `protobuf:"bytes,3,opt,name=requester_replica_id,json=requesterReplicaId,proto3" json:"requester_replica_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerRelayInit) Reset() { + *x = PeerRelayInit{} + mi := &file_openshell_proto_msgTypes[179] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerRelayInit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerRelayInit) ProtoMessage() {} + +func (x *PeerRelayInit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[179] + 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 PeerRelayInit.ProtoReflect.Descriptor instead. +func (*PeerRelayInit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{179} +} + +func (x *PeerRelayInit) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PeerRelayInit) GetRelayOpen() *RelayOpen { + if x != nil { + return x.RelayOpen + } + return nil +} + +func (x *PeerRelayInit) GetRequesterReplicaId() string { + if x != nil { + return x.RequesterReplicaId + } + return "" +} + +// A single frame on the gateway-to-gateway peer relay RPC. +type PeerRelayFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *PeerRelayFrame_Init + // *PeerRelayFrame_Data + Payload isPeerRelayFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerRelayFrame) Reset() { + *x = PeerRelayFrame{} + mi := &file_openshell_proto_msgTypes[180] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerRelayFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerRelayFrame) ProtoMessage() {} + +func (x *PeerRelayFrame) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[180] + 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 PeerRelayFrame.ProtoReflect.Descriptor instead. +func (*PeerRelayFrame) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{180} +} + +func (x *PeerRelayFrame) GetPayload() isPeerRelayFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *PeerRelayFrame) GetInit() *PeerRelayInit { + if x != nil { + if x, ok := x.Payload.(*PeerRelayFrame_Init); ok { + return x.Init + } + } + return nil +} + +func (x *PeerRelayFrame) GetData() []byte { + if x != nil { + if x, ok := x.Payload.(*PeerRelayFrame_Data); ok { + return x.Data + } + } + return nil +} + +type isPeerRelayFrame_Payload interface { + isPeerRelayFrame_Payload() +} + +type PeerRelayFrame_Init struct { + Init *PeerRelayInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"` +} + +type PeerRelayFrame_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +func (*PeerRelayFrame_Init) isPeerRelayFrame_Payload() {} + +func (*PeerRelayFrame_Data) isPeerRelayFrame_Payload() {} + // Supervisor reports the result of a relay open request. type RelayOpenResult struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -13622,7 +13971,7 @@ type RelayOpenResult struct { func (x *RelayOpenResult) Reset() { *x = RelayOpenResult{} - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[181] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13634,7 +13983,7 @@ func (x *RelayOpenResult) String() string { func (*RelayOpenResult) ProtoMessage() {} func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[178] + mi := &file_openshell_proto_msgTypes[181] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13647,7 +13996,7 @@ func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayOpenResult.ProtoReflect.Descriptor instead. func (*RelayOpenResult) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{178} + return file_openshell_proto_rawDescGZIP(), []int{181} } func (x *RelayOpenResult) GetChannelId() string { @@ -13684,7 +14033,7 @@ type RelayClose struct { func (x *RelayClose) Reset() { *x = RelayClose{} - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[182] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13696,7 +14045,7 @@ func (x *RelayClose) String() string { func (*RelayClose) ProtoMessage() {} func (x *RelayClose) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[179] + mi := &file_openshell_proto_msgTypes[182] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13709,7 +14058,7 @@ func (x *RelayClose) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayClose.ProtoReflect.Descriptor instead. func (*RelayClose) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{179} + return file_openshell_proto_rawDescGZIP(), []int{182} } func (x *RelayClose) GetChannelId() string { @@ -13743,7 +14092,7 @@ type L7RequestSample struct { func (x *L7RequestSample) Reset() { *x = L7RequestSample{} - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[183] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13755,7 +14104,7 @@ func (x *L7RequestSample) String() string { func (*L7RequestSample) ProtoMessage() {} func (x *L7RequestSample) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[180] + mi := &file_openshell_proto_msgTypes[183] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13768,7 +14117,7 @@ func (x *L7RequestSample) ProtoReflect() protoreflect.Message { // Deprecated: Use L7RequestSample.ProtoReflect.Descriptor instead. func (*L7RequestSample) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{180} + return file_openshell_proto_rawDescGZIP(), []int{183} } func (x *L7RequestSample) GetMethod() string { @@ -13842,7 +14191,7 @@ type DenialSummary struct { func (x *DenialSummary) Reset() { *x = DenialSummary{} - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[184] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -13854,7 +14203,7 @@ func (x *DenialSummary) String() string { func (*DenialSummary) ProtoMessage() {} func (x *DenialSummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[181] + mi := &file_openshell_proto_msgTypes[184] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -13867,7 +14216,7 @@ func (x *DenialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialSummary.ProtoReflect.Descriptor instead. func (*DenialSummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{181} + return file_openshell_proto_rawDescGZIP(), []int{184} } func (x *DenialSummary) GetSandboxId() string { @@ -14002,7 +14351,7 @@ type DenialGroupCount struct { func (x *DenialGroupCount) Reset() { *x = DenialGroupCount{} - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[185] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14014,7 +14363,7 @@ func (x *DenialGroupCount) String() string { func (*DenialGroupCount) ProtoMessage() {} func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[182] + mi := &file_openshell_proto_msgTypes[185] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14027,7 +14376,7 @@ func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { // Deprecated: Use DenialGroupCount.ProtoReflect.Descriptor instead. func (*DenialGroupCount) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{182} + return file_openshell_proto_rawDescGZIP(), []int{185} } func (x *DenialGroupCount) GetDenyGroup() string { @@ -14060,7 +14409,7 @@ type NetworkActivitySummary struct { func (x *NetworkActivitySummary) Reset() { *x = NetworkActivitySummary{} - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[186] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14072,7 +14421,7 @@ func (x *NetworkActivitySummary) String() string { func (*NetworkActivitySummary) ProtoMessage() {} func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[183] + mi := &file_openshell_proto_msgTypes[186] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14085,7 +14434,7 @@ func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkActivitySummary.ProtoReflect.Descriptor instead. func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{183} + return file_openshell_proto_rawDescGZIP(), []int{186} } func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { @@ -14173,7 +14522,7 @@ type PolicyChunk struct { func (x *PolicyChunk) Reset() { *x = PolicyChunk{} - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[187] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14185,7 +14534,7 @@ func (x *PolicyChunk) String() string { func (*PolicyChunk) ProtoMessage() {} func (x *PolicyChunk) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[184] + mi := &file_openshell_proto_msgTypes[187] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14198,7 +14547,7 @@ func (x *PolicyChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyChunk.ProtoReflect.Descriptor instead. func (*PolicyChunk) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{184} + return file_openshell_proto_rawDescGZIP(), []int{187} } func (x *PolicyChunk) GetId() string { @@ -14386,7 +14735,7 @@ type DraftPolicyUpdate struct { func (x *DraftPolicyUpdate) Reset() { *x = DraftPolicyUpdate{} - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[188] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14398,7 +14747,7 @@ func (x *DraftPolicyUpdate) String() string { func (*DraftPolicyUpdate) ProtoMessage() {} func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[185] + mi := &file_openshell_proto_msgTypes[188] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14411,7 +14760,7 @@ func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftPolicyUpdate.ProtoReflect.Descriptor instead. func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{185} + return file_openshell_proto_rawDescGZIP(), []int{188} } func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { @@ -14470,7 +14819,7 @@ type SubmitPolicyAnalysisRequest struct { func (x *SubmitPolicyAnalysisRequest) Reset() { *x = SubmitPolicyAnalysisRequest{} - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[189] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14482,7 +14831,7 @@ func (x *SubmitPolicyAnalysisRequest) String() string { func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[186] + mi := &file_openshell_proto_msgTypes[189] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14495,7 +14844,7 @@ func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{186} + return file_openshell_proto_rawDescGZIP(), []int{189} } func (x *SubmitPolicyAnalysisRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -14558,7 +14907,7 @@ type SubmitPolicyAnalysisResponse struct { func (x *SubmitPolicyAnalysisResponse) Reset() { *x = SubmitPolicyAnalysisResponse{} - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[190] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14570,7 +14919,7 @@ func (x *SubmitPolicyAnalysisResponse) String() string { func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[187] + mi := &file_openshell_proto_msgTypes[190] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14583,7 +14932,7 @@ func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{187} + return file_openshell_proto_rawDescGZIP(), []int{190} } func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { @@ -14628,7 +14977,7 @@ type GetDraftPolicyRequest struct { func (x *GetDraftPolicyRequest) Reset() { *x = GetDraftPolicyRequest{} - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[191] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14640,7 +14989,7 @@ func (x *GetDraftPolicyRequest) String() string { func (*GetDraftPolicyRequest) ProtoMessage() {} func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[188] + mi := &file_openshell_proto_msgTypes[191] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14653,7 +15002,7 @@ func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyRequest.ProtoReflect.Descriptor instead. func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{188} + return file_openshell_proto_rawDescGZIP(), []int{191} } func (x *GetDraftPolicyRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -14693,7 +15042,7 @@ type GetDraftPolicyResponse struct { func (x *GetDraftPolicyResponse) Reset() { *x = GetDraftPolicyResponse{} - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[192] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14705,7 +15054,7 @@ func (x *GetDraftPolicyResponse) String() string { func (*GetDraftPolicyResponse) ProtoMessage() {} func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[189] + mi := &file_openshell_proto_msgTypes[192] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14718,7 +15067,7 @@ func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftPolicyResponse.ProtoReflect.Descriptor instead. func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{189} + return file_openshell_proto_rawDescGZIP(), []int{192} } func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { @@ -14769,7 +15118,7 @@ type ApproveDraftChunkRequest struct { func (x *ApproveDraftChunkRequest) Reset() { *x = ApproveDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[193] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14781,7 +15130,7 @@ func (x *ApproveDraftChunkRequest) String() string { func (*ApproveDraftChunkRequest) ProtoMessage() {} func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[190] + mi := &file_openshell_proto_msgTypes[193] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14794,7 +15143,7 @@ func (x *ApproveDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{190} + return file_openshell_proto_rawDescGZIP(), []int{193} } func (x *ApproveDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -14844,7 +15193,7 @@ type ApproveDraftChunkResponse struct { func (x *ApproveDraftChunkResponse) Reset() { *x = ApproveDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[194] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14856,7 +15205,7 @@ func (x *ApproveDraftChunkResponse) String() string { func (*ApproveDraftChunkResponse) ProtoMessage() {} func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[191] + mi := &file_openshell_proto_msgTypes[194] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14869,7 +15218,7 @@ func (x *ApproveDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{191} + return file_openshell_proto_rawDescGZIP(), []int{194} } func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { @@ -14905,7 +15254,7 @@ type RejectDraftChunkRequest struct { func (x *RejectDraftChunkRequest) Reset() { *x = RejectDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[195] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14917,7 +15266,7 @@ func (x *RejectDraftChunkRequest) String() string { func (*RejectDraftChunkRequest) ProtoMessage() {} func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[192] + mi := &file_openshell_proto_msgTypes[195] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -14930,7 +15279,7 @@ func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkRequest.ProtoReflect.Descriptor instead. func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{192} + return file_openshell_proto_rawDescGZIP(), []int{195} } func (x *RejectDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -14976,7 +15325,7 @@ type RejectDraftChunkResponse struct { func (x *RejectDraftChunkResponse) Reset() { *x = RejectDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[196] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -14988,7 +15337,7 @@ func (x *RejectDraftChunkResponse) String() string { func (*RejectDraftChunkResponse) ProtoMessage() {} func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[193] + mi := &file_openshell_proto_msgTypes[196] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15001,7 +15350,7 @@ func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RejectDraftChunkResponse.ProtoReflect.Descriptor instead. func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{193} + return file_openshell_proto_rawDescGZIP(), []int{196} } // Approve all pending chunks. @@ -15015,7 +15364,7 @@ type DraftChunkApproval struct { func (x *DraftChunkApproval) Reset() { *x = DraftChunkApproval{} - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[197] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15027,7 +15376,7 @@ func (x *DraftChunkApproval) String() string { func (*DraftChunkApproval) ProtoMessage() {} func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[194] + mi := &file_openshell_proto_msgTypes[197] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15040,7 +15389,7 @@ func (x *DraftChunkApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftChunkApproval.ProtoReflect.Descriptor instead. func (*DraftChunkApproval) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{194} + return file_openshell_proto_rawDescGZIP(), []int{197} } func (x *DraftChunkApproval) GetChunkId() string { @@ -15076,7 +15425,7 @@ type ApproveAllDraftChunksRequest struct { func (x *ApproveAllDraftChunksRequest) Reset() { *x = ApproveAllDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[198] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15088,7 +15437,7 @@ func (x *ApproveAllDraftChunksRequest) String() string { func (*ApproveAllDraftChunksRequest) ProtoMessage() {} func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[195] + mi := &file_openshell_proto_msgTypes[198] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15101,7 +15450,7 @@ func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{195} + return file_openshell_proto_rawDescGZIP(), []int{198} } func (x *ApproveAllDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15156,7 +15505,7 @@ type ApproveAllDraftChunksResponse struct { func (x *ApproveAllDraftChunksResponse) Reset() { *x = ApproveAllDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[199] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15168,7 +15517,7 @@ func (x *ApproveAllDraftChunksResponse) String() string { func (*ApproveAllDraftChunksResponse) ProtoMessage() {} func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[196] + mi := &file_openshell_proto_msgTypes[199] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15181,7 +15530,7 @@ func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{196} + return file_openshell_proto_rawDescGZIP(), []int{199} } func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { @@ -15231,7 +15580,7 @@ type EditDraftChunkRequest struct { func (x *EditDraftChunkRequest) Reset() { *x = EditDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[200] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15243,7 +15592,7 @@ func (x *EditDraftChunkRequest) String() string { func (*EditDraftChunkRequest) ProtoMessage() {} func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[197] + mi := &file_openshell_proto_msgTypes[200] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15256,7 +15605,7 @@ func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkRequest.ProtoReflect.Descriptor instead. func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{197} + return file_openshell_proto_rawDescGZIP(), []int{200} } func (x *EditDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15302,7 +15651,7 @@ type EditDraftChunkResponse struct { func (x *EditDraftChunkResponse) Reset() { *x = EditDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[201] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15314,7 +15663,7 @@ func (x *EditDraftChunkResponse) String() string { func (*EditDraftChunkResponse) ProtoMessage() {} func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[198] + mi := &file_openshell_proto_msgTypes[201] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15327,7 +15676,7 @@ func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EditDraftChunkResponse.ProtoReflect.Descriptor instead. func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{198} + return file_openshell_proto_rawDescGZIP(), []int{201} } // Reverse an approval (remove merged rule from active policy). @@ -15347,7 +15696,7 @@ type UndoDraftChunkRequest struct { func (x *UndoDraftChunkRequest) Reset() { *x = UndoDraftChunkRequest{} - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[202] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15359,7 +15708,7 @@ func (x *UndoDraftChunkRequest) String() string { func (*UndoDraftChunkRequest) ProtoMessage() {} func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[199] + mi := &file_openshell_proto_msgTypes[202] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15372,7 +15721,7 @@ func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkRequest.ProtoReflect.Descriptor instead. func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{199} + return file_openshell_proto_rawDescGZIP(), []int{202} } func (x *UndoDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15415,7 +15764,7 @@ type UndoDraftChunkResponse struct { func (x *UndoDraftChunkResponse) Reset() { *x = UndoDraftChunkResponse{} - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[203] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15427,7 +15776,7 @@ func (x *UndoDraftChunkResponse) String() string { func (*UndoDraftChunkResponse) ProtoMessage() {} func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[200] + mi := &file_openshell_proto_msgTypes[203] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15440,7 +15789,7 @@ func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UndoDraftChunkResponse.ProtoReflect.Descriptor instead. func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{200} + return file_openshell_proto_rawDescGZIP(), []int{203} } func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { @@ -15472,7 +15821,7 @@ type ClearDraftChunksRequest struct { func (x *ClearDraftChunksRequest) Reset() { *x = ClearDraftChunksRequest{} - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[204] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15484,7 +15833,7 @@ func (x *ClearDraftChunksRequest) String() string { func (*ClearDraftChunksRequest) ProtoMessage() {} func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[201] + mi := &file_openshell_proto_msgTypes[204] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15497,7 +15846,7 @@ func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksRequest.ProtoReflect.Descriptor instead. func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{201} + return file_openshell_proto_rawDescGZIP(), []int{204} } func (x *ClearDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15531,7 +15880,7 @@ type ClearDraftChunksResponse struct { func (x *ClearDraftChunksResponse) Reset() { *x = ClearDraftChunksResponse{} - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[205] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15543,7 +15892,7 @@ func (x *ClearDraftChunksResponse) String() string { func (*ClearDraftChunksResponse) ProtoMessage() {} func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[202] + mi := &file_openshell_proto_msgTypes[205] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15556,7 +15905,7 @@ func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearDraftChunksResponse.ProtoReflect.Descriptor instead. func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{202} + return file_openshell_proto_rawDescGZIP(), []int{205} } func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { @@ -15578,7 +15927,7 @@ type GetDraftHistoryRequest struct { func (x *GetDraftHistoryRequest) Reset() { *x = GetDraftHistoryRequest{} - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[206] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15590,7 +15939,7 @@ func (x *GetDraftHistoryRequest) String() string { func (*GetDraftHistoryRequest) ProtoMessage() {} func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[203] + mi := &file_openshell_proto_msgTypes[206] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15603,7 +15952,7 @@ func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryRequest.ProtoReflect.Descriptor instead. func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{203} + return file_openshell_proto_rawDescGZIP(), []int{206} } func (x *GetDraftHistoryRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -15637,7 +15986,7 @@ type DraftHistoryEntry struct { func (x *DraftHistoryEntry) Reset() { *x = DraftHistoryEntry{} - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[207] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15649,7 +15998,7 @@ func (x *DraftHistoryEntry) String() string { func (*DraftHistoryEntry) ProtoMessage() {} func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[204] + mi := &file_openshell_proto_msgTypes[207] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15662,7 +16011,7 @@ func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use DraftHistoryEntry.ProtoReflect.Descriptor instead. func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{204} + return file_openshell_proto_rawDescGZIP(), []int{207} } func (x *DraftHistoryEntry) GetEventTime() *timestamppb.Timestamp { @@ -15703,7 +16052,7 @@ type GetDraftHistoryResponse struct { func (x *GetDraftHistoryResponse) Reset() { *x = GetDraftHistoryResponse{} - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[208] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15715,7 +16064,7 @@ func (x *GetDraftHistoryResponse) String() string { func (*GetDraftHistoryResponse) ProtoMessage() {} func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[205] + mi := &file_openshell_proto_msgTypes[208] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15728,7 +16077,7 @@ func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDraftHistoryResponse.ProtoReflect.Descriptor instead. func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{205} + return file_openshell_proto_rawDescGZIP(), []int{208} } func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { @@ -15753,7 +16102,7 @@ type CreateWorkspaceRequest struct { func (x *CreateWorkspaceRequest) Reset() { *x = CreateWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[209] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15765,7 +16114,7 @@ func (x *CreateWorkspaceRequest) String() string { func (*CreateWorkspaceRequest) ProtoMessage() {} func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[206] + mi := &file_openshell_proto_msgTypes[209] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15778,7 +16127,7 @@ func (x *CreateWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceRequest.ProtoReflect.Descriptor instead. func (*CreateWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{206} + return file_openshell_proto_rawDescGZIP(), []int{209} } func (x *CreateWorkspaceRequest) GetName() string { @@ -15812,7 +16161,7 @@ type CreateWorkspaceResponse struct { func (x *CreateWorkspaceResponse) Reset() { *x = CreateWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[210] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15824,7 +16173,7 @@ func (x *CreateWorkspaceResponse) String() string { func (*CreateWorkspaceResponse) ProtoMessage() {} func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[207] + mi := &file_openshell_proto_msgTypes[210] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15837,7 +16186,7 @@ func (x *CreateWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateWorkspaceResponse.ProtoReflect.Descriptor instead. func (*CreateWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{207} + return file_openshell_proto_rawDescGZIP(), []int{210} } func (x *CreateWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -15858,7 +16207,7 @@ type GetWorkspaceRequest struct { func (x *GetWorkspaceRequest) Reset() { *x = GetWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[211] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15870,7 +16219,7 @@ func (x *GetWorkspaceRequest) String() string { func (*GetWorkspaceRequest) ProtoMessage() {} func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[208] + mi := &file_openshell_proto_msgTypes[211] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15883,7 +16232,7 @@ func (x *GetWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceRequest.ProtoReflect.Descriptor instead. func (*GetWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{208} + return file_openshell_proto_rawDescGZIP(), []int{211} } func (x *GetWorkspaceRequest) GetName() string { @@ -15903,7 +16252,7 @@ type GetWorkspaceResponse struct { func (x *GetWorkspaceResponse) Reset() { *x = GetWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[212] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15915,7 +16264,7 @@ func (x *GetWorkspaceResponse) String() string { func (*GetWorkspaceResponse) ProtoMessage() {} func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[209] + mi := &file_openshell_proto_msgTypes[212] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15928,7 +16277,7 @@ func (x *GetWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkspaceResponse.ProtoReflect.Descriptor instead. func (*GetWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{209} + return file_openshell_proto_rawDescGZIP(), []int{212} } func (x *GetWorkspaceResponse) GetWorkspace() *datamodelv1.Workspace { @@ -15955,7 +16304,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[213] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -15967,7 +16316,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[210] + mi := &file_openshell_proto_msgTypes[213] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -15980,7 +16329,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{210} + return file_openshell_proto_rawDescGZIP(), []int{213} } func (x *ListWorkspacesRequest) GetPageSize() int32 { @@ -16016,7 +16365,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[214] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16028,7 +16377,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[211] + mi := &file_openshell_proto_msgTypes[214] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16041,7 +16390,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{211} + return file_openshell_proto_rawDescGZIP(), []int{214} } func (x *ListWorkspacesResponse) GetWorkspaces() []*datamodelv1.Workspace { @@ -16072,7 +16421,7 @@ type DeleteWorkspaceRequest struct { func (x *DeleteWorkspaceRequest) Reset() { *x = DeleteWorkspaceRequest{} - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[215] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16084,7 +16433,7 @@ func (x *DeleteWorkspaceRequest) String() string { func (*DeleteWorkspaceRequest) ProtoMessage() {} func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[212] + mi := &file_openshell_proto_msgTypes[215] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16097,7 +16446,7 @@ func (x *DeleteWorkspaceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceRequest.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{212} + return file_openshell_proto_rawDescGZIP(), []int{215} } func (x *DeleteWorkspaceRequest) GetName() string { @@ -16131,7 +16480,7 @@ type DeleteWorkspaceResponse struct { func (x *DeleteWorkspaceResponse) Reset() { *x = DeleteWorkspaceResponse{} - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[216] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16143,7 +16492,7 @@ func (x *DeleteWorkspaceResponse) String() string { func (*DeleteWorkspaceResponse) ProtoMessage() {} func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[213] + mi := &file_openshell_proto_msgTypes[216] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16156,7 +16505,7 @@ func (x *DeleteWorkspaceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteWorkspaceResponse.ProtoReflect.Descriptor instead. func (*DeleteWorkspaceResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{213} + return file_openshell_proto_rawDescGZIP(), []int{216} } func (x *DeleteWorkspaceResponse) GetOutcome() DeletionOutcome { @@ -16180,7 +16529,7 @@ type WorkspaceMember struct { func (x *WorkspaceMember) Reset() { *x = WorkspaceMember{} - mi := &file_openshell_proto_msgTypes[214] + mi := &file_openshell_proto_msgTypes[217] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16192,7 +16541,7 @@ func (x *WorkspaceMember) String() string { func (*WorkspaceMember) ProtoMessage() {} func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[214] + mi := &file_openshell_proto_msgTypes[217] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16205,7 +16554,7 @@ func (x *WorkspaceMember) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceMember.ProtoReflect.Descriptor instead. func (*WorkspaceMember) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{214} + return file_openshell_proto_rawDescGZIP(), []int{217} } func (x *WorkspaceMember) GetMetadata() *datamodelv1.ObjectMeta { @@ -16246,7 +16595,7 @@ type AddWorkspaceMemberRequest struct { func (x *AddWorkspaceMemberRequest) Reset() { *x = AddWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[215] + mi := &file_openshell_proto_msgTypes[218] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16258,7 +16607,7 @@ func (x *AddWorkspaceMemberRequest) String() string { func (*AddWorkspaceMemberRequest) ProtoMessage() {} func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[215] + mi := &file_openshell_proto_msgTypes[218] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16271,7 +16620,7 @@ func (x *AddWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{215} + return file_openshell_proto_rawDescGZIP(), []int{218} } func (x *AddWorkspaceMemberRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16312,7 +16661,7 @@ type AddWorkspaceMemberResponse struct { func (x *AddWorkspaceMemberResponse) Reset() { *x = AddWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[216] + mi := &file_openshell_proto_msgTypes[219] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16324,7 +16673,7 @@ func (x *AddWorkspaceMemberResponse) String() string { func (*AddWorkspaceMemberResponse) ProtoMessage() {} func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[216] + mi := &file_openshell_proto_msgTypes[219] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16337,7 +16686,7 @@ func (x *AddWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*AddWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{216} + return file_openshell_proto_rawDescGZIP(), []int{219} } func (x *AddWorkspaceMemberResponse) GetMember() *WorkspaceMember { @@ -16363,7 +16712,7 @@ type RemoveWorkspaceMemberRequest struct { func (x *RemoveWorkspaceMemberRequest) Reset() { *x = RemoveWorkspaceMemberRequest{} - mi := &file_openshell_proto_msgTypes[217] + mi := &file_openshell_proto_msgTypes[220] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16375,7 +16724,7 @@ func (x *RemoveWorkspaceMemberRequest) String() string { func (*RemoveWorkspaceMemberRequest) ProtoMessage() {} func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[217] + mi := &file_openshell_proto_msgTypes[220] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16388,7 +16737,7 @@ func (x *RemoveWorkspaceMemberRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberRequest.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{217} + return file_openshell_proto_rawDescGZIP(), []int{220} } func (x *RemoveWorkspaceMemberRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16429,7 +16778,7 @@ type RemoveWorkspaceMemberResponse struct { func (x *RemoveWorkspaceMemberResponse) Reset() { *x = RemoveWorkspaceMemberResponse{} - mi := &file_openshell_proto_msgTypes[218] + mi := &file_openshell_proto_msgTypes[221] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16441,7 +16790,7 @@ func (x *RemoveWorkspaceMemberResponse) String() string { func (*RemoveWorkspaceMemberResponse) ProtoMessage() {} func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[218] + mi := &file_openshell_proto_msgTypes[221] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16454,7 +16803,7 @@ func (x *RemoveWorkspaceMemberResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveWorkspaceMemberResponse.ProtoReflect.Descriptor instead. func (*RemoveWorkspaceMemberResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{218} + return file_openshell_proto_rawDescGZIP(), []int{221} } func (x *RemoveWorkspaceMemberResponse) GetOutcome() DeletionOutcome { @@ -16481,7 +16830,7 @@ type ListWorkspaceMembersRequest struct { func (x *ListWorkspaceMembersRequest) Reset() { *x = ListWorkspaceMembersRequest{} - mi := &file_openshell_proto_msgTypes[219] + mi := &file_openshell_proto_msgTypes[222] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16493,7 +16842,7 @@ func (x *ListWorkspaceMembersRequest) String() string { func (*ListWorkspaceMembersRequest) ProtoMessage() {} func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[219] + mi := &file_openshell_proto_msgTypes[222] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16506,7 +16855,7 @@ func (x *ListWorkspaceMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersRequest.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{219} + return file_openshell_proto_rawDescGZIP(), []int{222} } func (x *ListWorkspaceMembersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -16542,7 +16891,7 @@ type ListWorkspaceMembersResponse struct { func (x *ListWorkspaceMembersResponse) Reset() { *x = ListWorkspaceMembersResponse{} - mi := &file_openshell_proto_msgTypes[220] + mi := &file_openshell_proto_msgTypes[223] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16554,7 +16903,7 @@ func (x *ListWorkspaceMembersResponse) String() string { func (*ListWorkspaceMembersResponse) ProtoMessage() {} func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[220] + mi := &file_openshell_proto_msgTypes[223] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16567,7 +16916,7 @@ func (x *ListWorkspaceMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspaceMembersResponse.ProtoReflect.Descriptor instead. func (*ListWorkspaceMembersResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{220} + return file_openshell_proto_rawDescGZIP(), []int{223} } func (x *ListWorkspaceMembersResponse) GetMembers() []*WorkspaceMember { @@ -16602,7 +16951,7 @@ type ExtensionServiceCredential struct { func (x *ExtensionServiceCredential) Reset() { *x = ExtensionServiceCredential{} - mi := &file_openshell_proto_msgTypes[221] + mi := &file_openshell_proto_msgTypes[224] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16614,7 +16963,7 @@ func (x *ExtensionServiceCredential) String() string { func (*ExtensionServiceCredential) ProtoMessage() {} func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[221] + mi := &file_openshell_proto_msgTypes[224] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16627,7 +16976,7 @@ func (x *ExtensionServiceCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionServiceCredential.ProtoReflect.Descriptor instead. func (*ExtensionServiceCredential) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{221} + return file_openshell_proto_rawDescGZIP(), []int{224} } func (x *ExtensionServiceCredential) GetServiceName() string { @@ -16664,7 +17013,7 @@ type EndpointObservation struct { func (x *EndpointObservation) Reset() { *x = EndpointObservation{} - mi := &file_openshell_proto_msgTypes[222] + mi := &file_openshell_proto_msgTypes[225] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16676,7 +17025,7 @@ func (x *EndpointObservation) String() string { func (*EndpointObservation) ProtoMessage() {} func (x *EndpointObservation) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[222] + mi := &file_openshell_proto_msgTypes[225] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16689,7 +17038,7 @@ func (x *EndpointObservation) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointObservation.ProtoReflect.Descriptor instead. func (*EndpointObservation) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{222} + return file_openshell_proto_rawDescGZIP(), []int{225} } func (x *EndpointObservation) GetEndpointId() string { @@ -16732,7 +17081,7 @@ type ReportEndpointStatusRequest struct { func (x *ReportEndpointStatusRequest) Reset() { *x = ReportEndpointStatusRequest{} - mi := &file_openshell_proto_msgTypes[223] + mi := &file_openshell_proto_msgTypes[226] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16744,7 +17093,7 @@ func (x *ReportEndpointStatusRequest) String() string { func (*ReportEndpointStatusRequest) ProtoMessage() {} func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[223] + mi := &file_openshell_proto_msgTypes[226] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16757,7 +17106,7 @@ func (x *ReportEndpointStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportEndpointStatusRequest.ProtoReflect.Descriptor instead. func (*ReportEndpointStatusRequest) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{223} + return file_openshell_proto_rawDescGZIP(), []int{226} } func (x *ReportEndpointStatusRequest) GetSandboxId() string { @@ -16818,7 +17167,7 @@ type ReportEndpointStatusResponse struct { func (x *ReportEndpointStatusResponse) Reset() { *x = ReportEndpointStatusResponse{} - mi := &file_openshell_proto_msgTypes[224] + mi := &file_openshell_proto_msgTypes[227] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16830,7 +17179,7 @@ func (x *ReportEndpointStatusResponse) String() string { func (*ReportEndpointStatusResponse) ProtoMessage() {} func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[224] + mi := &file_openshell_proto_msgTypes[227] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16843,7 +17192,7 @@ func (x *ReportEndpointStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportEndpointStatusResponse.ProtoReflect.Descriptor instead. func (*ReportEndpointStatusResponse) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{224} + return file_openshell_proto_rawDescGZIP(), []int{227} } // A configured endpoint and its last accepted network result in one record. @@ -16872,7 +17221,7 @@ type EndpointStatus struct { func (x *EndpointStatus) Reset() { *x = EndpointStatus{} - mi := &file_openshell_proto_msgTypes[225] + mi := &file_openshell_proto_msgTypes[228] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16884,7 +17233,7 @@ func (x *EndpointStatus) String() string { func (*EndpointStatus) ProtoMessage() {} func (x *EndpointStatus) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[225] + mi := &file_openshell_proto_msgTypes[228] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16897,7 +17246,7 @@ func (x *EndpointStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use EndpointStatus.ProtoReflect.Descriptor instead. func (*EndpointStatus) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{225} + return file_openshell_proto_rawDescGZIP(), []int{228} } func (x *EndpointStatus) GetEndpointId() string { @@ -16967,7 +17316,7 @@ type SandboxProvisioning struct { func (x *SandboxProvisioning) Reset() { *x = SandboxProvisioning{} - mi := &file_openshell_proto_msgTypes[226] + mi := &file_openshell_proto_msgTypes[229] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -16979,7 +17328,7 @@ func (x *SandboxProvisioning) String() string { func (*SandboxProvisioning) ProtoMessage() {} func (x *SandboxProvisioning) ProtoReflect() protoreflect.Message { - mi := &file_openshell_proto_msgTypes[226] + mi := &file_openshell_proto_msgTypes[229] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -16992,7 +17341,7 @@ func (x *SandboxProvisioning) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxProvisioning.ProtoReflect.Descriptor instead. func (*SandboxProvisioning) Descriptor() ([]byte, []int) { - return file_openshell_proto_rawDescGZIP(), []int{226} + return file_openshell_proto_rawDescGZIP(), []int{229} } func (x *SandboxProvisioning) GetAttemptId() string { @@ -17072,6 +17421,61 @@ func (x *SandboxProvisioning) GetAttachmentChangeTime() *timestamppb.Timestamp { return nil } +// Create-time request to expose one loopback HTTP service in a sandbox. +type SandboxServiceExposure struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Service name within the sandbox. Empty selects the unnamed endpoint. + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + // Loopback TCP port inside the sandbox. + TargetPort uint32 `protobuf:"varint,2,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxServiceExposure) Reset() { + *x = SandboxServiceExposure{} + mi := &file_openshell_proto_msgTypes[230] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxServiceExposure) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxServiceExposure) ProtoMessage() {} + +func (x *SandboxServiceExposure) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[230] + 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 SandboxServiceExposure.ProtoReflect.Descriptor instead. +func (*SandboxServiceExposure) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{230} +} + +func (x *SandboxServiceExposure) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *SandboxServiceExposure) GetTargetPort() uint32 { + if x != nil { + return x.TargetPort + } + return 0 +} + var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + @@ -17103,11 +17507,23 @@ const file_openshell_proto_rawDesc = "" + "\x05roles\x18\x03 \x03(\tR\x05roles\x12\x16\n" + "\x06scopes\x18\x04 \x03(\tR\x06scopes\x12+\n" + "\x11identity_provider\x18\x05 \x01(\tR\x10identityProvider\"\x17\n" + - "\x15GetGatewayInfoRequest\"\xc0\x01\n" + + "\x15GetGatewayInfoRequest\"\x87\x02\n" + "\x16GetGatewayInfoResponse\x123\n" + "\x06status\x18\x01 \x01(\x0e2\x1b.openshell.v1.ServiceStatusR\x06status\x12'\n" + "\x0fgateway_version\x18\x02 \x01(\tR\x0egatewayVersion\x12H\n" + - "\x0fcompute_drivers\x18\x03 \x03(\v2\x1f.openshell.v1.ComputeDriverInfoR\x0ecomputeDrivers\"t\n" + + "\x0fcompute_drivers\x18\x03 \x03(\v2\x1f.openshell.v1.ComputeDriverInfoR\x0ecomputeDrivers\x12E\n" + + "\n" + + "extensions\x18\x04 \x03(\v2%.openshell.v1.NegotiatedExtensionInfoR\n" + + "extensions\"\x95\x03\n" + + "\x17NegotiatedExtensionInfo\x12/\n" + + "\x04kind\x18\x01 \x01(\x0e2\x1b.openshell.v1.ExtensionKindR\x04kind\x12'\n" + + "\x0fconfigured_name\x18\x02 \x01(\tR\x0econfiguredName\x12/\n" + + "\x13implementation_name\x18\x03 \x01(\tR\x12implementationName\x125\n" + + "\x16implementation_version\x18\x04 \x01(\tR\x15implementationVersion\x12%\n" + + "\x0eprotocol_major\x18\x05 \x01(\rR\rprotocolMajor\x12%\n" + + "\x0eprotocol_minor\x18\x06 \x01(\rR\rprotocolMinor\x125\n" + + "\x16supported_capabilities\x18\a \x03(\tR\x15supportedCapabilities\x123\n" + + "\x15required_capabilities\x18\b \x03(\tR\x14requiredCapabilities\"t\n" + "\x11ComputeDriverInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12K\n" + "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xbc\x01\n" + @@ -17236,7 +17652,7 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02R\ftimestamp_ms\"\xd6\x04\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02R\ftimestamp_ms\"\xa9\x05\n" + "\x14CreateSandboxRequest\x12R\n" + "\x0fworkspace_scope\x18\a \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12-\n" + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + @@ -17246,7 +17662,8 @@ const file_openshell_proto_rawDesc = "" + "\x1dawait_main_process_attachment\x18\x05 \x01(\bR\x1aawaitMainProcessAttachment\x12+\n" + "\x11workload_template\x18\x06 \x01(\tR\x10workloadTemplate\x12\x1d\n" + "\n" + - "request_id\x18\b \x01(\tR\trequestId\x1a9\n" + + "request_id\x18\b \x01(\tR\trequestId\x12Q\n" + + "\x11service_exposures\x18\t \x03(\v2$.openshell.v1.SandboxServiceExposureR\x10serviceExposures\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + @@ -17332,9 +17749,13 @@ const file_openshell_proto_rawDesc = "" + "\x0fworkspace_scope\x18\x02 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "request_id\x18\x03 \x01(\tR\trequestId\"B\n" + + "request_id\x18\x03 \x01(\tR\trequestId\"\xd5\x01\n" + "\x0fSandboxResponse\x12/\n" + - "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\"t\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\x12Q\n" + + "\fservice_urls\x18\x02 \x03(\v2..openshell.v1.SandboxResponse.ServiceUrlsEntryR\vserviceUrls\x1a>\n" + + "\x10ServiceUrlsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"t\n" + "\x15ListSandboxesResponse\x123\n" + "\tsandboxes\x18\x01 \x03(\v2\x15.openshell.v1.SandboxR\tsandboxes\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"^\n" + @@ -18028,12 +18449,13 @@ const file_openshell_proto_rawDesc = "" + "relay_open\x18\x04 \x01(\v2\x17.openshell.v1.RelayOpenH\x00R\trelayOpen\x12;\n" + "\vrelay_close\x18\x05 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + "relayCloseB\t\n" + - "\apayload\"\x91\x01\n" + + "\apayload\"\xbc\x01\n" + "\x0fSupervisorHello\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + "\vinstance_id\x18\x02 \x01(\tR\n" + - "instanceId\x12>\n" + + "instanceId\x12)\n" + + "\x10connection_epoch\x18\x03 \x01(\x04R\x0fconnectionEpoch\x12>\n" + "\x1bsupports_provider_readiness\x18\x04 \x01(\bR\x19supportsProviderReadiness\"\x99\x01\n" + "\x0fSessionAccepted\x12\x1d\n" + "\n" + @@ -18075,6 +18497,16 @@ const file_openshell_proto_rawDesc = "" + "RelayFrame\x12-\n" + "\x04init\x18\x01 \x01(\v2\x17.openshell.v1.RelayInitH\x00R\x04init\x12\x14\n" + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + + "\apayload\"\x98\x01\n" + + "\rPeerRelayInit\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x126\n" + + "\n" + + "relay_open\x18\x02 \x01(\v2\x17.openshell.v1.RelayOpenR\trelayOpen\x120\n" + + "\x14requester_replica_id\x18\x03 \x01(\tR\x12requesterReplicaId\"d\n" + + "\x0ePeerRelayFrame\x121\n" + + "\x04init\x18\x01 \x01(\v2\x1b.openshell.v1.PeerRelayInitH\x00R\x04init\x12\x14\n" + + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + "\apayload\"`\n" + "\x0fRelayOpenResult\x12\x1d\n" + "\n" + @@ -18351,7 +18783,17 @@ const file_openshell_proto_rawDesc = "" + "\x12cleanup_retry_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\x10cleanupRetryTime\x120\n" + "\x14attachment_change_id\x18\n" + " \x01(\tR\x12attachmentChangeId\x12P\n" + - "\x16attachment_change_time\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x14attachmentChangeTime*\xa6\x02\n" + + "\x16attachment_change_time\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x14attachmentChangeTime\"S\n" + + "\x16SandboxServiceExposure\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x1f\n" + + "\vtarget_port\x18\x02 \x01(\rR\n" + + "targetPort*\xca\x01\n" + + "\rExtensionKind\x12\x1e\n" + + "\x1aEXTENSION_KIND_UNSPECIFIED\x10\x00\x12!\n" + + "\x1dEXTENSION_KIND_COMPUTE_DRIVER\x10\x01\x12$\n" + + " EXTENSION_KIND_CREDENTIAL_DRIVER\x10\x02\x12&\n" + + "\"EXTENSION_KIND_GATEWAY_INTERCEPTOR\x10\x03\x12(\n" + + "$EXTENSION_KIND_SUPERVISOR_MIDDLEWARE\x10\x04*\xa6\x02\n" + "\fSandboxPhase\x12\x1d\n" + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + @@ -18478,7 +18920,7 @@ const file_openshell_proto_rawDesc = "" + "&ENDPOINT_RESULT_CREDENTIAL_UNAVAILABLE\x10\x04\x12\x1e\n" + "\x1aENDPOINT_RESULT_TLS_FAILED\x10\x05\x12$\n" + " ENDPOINT_RESULT_TRANSPORT_FAILED\x10\x06\x12%\n" + - "!ENDPOINT_RESULT_UPSTREAM_REJECTED\x10\a2\xc2Q\n" + + "!ENDPOINT_RESULT_UPSTREAM_REJECTED\x10\a2\xafU\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + "\x0funauthenticated\x12i\n" + @@ -18600,7 +19042,19 @@ const file_openshell_proto_rawDesc = "" + "\x17FinalizeMainProcessExit\x12,.openshell.v1.FinalizeMainProcessExitRequest\x1a-.openshell.v1.FinalizeMainProcessExitResponse\"\r\x82\xb5\x18\t\n" + "\asandbox\x12T\n" + "\vRelayStream\x12\x18.openshell.v1.RelayFrame\x1a\x18.openshell.v1.RelayFrame\"\r\x82\xb5\x18\t\n" + - "\asandbox(\x010\x01\x12w\n" + + "\asandbox(\x010\x01\x12W\n" + + "\tPeerRelay\x12\x1c.openshell.v1.PeerRelayFrame\x1a\x1c.openshell.v1.PeerRelayFrame\"\n" + + "\x82\xb5\x18\x06\n" + + "\x04peer(\x010\x01\x12\x86\x01\n" + + "\x1bPeerReportProviderReadiness\x12,.openshell.v1.ReportProviderReadinessRequest\x1a-.openshell.v1.ReportProviderReadinessResponse\"\n" + + "\x82\xb5\x18\x06\n" + + "\x04peer\x12}\n" + + "\x18PeerReportEndpointStatus\x12).openshell.v1.ReportEndpointStatusRequest\x1a*.openshell.v1.ReportEndpointStatusResponse\"\n" + + "\x82\xb5\x18\x06\n" + + "\x04peer\x12\x89\x01\n" + + "\x1cPeerGetSandboxProviderStatus\x12-.openshell.v1.GetSandboxProviderStatusRequest\x1a..openshell.v1.GetSandboxProviderStatusResponse\"\n" + + "\x82\xb5\x18\x06\n" + + "\x04peer\x12w\n" + "\fWatchSandbox\x12!.openshell.v1.WatchSandboxRequest\x1a .openshell.v1.SandboxStreamEvent\" \x82\xb5\x18\x1c\n" + "\x06bearer\x12\x04user\"\fsandbox:read0\x01\x12|\n" + "\x14SubmitPolicyAnalysis\x12).openshell.v1.SubmitPolicyAnalysisRequest\x1a*.openshell.v1.SubmitPolicyAnalysisResponse\"\r\x82\xb5\x18\t\n" + @@ -18652,766 +19106,786 @@ func file_openshell_proto_rawDescGZIP() []byte { return file_openshell_proto_rawDescData } -var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 17) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 248) +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 18) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 253) var file_openshell_proto_goTypes = []any{ - (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase - (ProviderMutationKind)(0), // 1: openshell.v1.ProviderMutationKind - (ProviderReadinessState)(0), // 2: openshell.v1.ProviderReadinessState - (ProviderReadinessReason)(0), // 3: openshell.v1.ProviderReadinessReason - (ConfigComponent)(0), // 4: openshell.v1.ConfigComponent - (ConfigApplyOutcome)(0), // 5: openshell.v1.ConfigApplyOutcome - (ConfigUpdateOperationState)(0), // 6: openshell.v1.ConfigUpdateOperationState - (ProviderCredentialTokenGrantType)(0), // 7: openshell.v1.ProviderCredentialTokenGrantType - (ProviderCredentialRefreshStrategy)(0), // 8: openshell.v1.ProviderCredentialRefreshStrategy - (ProviderProfileCategory)(0), // 9: openshell.v1.ProviderProfileCategory - (ConfigurationAdmissionState)(0), // 10: openshell.v1.ConfigurationAdmissionState - (PolicyStatus)(0), // 11: openshell.v1.PolicyStatus - (ServiceStatus)(0), // 12: openshell.v1.ServiceStatus - (WorkspaceRole)(0), // 13: openshell.v1.WorkspaceRole - (ProviderCredentialRefreshRecoveryAction)(0), // 14: openshell.v1.ProviderCredentialRefreshRecoveryAction - (DeletionOutcome)(0), // 15: openshell.v1.DeletionOutcome - (EndpointResult)(0), // 16: openshell.v1.EndpointResult - (*IssueSandboxTokenRequest)(nil), // 17: openshell.v1.IssueSandboxTokenRequest - (*IssueSandboxTokenResponse)(nil), // 18: openshell.v1.IssueSandboxTokenResponse - (*RefreshSandboxTokenRequest)(nil), // 19: openshell.v1.RefreshSandboxTokenRequest - (*RefreshSandboxTokenResponse)(nil), // 20: openshell.v1.RefreshSandboxTokenResponse - (*HealthRequest)(nil), // 21: openshell.v1.HealthRequest - (*HealthResponse)(nil), // 22: openshell.v1.HealthResponse - (*GetCurrentUserRequest)(nil), // 23: openshell.v1.GetCurrentUserRequest - (*GetCurrentUserResponse)(nil), // 24: openshell.v1.GetCurrentUserResponse - (*GetGatewayInfoRequest)(nil), // 25: openshell.v1.GetGatewayInfoRequest - (*GetGatewayInfoResponse)(nil), // 26: openshell.v1.GetGatewayInfoResponse - (*ComputeDriverInfo)(nil), // 27: openshell.v1.ComputeDriverInfo - (*ComputeDriverCapabilities)(nil), // 28: openshell.v1.ComputeDriverCapabilities - (*ResourceCapabilities)(nil), // 29: openshell.v1.ResourceCapabilities - (*CpuResourceCapabilities)(nil), // 30: openshell.v1.CpuResourceCapabilities - (*MemoryResourceCapabilities)(nil), // 31: openshell.v1.MemoryResourceCapabilities - (*GpuResourceCapabilities)(nil), // 32: openshell.v1.GpuResourceCapabilities - (*Sandbox)(nil), // 33: openshell.v1.Sandbox - (*SandboxSpec)(nil), // 34: openshell.v1.SandboxSpec - (*ResourceRequirements)(nil), // 35: openshell.v1.ResourceRequirements - (*GpuResourceRequirements)(nil), // 36: openshell.v1.GpuResourceRequirements - (*SandboxTemplate)(nil), // 37: openshell.v1.SandboxTemplate - (*SandboxWorkloadTemplate)(nil), // 38: openshell.v1.SandboxWorkloadTemplate - (*SandboxWorkloadTemplateSpec)(nil), // 39: openshell.v1.SandboxWorkloadTemplateSpec - (*SandboxWorkloadConfig)(nil), // 40: openshell.v1.SandboxWorkloadConfig - (*SandboxResources)(nil), // 41: openshell.v1.SandboxResources - (*SandboxServiceLevel)(nil), // 42: openshell.v1.SandboxServiceLevel - (*SandboxStartup)(nil), // 43: openshell.v1.SandboxStartup - (*SandboxWorkloadTemplateProvenance)(nil), // 44: openshell.v1.SandboxWorkloadTemplateProvenance - (*SandboxStatus)(nil), // 45: openshell.v1.SandboxStatus - (*SandboxCondition)(nil), // 46: openshell.v1.SandboxCondition - (*PlatformEvent)(nil), // 47: openshell.v1.PlatformEvent - (*CreateSandboxRequest)(nil), // 48: openshell.v1.CreateSandboxRequest - (*CreateSandboxTemplateRequest)(nil), // 49: openshell.v1.CreateSandboxTemplateRequest - (*GetSandboxTemplateRequest)(nil), // 50: openshell.v1.GetSandboxTemplateRequest - (*ListSandboxTemplatesRequest)(nil), // 51: openshell.v1.ListSandboxTemplatesRequest - (*DeleteSandboxTemplateRequest)(nil), // 52: openshell.v1.DeleteSandboxTemplateRequest - (*SandboxTemplateResponse)(nil), // 53: openshell.v1.SandboxTemplateResponse - (*ListSandboxTemplatesResponse)(nil), // 54: openshell.v1.ListSandboxTemplatesResponse - (*DeleteSandboxTemplateResponse)(nil), // 55: openshell.v1.DeleteSandboxTemplateResponse - (*BeginRootfsTarStagingRequest)(nil), // 56: openshell.v1.BeginRootfsTarStagingRequest - (*BeginRootfsTarStagingResponse)(nil), // 57: openshell.v1.BeginRootfsTarStagingResponse - (*GetSandboxRequest)(nil), // 58: openshell.v1.GetSandboxRequest - (*ListSandboxesRequest)(nil), // 59: openshell.v1.ListSandboxesRequest - (*ListSandboxProvidersRequest)(nil), // 60: openshell.v1.ListSandboxProvidersRequest - (*AttachSandboxProviderRequest)(nil), // 61: openshell.v1.AttachSandboxProviderRequest - (*DetachSandboxProviderRequest)(nil), // 62: openshell.v1.DetachSandboxProviderRequest - (*DeleteSandboxRequest)(nil), // 63: openshell.v1.DeleteSandboxRequest - (*StopSandboxRequest)(nil), // 64: openshell.v1.StopSandboxRequest - (*StartSandboxRequest)(nil), // 65: openshell.v1.StartSandboxRequest - (*SandboxResponse)(nil), // 66: openshell.v1.SandboxResponse - (*ListSandboxesResponse)(nil), // 67: openshell.v1.ListSandboxesResponse - (*ListSandboxProvidersResponse)(nil), // 68: openshell.v1.ListSandboxProvidersResponse - (*AttachSandboxProviderResponse)(nil), // 69: openshell.v1.AttachSandboxProviderResponse - (*DetachSandboxProviderResponse)(nil), // 70: openshell.v1.DetachSandboxProviderResponse - (*ProviderDesiredIdentity)(nil), // 71: openshell.v1.ProviderDesiredIdentity - (*ConfigSnapshotRevision)(nil), // 72: openshell.v1.ConfigSnapshotRevision - (*SandboxConfigRevision)(nil), // 73: openshell.v1.SandboxConfigRevision - (*ConfigUpdateOperation)(nil), // 74: openshell.v1.ConfigUpdateOperation - (*ProviderMutationReceipt)(nil), // 75: openshell.v1.ProviderMutationReceipt - (*ProviderReadinessObservation)(nil), // 76: openshell.v1.ProviderReadinessObservation - (*ProviderReadinessStatus)(nil), // 77: openshell.v1.ProviderReadinessStatus - (*GetSandboxProviderStatusRequest)(nil), // 78: openshell.v1.GetSandboxProviderStatusRequest - (*GetSandboxProviderStatusResponse)(nil), // 79: openshell.v1.GetSandboxProviderStatusResponse - (*ReportProviderReadinessRequest)(nil), // 80: openshell.v1.ReportProviderReadinessRequest - (*ReportProviderReadinessResponse)(nil), // 81: openshell.v1.ReportProviderReadinessResponse - (*DeleteSandboxResponse)(nil), // 82: openshell.v1.DeleteSandboxResponse - (*CreateSshSessionRequest)(nil), // 83: openshell.v1.CreateSshSessionRequest - (*CreateSshSessionResponse)(nil), // 84: openshell.v1.CreateSshSessionResponse - (*ExposeServiceRequest)(nil), // 85: openshell.v1.ExposeServiceRequest - (*GetServiceRequest)(nil), // 86: openshell.v1.GetServiceRequest - (*ListServicesRequest)(nil), // 87: openshell.v1.ListServicesRequest - (*ListServicesResponse)(nil), // 88: openshell.v1.ListServicesResponse - (*DeleteServiceRequest)(nil), // 89: openshell.v1.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 90: openshell.v1.DeleteServiceResponse - (*ServiceEndpoint)(nil), // 91: openshell.v1.ServiceEndpoint - (*ServiceEndpointResponse)(nil), // 92: openshell.v1.ServiceEndpointResponse - (*RevokeSshSessionRequest)(nil), // 93: openshell.v1.RevokeSshSessionRequest - (*RevokeSshSessionResponse)(nil), // 94: openshell.v1.RevokeSshSessionResponse - (*ExecSandboxRequest)(nil), // 95: openshell.v1.ExecSandboxRequest - (*ExecSandboxStdout)(nil), // 96: openshell.v1.ExecSandboxStdout - (*ExecSandboxStderr)(nil), // 97: openshell.v1.ExecSandboxStderr - (*ExecSandboxExit)(nil), // 98: openshell.v1.ExecSandboxExit - (*ExecSandboxEvent)(nil), // 99: openshell.v1.ExecSandboxEvent - (*TcpForwardInit)(nil), // 100: openshell.v1.TcpForwardInit - (*TcpForwardFrame)(nil), // 101: openshell.v1.TcpForwardFrame - (*ExecSandboxInput)(nil), // 102: openshell.v1.ExecSandboxInput - (*ExecSandboxWindowResize)(nil), // 103: openshell.v1.ExecSandboxWindowResize - (*SshSession)(nil), // 104: openshell.v1.SshSession - (*WatchSandboxRequest)(nil), // 105: openshell.v1.WatchSandboxRequest - (*SandboxStreamEvent)(nil), // 106: openshell.v1.SandboxStreamEvent - (*SandboxLogLine)(nil), // 107: openshell.v1.SandboxLogLine - (*SandboxStreamWarning)(nil), // 108: openshell.v1.SandboxStreamWarning - (*CreateProviderRequest)(nil), // 109: openshell.v1.CreateProviderRequest - (*GetProviderRequest)(nil), // 110: openshell.v1.GetProviderRequest - (*ListProvidersRequest)(nil), // 111: openshell.v1.ListProvidersRequest - (*UpdateProviderRequest)(nil), // 112: openshell.v1.UpdateProviderRequest - (*DeleteProviderRequest)(nil), // 113: openshell.v1.DeleteProviderRequest - (*ProviderResponse)(nil), // 114: openshell.v1.ProviderResponse - (*ListProvidersResponse)(nil), // 115: openshell.v1.ListProvidersResponse - (*ListProviderProfilesRequest)(nil), // 116: openshell.v1.ListProviderProfilesRequest - (*GetProviderProfileRequest)(nil), // 117: openshell.v1.GetProviderProfileRequest - (*ProviderProfileImportItem)(nil), // 118: openshell.v1.ProviderProfileImportItem - (*ProviderProfileDiagnostic)(nil), // 119: openshell.v1.ProviderProfileDiagnostic - (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 120: openshell.v1.ProviderCredentialTokenGrantAudienceOverride - (*ProviderCredentialTokenGrantSubjectToken)(nil), // 121: openshell.v1.ProviderCredentialTokenGrantSubjectToken - (*ProviderCredentialTokenGrant)(nil), // 122: openshell.v1.ProviderCredentialTokenGrant - (*ProviderProfileCredential)(nil), // 123: openshell.v1.ProviderProfileCredential - (*ProviderCredentialRefreshMaterial)(nil), // 124: openshell.v1.ProviderCredentialRefreshMaterial - (*ProviderCredentialRefreshOutput)(nil), // 125: openshell.v1.ProviderCredentialRefreshOutput - (*ProviderCredentialRefresh)(nil), // 126: openshell.v1.ProviderCredentialRefresh - (*ProviderCredentialRefreshStatus)(nil), // 127: openshell.v1.ProviderCredentialRefreshStatus - (*ProviderProfileDiscovery)(nil), // 128: openshell.v1.ProviderProfileDiscovery - (*GetProviderRefreshStatusRequest)(nil), // 129: openshell.v1.GetProviderRefreshStatusRequest - (*GetProviderRefreshStatusResponse)(nil), // 130: openshell.v1.GetProviderRefreshStatusResponse - (*ConfigureProviderRefreshRequest)(nil), // 131: openshell.v1.ConfigureProviderRefreshRequest - (*ConfigureProviderRefreshResponse)(nil), // 132: openshell.v1.ConfigureProviderRefreshResponse - (*RotateProviderCredentialRequest)(nil), // 133: openshell.v1.RotateProviderCredentialRequest - (*RotateProviderCredentialResponse)(nil), // 134: openshell.v1.RotateProviderCredentialResponse - (*DeleteProviderRefreshRequest)(nil), // 135: openshell.v1.DeleteProviderRefreshRequest - (*DeleteProviderRefreshResponse)(nil), // 136: openshell.v1.DeleteProviderRefreshResponse - (*ProviderProfile)(nil), // 137: openshell.v1.ProviderProfile - (*ProviderProfileResponse)(nil), // 138: openshell.v1.ProviderProfileResponse - (*ListProviderProfilesResponse)(nil), // 139: openshell.v1.ListProviderProfilesResponse - (*ImportProviderProfilesRequest)(nil), // 140: openshell.v1.ImportProviderProfilesRequest - (*ImportProviderProfilesResponse)(nil), // 141: openshell.v1.ImportProviderProfilesResponse - (*UpdateProviderProfilesRequest)(nil), // 142: openshell.v1.UpdateProviderProfilesRequest - (*UpdateProviderProfilesResponse)(nil), // 143: openshell.v1.UpdateProviderProfilesResponse - (*LintProviderProfilesRequest)(nil), // 144: openshell.v1.LintProviderProfilesRequest - (*LintProviderProfilesResponse)(nil), // 145: openshell.v1.LintProviderProfilesResponse - (*DeleteProviderResponse)(nil), // 146: openshell.v1.DeleteProviderResponse - (*DeleteProviderProfileRequest)(nil), // 147: openshell.v1.DeleteProviderProfileRequest - (*DeleteProviderProfileResponse)(nil), // 148: openshell.v1.DeleteProviderProfileResponse - (*GetSandboxProviderEnvironmentRequest)(nil), // 149: openshell.v1.GetSandboxProviderEnvironmentRequest - (*StaticCredentialEndpointBinding)(nil), // 150: openshell.v1.StaticCredentialEndpointBinding - (*StaticCredentialBinding)(nil), // 151: openshell.v1.StaticCredentialBinding - (*GetSandboxProviderEnvironmentResponse)(nil), // 152: openshell.v1.GetSandboxProviderEnvironmentResponse - (*ExchangeProviderSubjectTokenRequest)(nil), // 153: openshell.v1.ExchangeProviderSubjectTokenRequest - (*ExchangeProviderSubjectTokenResponse)(nil), // 154: openshell.v1.ExchangeProviderSubjectTokenResponse - (*UpdateConfigRequest)(nil), // 155: openshell.v1.UpdateConfigRequest - (*PolicyMergeOperation)(nil), // 156: openshell.v1.PolicyMergeOperation - (*AddNetworkRule)(nil), // 157: openshell.v1.AddNetworkRule - (*RemoveNetworkEndpoint)(nil), // 158: openshell.v1.RemoveNetworkEndpoint - (*RemoveNetworkRule)(nil), // 159: openshell.v1.RemoveNetworkRule - (*L7RuleTarget)(nil), // 160: openshell.v1.L7RuleTarget - (*AddDenyRules)(nil), // 161: openshell.v1.AddDenyRules - (*AddAllowRules)(nil), // 162: openshell.v1.AddAllowRules - (*RemoveNetworkBinary)(nil), // 163: openshell.v1.RemoveNetworkBinary - (*UpdateConfigResponse)(nil), // 164: openshell.v1.UpdateConfigResponse - (*GetSandboxPolicyStatusRequest)(nil), // 165: openshell.v1.GetSandboxPolicyStatusRequest - (*GetSandboxPolicyStatusResponse)(nil), // 166: openshell.v1.GetSandboxPolicyStatusResponse - (*ListSandboxPoliciesRequest)(nil), // 167: openshell.v1.ListSandboxPoliciesRequest - (*ListSandboxPoliciesResponse)(nil), // 168: openshell.v1.ListSandboxPoliciesResponse - (*ReportPolicyStatusRequest)(nil), // 169: openshell.v1.ReportPolicyStatusRequest - (*ReportPolicyStatusResponse)(nil), // 170: openshell.v1.ReportPolicyStatusResponse - (*SandboxConfigurationAdmission)(nil), // 171: openshell.v1.SandboxConfigurationAdmission - (*ReportSandboxConfigurationRequest)(nil), // 172: openshell.v1.ReportSandboxConfigurationRequest - (*ReportSandboxConfigurationResponse)(nil), // 173: openshell.v1.ReportSandboxConfigurationResponse - (*SandboxPolicyRevision)(nil), // 174: openshell.v1.SandboxPolicyRevision - (*GetSandboxLogsRequest)(nil), // 175: openshell.v1.GetSandboxLogsRequest - (*PushSandboxLogsRequest)(nil), // 176: openshell.v1.PushSandboxLogsRequest - (*PushSandboxLogsResponse)(nil), // 177: openshell.v1.PushSandboxLogsResponse - (*GetSandboxLogsResponse)(nil), // 178: openshell.v1.GetSandboxLogsResponse - (*SupervisorMessage)(nil), // 179: openshell.v1.SupervisorMessage - (*GatewayMessage)(nil), // 180: openshell.v1.GatewayMessage - (*SupervisorHello)(nil), // 181: openshell.v1.SupervisorHello - (*SessionAccepted)(nil), // 182: openshell.v1.SessionAccepted - (*SessionRejected)(nil), // 183: openshell.v1.SessionRejected - (*SupervisorHeartbeat)(nil), // 184: openshell.v1.SupervisorHeartbeat - (*GatewayHeartbeat)(nil), // 185: openshell.v1.GatewayHeartbeat - (*ReportMainProcessExitRequest)(nil), // 186: openshell.v1.ReportMainProcessExitRequest - (*ReportMainProcessExitResponse)(nil), // 187: openshell.v1.ReportMainProcessExitResponse - (*FinalizeMainProcessExitRequest)(nil), // 188: openshell.v1.FinalizeMainProcessExitRequest - (*FinalizeMainProcessExitResponse)(nil), // 189: openshell.v1.FinalizeMainProcessExitResponse - (*RelayOpen)(nil), // 190: openshell.v1.RelayOpen - (*SshRelayTarget)(nil), // 191: openshell.v1.SshRelayTarget - (*TcpRelayTarget)(nil), // 192: openshell.v1.TcpRelayTarget - (*RelayInit)(nil), // 193: openshell.v1.RelayInit - (*RelayFrame)(nil), // 194: openshell.v1.RelayFrame - (*RelayOpenResult)(nil), // 195: openshell.v1.RelayOpenResult - (*RelayClose)(nil), // 196: openshell.v1.RelayClose - (*L7RequestSample)(nil), // 197: openshell.v1.L7RequestSample - (*DenialSummary)(nil), // 198: openshell.v1.DenialSummary - (*DenialGroupCount)(nil), // 199: openshell.v1.DenialGroupCount - (*NetworkActivitySummary)(nil), // 200: openshell.v1.NetworkActivitySummary - (*PolicyChunk)(nil), // 201: openshell.v1.PolicyChunk - (*DraftPolicyUpdate)(nil), // 202: openshell.v1.DraftPolicyUpdate - (*SubmitPolicyAnalysisRequest)(nil), // 203: openshell.v1.SubmitPolicyAnalysisRequest - (*SubmitPolicyAnalysisResponse)(nil), // 204: openshell.v1.SubmitPolicyAnalysisResponse - (*GetDraftPolicyRequest)(nil), // 205: openshell.v1.GetDraftPolicyRequest - (*GetDraftPolicyResponse)(nil), // 206: openshell.v1.GetDraftPolicyResponse - (*ApproveDraftChunkRequest)(nil), // 207: openshell.v1.ApproveDraftChunkRequest - (*ApproveDraftChunkResponse)(nil), // 208: openshell.v1.ApproveDraftChunkResponse - (*RejectDraftChunkRequest)(nil), // 209: openshell.v1.RejectDraftChunkRequest - (*RejectDraftChunkResponse)(nil), // 210: openshell.v1.RejectDraftChunkResponse - (*DraftChunkApproval)(nil), // 211: openshell.v1.DraftChunkApproval - (*ApproveAllDraftChunksRequest)(nil), // 212: openshell.v1.ApproveAllDraftChunksRequest - (*ApproveAllDraftChunksResponse)(nil), // 213: openshell.v1.ApproveAllDraftChunksResponse - (*EditDraftChunkRequest)(nil), // 214: openshell.v1.EditDraftChunkRequest - (*EditDraftChunkResponse)(nil), // 215: openshell.v1.EditDraftChunkResponse - (*UndoDraftChunkRequest)(nil), // 216: openshell.v1.UndoDraftChunkRequest - (*UndoDraftChunkResponse)(nil), // 217: openshell.v1.UndoDraftChunkResponse - (*ClearDraftChunksRequest)(nil), // 218: openshell.v1.ClearDraftChunksRequest - (*ClearDraftChunksResponse)(nil), // 219: openshell.v1.ClearDraftChunksResponse - (*GetDraftHistoryRequest)(nil), // 220: openshell.v1.GetDraftHistoryRequest - (*DraftHistoryEntry)(nil), // 221: openshell.v1.DraftHistoryEntry - (*GetDraftHistoryResponse)(nil), // 222: openshell.v1.GetDraftHistoryResponse - (*CreateWorkspaceRequest)(nil), // 223: openshell.v1.CreateWorkspaceRequest - (*CreateWorkspaceResponse)(nil), // 224: openshell.v1.CreateWorkspaceResponse - (*GetWorkspaceRequest)(nil), // 225: openshell.v1.GetWorkspaceRequest - (*GetWorkspaceResponse)(nil), // 226: openshell.v1.GetWorkspaceResponse - (*ListWorkspacesRequest)(nil), // 227: openshell.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 228: openshell.v1.ListWorkspacesResponse - (*DeleteWorkspaceRequest)(nil), // 229: openshell.v1.DeleteWorkspaceRequest - (*DeleteWorkspaceResponse)(nil), // 230: openshell.v1.DeleteWorkspaceResponse - (*WorkspaceMember)(nil), // 231: openshell.v1.WorkspaceMember - (*AddWorkspaceMemberRequest)(nil), // 232: openshell.v1.AddWorkspaceMemberRequest - (*AddWorkspaceMemberResponse)(nil), // 233: openshell.v1.AddWorkspaceMemberResponse - (*RemoveWorkspaceMemberRequest)(nil), // 234: openshell.v1.RemoveWorkspaceMemberRequest - (*RemoveWorkspaceMemberResponse)(nil), // 235: openshell.v1.RemoveWorkspaceMemberResponse - (*ListWorkspaceMembersRequest)(nil), // 236: openshell.v1.ListWorkspaceMembersRequest - (*ListWorkspaceMembersResponse)(nil), // 237: openshell.v1.ListWorkspaceMembersResponse - (*ExtensionServiceCredential)(nil), // 238: openshell.v1.ExtensionServiceCredential - (*EndpointObservation)(nil), // 239: openshell.v1.EndpointObservation - (*ReportEndpointStatusRequest)(nil), // 240: openshell.v1.ReportEndpointStatusRequest - (*ReportEndpointStatusResponse)(nil), // 241: openshell.v1.ReportEndpointStatusResponse - (*EndpointStatus)(nil), // 242: openshell.v1.EndpointStatus - (*SandboxProvisioning)(nil), // 243: openshell.v1.SandboxProvisioning - nil, // 244: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 245: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 246: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 247: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 248: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - nil, // 249: openshell.v1.PlatformEvent.MetadataEntry - nil, // 250: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 251: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 252: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 253: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 254: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry - nil, // 255: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 256: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 257: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 258: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry - nil, // 259: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 260: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 261: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 262: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 263: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 264: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*timestamppb.Timestamp)(nil), // 265: google.protobuf.Timestamp - (*datamodelv1.ObjectMeta)(nil), // 266: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 267: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 268: google.protobuf.Struct - (*durationpb.Duration)(nil), // 269: google.protobuf.Duration - (*datamodelv1.WorkspaceSelector)(nil), // 270: openshell.datamodel.v1.WorkspaceSelector - (*datamodelv1.Provider)(nil), // 271: openshell.datamodel.v1.Provider - (sandboxv1.PolicySource)(0), // 272: openshell.sandbox.v1.PolicySource - (*sandboxv1.NetworkEndpoint)(nil), // 273: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 274: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 275: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 276: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 277: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 278: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 279: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 280: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 281: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 282: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 283: openshell.sandbox.v1.GetGatewayConfigResponse + (ExtensionKind)(0), // 0: openshell.v1.ExtensionKind + (SandboxPhase)(0), // 1: openshell.v1.SandboxPhase + (ProviderMutationKind)(0), // 2: openshell.v1.ProviderMutationKind + (ProviderReadinessState)(0), // 3: openshell.v1.ProviderReadinessState + (ProviderReadinessReason)(0), // 4: openshell.v1.ProviderReadinessReason + (ConfigComponent)(0), // 5: openshell.v1.ConfigComponent + (ConfigApplyOutcome)(0), // 6: openshell.v1.ConfigApplyOutcome + (ConfigUpdateOperationState)(0), // 7: openshell.v1.ConfigUpdateOperationState + (ProviderCredentialTokenGrantType)(0), // 8: openshell.v1.ProviderCredentialTokenGrantType + (ProviderCredentialRefreshStrategy)(0), // 9: openshell.v1.ProviderCredentialRefreshStrategy + (ProviderProfileCategory)(0), // 10: openshell.v1.ProviderProfileCategory + (ConfigurationAdmissionState)(0), // 11: openshell.v1.ConfigurationAdmissionState + (PolicyStatus)(0), // 12: openshell.v1.PolicyStatus + (ServiceStatus)(0), // 13: openshell.v1.ServiceStatus + (WorkspaceRole)(0), // 14: openshell.v1.WorkspaceRole + (ProviderCredentialRefreshRecoveryAction)(0), // 15: openshell.v1.ProviderCredentialRefreshRecoveryAction + (DeletionOutcome)(0), // 16: openshell.v1.DeletionOutcome + (EndpointResult)(0), // 17: openshell.v1.EndpointResult + (*IssueSandboxTokenRequest)(nil), // 18: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 19: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 20: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 21: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 22: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 23: openshell.v1.HealthResponse + (*GetCurrentUserRequest)(nil), // 24: openshell.v1.GetCurrentUserRequest + (*GetCurrentUserResponse)(nil), // 25: openshell.v1.GetCurrentUserResponse + (*GetGatewayInfoRequest)(nil), // 26: openshell.v1.GetGatewayInfoRequest + (*GetGatewayInfoResponse)(nil), // 27: openshell.v1.GetGatewayInfoResponse + (*NegotiatedExtensionInfo)(nil), // 28: openshell.v1.NegotiatedExtensionInfo + (*ComputeDriverInfo)(nil), // 29: openshell.v1.ComputeDriverInfo + (*ComputeDriverCapabilities)(nil), // 30: openshell.v1.ComputeDriverCapabilities + (*ResourceCapabilities)(nil), // 31: openshell.v1.ResourceCapabilities + (*CpuResourceCapabilities)(nil), // 32: openshell.v1.CpuResourceCapabilities + (*MemoryResourceCapabilities)(nil), // 33: openshell.v1.MemoryResourceCapabilities + (*GpuResourceCapabilities)(nil), // 34: openshell.v1.GpuResourceCapabilities + (*Sandbox)(nil), // 35: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 36: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 37: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 38: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 39: openshell.v1.SandboxTemplate + (*SandboxWorkloadTemplate)(nil), // 40: openshell.v1.SandboxWorkloadTemplate + (*SandboxWorkloadTemplateSpec)(nil), // 41: openshell.v1.SandboxWorkloadTemplateSpec + (*SandboxWorkloadConfig)(nil), // 42: openshell.v1.SandboxWorkloadConfig + (*SandboxResources)(nil), // 43: openshell.v1.SandboxResources + (*SandboxServiceLevel)(nil), // 44: openshell.v1.SandboxServiceLevel + (*SandboxStartup)(nil), // 45: openshell.v1.SandboxStartup + (*SandboxWorkloadTemplateProvenance)(nil), // 46: openshell.v1.SandboxWorkloadTemplateProvenance + (*SandboxStatus)(nil), // 47: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 48: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 49: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 50: openshell.v1.CreateSandboxRequest + (*CreateSandboxTemplateRequest)(nil), // 51: openshell.v1.CreateSandboxTemplateRequest + (*GetSandboxTemplateRequest)(nil), // 52: openshell.v1.GetSandboxTemplateRequest + (*ListSandboxTemplatesRequest)(nil), // 53: openshell.v1.ListSandboxTemplatesRequest + (*DeleteSandboxTemplateRequest)(nil), // 54: openshell.v1.DeleteSandboxTemplateRequest + (*SandboxTemplateResponse)(nil), // 55: openshell.v1.SandboxTemplateResponse + (*ListSandboxTemplatesResponse)(nil), // 56: openshell.v1.ListSandboxTemplatesResponse + (*DeleteSandboxTemplateResponse)(nil), // 57: openshell.v1.DeleteSandboxTemplateResponse + (*BeginRootfsTarStagingRequest)(nil), // 58: openshell.v1.BeginRootfsTarStagingRequest + (*BeginRootfsTarStagingResponse)(nil), // 59: openshell.v1.BeginRootfsTarStagingResponse + (*GetSandboxRequest)(nil), // 60: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 61: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 62: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 63: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 64: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 65: openshell.v1.DeleteSandboxRequest + (*StopSandboxRequest)(nil), // 66: openshell.v1.StopSandboxRequest + (*StartSandboxRequest)(nil), // 67: openshell.v1.StartSandboxRequest + (*SandboxResponse)(nil), // 68: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 69: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 70: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 71: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 72: openshell.v1.DetachSandboxProviderResponse + (*ProviderDesiredIdentity)(nil), // 73: openshell.v1.ProviderDesiredIdentity + (*ConfigSnapshotRevision)(nil), // 74: openshell.v1.ConfigSnapshotRevision + (*SandboxConfigRevision)(nil), // 75: openshell.v1.SandboxConfigRevision + (*ConfigUpdateOperation)(nil), // 76: openshell.v1.ConfigUpdateOperation + (*ProviderMutationReceipt)(nil), // 77: openshell.v1.ProviderMutationReceipt + (*ProviderReadinessObservation)(nil), // 78: openshell.v1.ProviderReadinessObservation + (*ProviderReadinessStatus)(nil), // 79: openshell.v1.ProviderReadinessStatus + (*GetSandboxProviderStatusRequest)(nil), // 80: openshell.v1.GetSandboxProviderStatusRequest + (*GetSandboxProviderStatusResponse)(nil), // 81: openshell.v1.GetSandboxProviderStatusResponse + (*ReportProviderReadinessRequest)(nil), // 82: openshell.v1.ReportProviderReadinessRequest + (*ReportProviderReadinessResponse)(nil), // 83: openshell.v1.ReportProviderReadinessResponse + (*DeleteSandboxResponse)(nil), // 84: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 85: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 86: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 87: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 88: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 89: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 90: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 91: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 92: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 93: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 94: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 95: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 96: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 97: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 98: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 99: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 100: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 101: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 102: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 103: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 104: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 105: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 106: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 107: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 108: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 109: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 110: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 111: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 112: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 113: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 114: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 115: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 116: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 117: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 118: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 119: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 120: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 121: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 122: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrantSubjectToken)(nil), // 123: openshell.v1.ProviderCredentialTokenGrantSubjectToken + (*ProviderCredentialTokenGrant)(nil), // 124: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 125: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 126: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefreshOutput)(nil), // 127: openshell.v1.ProviderCredentialRefreshOutput + (*ProviderCredentialRefresh)(nil), // 128: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 129: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 130: openshell.v1.ProviderProfileDiscovery + (*GetProviderRefreshStatusRequest)(nil), // 131: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 132: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 133: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 134: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 135: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 136: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 137: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 138: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 139: openshell.v1.ProviderProfile + (*ProviderProfileResponse)(nil), // 140: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 141: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 142: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 143: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 144: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 145: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 146: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 147: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 148: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 149: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 150: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 151: openshell.v1.GetSandboxProviderEnvironmentRequest + (*StaticCredentialEndpointBinding)(nil), // 152: openshell.v1.StaticCredentialEndpointBinding + (*StaticCredentialBinding)(nil), // 153: openshell.v1.StaticCredentialBinding + (*GetSandboxProviderEnvironmentResponse)(nil), // 154: openshell.v1.GetSandboxProviderEnvironmentResponse + (*ExchangeProviderSubjectTokenRequest)(nil), // 155: openshell.v1.ExchangeProviderSubjectTokenRequest + (*ExchangeProviderSubjectTokenResponse)(nil), // 156: openshell.v1.ExchangeProviderSubjectTokenResponse + (*UpdateConfigRequest)(nil), // 157: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 158: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 159: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 160: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 161: openshell.v1.RemoveNetworkRule + (*L7RuleTarget)(nil), // 162: openshell.v1.L7RuleTarget + (*AddDenyRules)(nil), // 163: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 164: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 165: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 166: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 167: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 168: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 169: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 170: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 171: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 172: openshell.v1.ReportPolicyStatusResponse + (*SandboxConfigurationAdmission)(nil), // 173: openshell.v1.SandboxConfigurationAdmission + (*ReportSandboxConfigurationRequest)(nil), // 174: openshell.v1.ReportSandboxConfigurationRequest + (*ReportSandboxConfigurationResponse)(nil), // 175: openshell.v1.ReportSandboxConfigurationResponse + (*SandboxPolicyRevision)(nil), // 176: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 177: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 178: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 179: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 180: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 181: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 182: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 183: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 184: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 185: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 186: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 187: openshell.v1.GatewayHeartbeat + (*ReportMainProcessExitRequest)(nil), // 188: openshell.v1.ReportMainProcessExitRequest + (*ReportMainProcessExitResponse)(nil), // 189: openshell.v1.ReportMainProcessExitResponse + (*FinalizeMainProcessExitRequest)(nil), // 190: openshell.v1.FinalizeMainProcessExitRequest + (*FinalizeMainProcessExitResponse)(nil), // 191: openshell.v1.FinalizeMainProcessExitResponse + (*RelayOpen)(nil), // 192: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 193: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 194: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 195: openshell.v1.RelayInit + (*RelayFrame)(nil), // 196: openshell.v1.RelayFrame + (*PeerRelayInit)(nil), // 197: openshell.v1.PeerRelayInit + (*PeerRelayFrame)(nil), // 198: openshell.v1.PeerRelayFrame + (*RelayOpenResult)(nil), // 199: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 200: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 201: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 202: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 203: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 204: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 205: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 206: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 207: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 208: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 209: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 210: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 211: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 212: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 213: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 214: openshell.v1.RejectDraftChunkResponse + (*DraftChunkApproval)(nil), // 215: openshell.v1.DraftChunkApproval + (*ApproveAllDraftChunksRequest)(nil), // 216: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 217: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 218: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 219: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 220: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 221: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 222: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 223: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 224: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 225: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 226: openshell.v1.GetDraftHistoryResponse + (*CreateWorkspaceRequest)(nil), // 227: openshell.v1.CreateWorkspaceRequest + (*CreateWorkspaceResponse)(nil), // 228: openshell.v1.CreateWorkspaceResponse + (*GetWorkspaceRequest)(nil), // 229: openshell.v1.GetWorkspaceRequest + (*GetWorkspaceResponse)(nil), // 230: openshell.v1.GetWorkspaceResponse + (*ListWorkspacesRequest)(nil), // 231: openshell.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 232: openshell.v1.ListWorkspacesResponse + (*DeleteWorkspaceRequest)(nil), // 233: openshell.v1.DeleteWorkspaceRequest + (*DeleteWorkspaceResponse)(nil), // 234: openshell.v1.DeleteWorkspaceResponse + (*WorkspaceMember)(nil), // 235: openshell.v1.WorkspaceMember + (*AddWorkspaceMemberRequest)(nil), // 236: openshell.v1.AddWorkspaceMemberRequest + (*AddWorkspaceMemberResponse)(nil), // 237: openshell.v1.AddWorkspaceMemberResponse + (*RemoveWorkspaceMemberRequest)(nil), // 238: openshell.v1.RemoveWorkspaceMemberRequest + (*RemoveWorkspaceMemberResponse)(nil), // 239: openshell.v1.RemoveWorkspaceMemberResponse + (*ListWorkspaceMembersRequest)(nil), // 240: openshell.v1.ListWorkspaceMembersRequest + (*ListWorkspaceMembersResponse)(nil), // 241: openshell.v1.ListWorkspaceMembersResponse + (*ExtensionServiceCredential)(nil), // 242: openshell.v1.ExtensionServiceCredential + (*EndpointObservation)(nil), // 243: openshell.v1.EndpointObservation + (*ReportEndpointStatusRequest)(nil), // 244: openshell.v1.ReportEndpointStatusRequest + (*ReportEndpointStatusResponse)(nil), // 245: openshell.v1.ReportEndpointStatusResponse + (*EndpointStatus)(nil), // 246: openshell.v1.EndpointStatus + (*SandboxProvisioning)(nil), // 247: openshell.v1.SandboxProvisioning + (*SandboxServiceExposure)(nil), // 248: openshell.v1.SandboxServiceExposure + nil, // 249: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 250: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 251: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 252: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 253: openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + nil, // 254: openshell.v1.PlatformEvent.MetadataEntry + nil, // 255: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 256: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 257: openshell.v1.SandboxResponse.ServiceUrlsEntry + nil, // 258: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 259: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 260: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + nil, // 261: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 262: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 263: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 264: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + nil, // 265: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 266: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 267: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 268: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 269: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 270: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*timestamppb.Timestamp)(nil), // 271: google.protobuf.Timestamp + (*datamodelv1.ObjectMeta)(nil), // 272: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 273: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 274: google.protobuf.Struct + (*durationpb.Duration)(nil), // 275: google.protobuf.Duration + (*datamodelv1.WorkspaceSelector)(nil), // 276: openshell.datamodel.v1.WorkspaceSelector + (*datamodelv1.Provider)(nil), // 277: openshell.datamodel.v1.Provider + (sandboxv1.PolicySource)(0), // 278: openshell.sandbox.v1.PolicySource + (*sandboxv1.NetworkEndpoint)(nil), // 279: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 280: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 281: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 282: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 283: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 284: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 285: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 286: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 287: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 288: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 289: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 265, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp - 265, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp - 238, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential - 265, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp - 12, // 4: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus - 12, // 5: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 27, // 6: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 28, // 7: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 29, // 8: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities - 30, // 9: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities - 31, // 10: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities - 32, // 11: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities - 266, // 12: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 34, // 13: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 45, // 14: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 44, // 15: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance - 244, // 16: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 37, // 17: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 267, // 18: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 35, // 19: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 36, // 20: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 245, // 21: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 246, // 22: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 247, // 23: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 268, // 24: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 268, // 25: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 266, // 26: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 39, // 27: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec - 40, // 28: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig - 268, // 29: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct - 42, // 30: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel - 248, // 31: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - 41, // 32: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources - 36, // 33: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements - 43, // 34: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup - 269, // 35: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration - 46, // 36: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition - 0, // 37: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 242, // 38: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus - 171, // 39: openshell.v1.SandboxStatus.configuration_admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 243, // 40: openshell.v1.SandboxStatus.provisioning:type_name -> openshell.v1.SandboxProvisioning - 265, // 41: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp - 265, // 42: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp - 249, // 43: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 270, // 44: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 34, // 45: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 250, // 46: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 251, // 47: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 270, // 48: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 38, // 49: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 270, // 50: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 51: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 52: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 38, // 53: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 38, // 54: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate - 15, // 55: openshell.v1.DeleteSandboxTemplateResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 270, // 56: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 265, // 57: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp - 270, // 58: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 59: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 60: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 61: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 62: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 63: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 64: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 65: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 33, // 66: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 33, // 67: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 271, // 68: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 33, // 69: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 75, // 70: openshell.v1.AttachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt - 33, // 71: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 75, // 72: openshell.v1.DetachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt - 73, // 73: openshell.v1.ConfigSnapshotRevision.sandbox_config:type_name -> openshell.v1.SandboxConfigRevision - 71, // 74: openshell.v1.ConfigSnapshotRevision.provider_target:type_name -> openshell.v1.ProviderDesiredIdentity - 272, // 75: openshell.v1.SandboxConfigRevision.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 4, // 76: openshell.v1.ConfigUpdateOperation.component:type_name -> openshell.v1.ConfigComponent - 72, // 77: openshell.v1.ConfigUpdateOperation.target_revision:type_name -> openshell.v1.ConfigSnapshotRevision - 6, // 78: openshell.v1.ConfigUpdateOperation.state:type_name -> openshell.v1.ConfigUpdateOperationState - 5, // 79: openshell.v1.ConfigUpdateOperation.outcome:type_name -> openshell.v1.ConfigApplyOutcome - 265, // 80: openshell.v1.ConfigUpdateOperation.created_time:type_name -> google.protobuf.Timestamp - 265, // 81: openshell.v1.ConfigUpdateOperation.updated_time:type_name -> google.protobuf.Timestamp - 265, // 82: openshell.v1.ConfigUpdateOperation.completed_time:type_name -> google.protobuf.Timestamp - 1, // 83: openshell.v1.ProviderMutationReceipt.kind:type_name -> openshell.v1.ProviderMutationKind - 71, // 84: openshell.v1.ProviderMutationReceipt.desired:type_name -> openshell.v1.ProviderDesiredIdentity - 265, // 85: openshell.v1.ProviderMutationReceipt.persisted_time:type_name -> google.protobuf.Timestamp - 3, // 86: openshell.v1.ProviderReadinessObservation.reason:type_name -> openshell.v1.ProviderReadinessReason - 75, // 87: openshell.v1.ProviderReadinessStatus.receipt:type_name -> openshell.v1.ProviderMutationReceipt - 2, // 88: openshell.v1.ProviderReadinessStatus.state:type_name -> openshell.v1.ProviderReadinessState - 3, // 89: openshell.v1.ProviderReadinessStatus.reason:type_name -> openshell.v1.ProviderReadinessReason - 76, // 90: openshell.v1.ProviderReadinessStatus.observed:type_name -> openshell.v1.ProviderReadinessObservation - 265, // 91: openshell.v1.ProviderReadinessStatus.observed_time:type_name -> google.protobuf.Timestamp - 265, // 92: openshell.v1.ProviderReadinessStatus.evaluated_time:type_name -> google.protobuf.Timestamp - 74, // 93: openshell.v1.ProviderReadinessStatus.operation:type_name -> openshell.v1.ConfigUpdateOperation - 270, // 94: openshell.v1.GetSandboxProviderStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 77, // 95: openshell.v1.GetSandboxProviderStatusResponse.status:type_name -> openshell.v1.ProviderReadinessStatus - 76, // 96: openshell.v1.ReportProviderReadinessRequest.observation:type_name -> openshell.v1.ProviderReadinessObservation - 269, // 97: openshell.v1.ReportProviderReadinessResponse.report_interval:type_name -> google.protobuf.Duration - 269, // 98: openshell.v1.ReportProviderReadinessResponse.observation_ttl:type_name -> google.protobuf.Duration - 15, // 99: openshell.v1.DeleteSandboxResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 270, // 100: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 265, // 101: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp - 270, // 102: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 103: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 104: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 92, // 105: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 270, // 106: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 15, // 107: openshell.v1.DeleteServiceResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 266, // 108: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 91, // 109: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 15, // 110: openshell.v1.RevokeSshSessionResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 270, // 111: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 252, // 112: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 269, // 113: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration - 96, // 114: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 97, // 115: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 98, // 116: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 191, // 117: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 192, // 118: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 100, // 119: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 95, // 120: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 103, // 121: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 266, // 122: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 265, // 123: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp - 270, // 124: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 265, // 125: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp - 33, // 126: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 107, // 127: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 47, // 128: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 108, // 129: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 202, // 130: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 265, // 131: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp - 253, // 132: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 270, // 133: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 271, // 134: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 270, // 135: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 136: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 137: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 271, // 138: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 254, // 139: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry - 270, // 140: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 271, // 141: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 75, // 142: openshell.v1.ProviderResponse.target_receipts:type_name -> openshell.v1.ProviderMutationReceipt - 271, // 143: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 270, // 144: openshell.v1.ListProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 145: openshell.v1.GetProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 137, // 146: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 269, // 147: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration - 120, // 148: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 7, // 149: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 121, // 150: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 126, // 151: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 122, // 152: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 8, // 153: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 269, // 154: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration - 269, // 155: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration - 124, // 156: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 125, // 157: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 8, // 158: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 265, // 159: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp - 265, // 160: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp - 265, // 161: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp - 14, // 162: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 265, // 163: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp - 270, // 164: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 127, // 165: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 270, // 166: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 8, // 167: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 255, // 168: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 265, // 169: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp - 127, // 170: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 270, // 171: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 127, // 172: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 270, // 173: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 15, // 174: openshell.v1.DeleteProviderRefreshResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 9, // 175: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 123, // 176: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 273, // 177: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 274, // 178: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 128, // 179: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 256, // 180: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 137, // 181: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 137, // 182: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 270, // 183: openshell.v1.ImportProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 118, // 184: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 119, // 185: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 137, // 186: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 270, // 187: openshell.v1.UpdateProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 118, // 188: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 119, // 189: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 137, // 190: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 270, // 191: openshell.v1.LintProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 118, // 192: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 119, // 193: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 15, // 194: openshell.v1.DeleteProviderResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 270, // 195: openshell.v1.DeleteProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 15, // 196: openshell.v1.DeleteProviderProfileResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 150, // 197: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 257, // 198: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 258, // 199: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry - 259, // 200: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 260, // 201: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 3, // 202: openshell.v1.GetSandboxProviderEnvironmentResponse.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason - 269, // 203: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration - 270, // 204: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 267, // 205: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 275, // 206: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 156, // 207: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 261, // 208: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 157, // 209: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 158, // 210: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 159, // 211: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 161, // 212: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 162, // 213: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 163, // 214: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 276, // 215: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 274, // 216: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 277, // 217: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 160, // 218: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget - 278, // 219: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 160, // 220: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget - 262, // 221: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 270, // 222: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 174, // 223: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 270, // 224: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 174, // 225: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 11, // 226: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 10, // 227: openshell.v1.SandboxConfigurationAdmission.state:type_name -> openshell.v1.ConfigurationAdmissionState - 171, // 228: openshell.v1.ReportSandboxConfigurationRequest.admission:type_name -> openshell.v1.SandboxConfigurationAdmission - 11, // 229: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 265, // 230: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp - 265, // 231: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp - 267, // 232: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 263, // 233: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 270, // 234: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 265, // 235: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp - 107, // 236: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 107, // 237: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 181, // 238: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 184, // 239: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 195, // 240: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 196, // 241: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 182, // 242: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 183, // 243: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 185, // 244: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 190, // 245: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 196, // 246: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 269, // 247: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration - 191, // 248: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 192, // 249: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 193, // 250: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 265, // 251: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp - 265, // 252: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp - 197, // 253: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 199, // 254: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 276, // 255: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 265, // 256: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp - 265, // 257: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp - 265, // 258: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp - 265, // 259: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp - 267, // 260: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 267, // 261: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 270, // 262: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 198, // 263: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 201, // 264: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 200, // 265: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 270, // 266: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 201, // 267: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 265, // 268: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp - 270, // 269: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 270: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 271: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 211, // 272: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 270, // 273: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 276, // 274: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 270, // 275: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 276: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 270, // 277: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 265, // 278: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp - 221, // 279: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 264, // 280: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 279, // 281: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 279, // 282: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 279, // 283: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 15, // 284: openshell.v1.DeleteWorkspaceResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 266, // 285: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 13, // 286: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 270, // 287: openshell.v1.AddWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 13, // 288: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 231, // 289: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 270, // 290: openshell.v1.RemoveWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 15, // 291: openshell.v1.RemoveWorkspaceMemberResponse.outcome:type_name -> openshell.v1.DeletionOutcome - 270, // 292: openshell.v1.ListWorkspaceMembersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 231, // 293: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 265, // 294: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp - 16, // 295: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult - 239, // 296: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation - 16, // 297: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult - 265, // 298: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp - 265, // 299: openshell.v1.SandboxProvisioning.configuration_change_time:type_name -> google.protobuf.Timestamp - 265, // 300: openshell.v1.SandboxProvisioning.first_rejection_time:type_name -> google.protobuf.Timestamp - 265, // 301: openshell.v1.SandboxProvisioning.deadline:type_name -> google.protobuf.Timestamp - 265, // 302: openshell.v1.SandboxProvisioning.timeout_time:type_name -> google.protobuf.Timestamp - 265, // 303: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp - 265, // 304: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp - 265, // 305: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp - 265, // 306: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp - 265, // 307: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp - 123, // 308: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 151, // 309: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 21, // 310: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 23, // 311: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 25, // 312: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 48, // 313: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 56, // 314: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 58, // 315: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 59, // 316: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 49, // 317: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 50, // 318: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 51, // 319: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 52, // 320: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 60, // 321: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 61, // 322: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 62, // 323: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 78, // 324: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest - 63, // 325: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 64, // 326: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 65, // 327: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 83, // 328: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 85, // 329: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 86, // 330: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 87, // 331: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 89, // 332: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 93, // 333: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 95, // 334: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 101, // 335: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 102, // 336: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 109, // 337: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 110, // 338: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 111, // 339: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 116, // 340: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 117, // 341: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 140, // 342: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 142, // 343: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 144, // 344: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 112, // 345: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 129, // 346: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 131, // 347: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 133, // 348: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 135, // 349: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 113, // 350: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 147, // 351: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 280, // 352: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 281, // 353: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 155, // 354: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 165, // 355: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 167, // 356: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 169, // 357: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 240, // 358: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest - 80, // 359: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest - 172, // 360: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest - 149, // 361: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 153, // 362: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 175, // 363: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 176, // 364: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 179, // 365: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 186, // 366: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 188, // 367: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 194, // 368: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 105, // 369: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 203, // 370: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 205, // 371: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 207, // 372: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 209, // 373: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 212, // 374: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 214, // 375: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 216, // 376: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 218, // 377: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 220, // 378: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 17, // 379: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 19, // 380: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 223, // 381: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 225, // 382: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 227, // 383: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 229, // 384: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 232, // 385: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 234, // 386: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 236, // 387: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 22, // 388: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 24, // 389: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 26, // 390: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 66, // 391: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 57, // 392: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 66, // 393: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 67, // 394: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 53, // 395: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 53, // 396: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 54, // 397: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 55, // 398: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 68, // 399: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 69, // 400: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 70, // 401: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 79, // 402: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse - 82, // 403: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 66, // 404: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 66, // 405: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 84, // 406: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 92, // 407: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 92, // 408: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 88, // 409: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 90, // 410: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 94, // 411: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 99, // 412: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 101, // 413: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 99, // 414: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 114, // 415: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 114, // 416: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 115, // 417: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 139, // 418: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 138, // 419: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 141, // 420: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 143, // 421: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 145, // 422: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 114, // 423: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 130, // 424: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 132, // 425: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 134, // 426: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 136, // 427: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 146, // 428: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 148, // 429: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 282, // 430: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 283, // 431: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 164, // 432: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 166, // 433: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 168, // 434: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 170, // 435: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 241, // 436: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse - 81, // 437: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse - 173, // 438: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse - 152, // 439: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 154, // 440: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 178, // 441: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 177, // 442: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 180, // 443: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 187, // 444: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 189, // 445: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 194, // 446: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 106, // 447: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 204, // 448: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 206, // 449: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 208, // 450: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 210, // 451: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 213, // 452: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 215, // 453: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 217, // 454: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 219, // 455: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 222, // 456: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 18, // 457: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 20, // 458: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 224, // 459: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 226, // 460: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 228, // 461: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 230, // 462: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 233, // 463: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 235, // 464: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 237, // 465: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 388, // [388:466] is the sub-list for method output_type - 310, // [310:388] is the sub-list for method input_type - 310, // [310:310] is the sub-list for extension type_name - 310, // [310:310] is the sub-list for extension extendee - 0, // [0:310] is the sub-list for field type_name + 271, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 271, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 242, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 271, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp + 13, // 4: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 13, // 5: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus + 29, // 6: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 28, // 7: openshell.v1.GetGatewayInfoResponse.extensions:type_name -> openshell.v1.NegotiatedExtensionInfo + 0, // 8: openshell.v1.NegotiatedExtensionInfo.kind:type_name -> openshell.v1.ExtensionKind + 30, // 9: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 31, // 10: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities + 32, // 11: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities + 33, // 12: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities + 34, // 13: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities + 272, // 14: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 36, // 15: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 47, // 16: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 46, // 17: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance + 249, // 18: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 39, // 19: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 273, // 20: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 37, // 21: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 38, // 22: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 250, // 23: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 251, // 24: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 252, // 25: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 274, // 26: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 274, // 27: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 272, // 28: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 41, // 29: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec + 42, // 30: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig + 274, // 31: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 44, // 32: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel + 253, // 33: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 43, // 34: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources + 38, // 35: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements + 45, // 36: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup + 275, // 37: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 48, // 38: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 1, // 39: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 246, // 40: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus + 173, // 41: openshell.v1.SandboxStatus.configuration_admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 247, // 42: openshell.v1.SandboxStatus.provisioning:type_name -> openshell.v1.SandboxProvisioning + 271, // 43: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp + 271, // 44: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp + 254, // 45: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 276, // 46: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 36, // 47: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 255, // 48: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 256, // 49: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 248, // 50: openshell.v1.CreateSandboxRequest.service_exposures:type_name -> openshell.v1.SandboxServiceExposure + 276, // 51: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 40, // 52: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 276, // 53: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 54: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 55: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 40, // 56: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 40, // 57: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 16, // 58: openshell.v1.DeleteSandboxTemplateResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 276, // 59: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 271, // 60: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp + 276, // 61: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 62: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 63: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 64: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 65: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 66: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 67: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 68: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 35, // 69: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 257, // 70: openshell.v1.SandboxResponse.service_urls:type_name -> openshell.v1.SandboxResponse.ServiceUrlsEntry + 35, // 71: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 277, // 72: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 35, // 73: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 77, // 74: openshell.v1.AttachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt + 35, // 75: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 77, // 76: openshell.v1.DetachSandboxProviderResponse.receipt:type_name -> openshell.v1.ProviderMutationReceipt + 75, // 77: openshell.v1.ConfigSnapshotRevision.sandbox_config:type_name -> openshell.v1.SandboxConfigRevision + 73, // 78: openshell.v1.ConfigSnapshotRevision.provider_target:type_name -> openshell.v1.ProviderDesiredIdentity + 278, // 79: openshell.v1.SandboxConfigRevision.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 5, // 80: openshell.v1.ConfigUpdateOperation.component:type_name -> openshell.v1.ConfigComponent + 74, // 81: openshell.v1.ConfigUpdateOperation.target_revision:type_name -> openshell.v1.ConfigSnapshotRevision + 7, // 82: openshell.v1.ConfigUpdateOperation.state:type_name -> openshell.v1.ConfigUpdateOperationState + 6, // 83: openshell.v1.ConfigUpdateOperation.outcome:type_name -> openshell.v1.ConfigApplyOutcome + 271, // 84: openshell.v1.ConfigUpdateOperation.created_time:type_name -> google.protobuf.Timestamp + 271, // 85: openshell.v1.ConfigUpdateOperation.updated_time:type_name -> google.protobuf.Timestamp + 271, // 86: openshell.v1.ConfigUpdateOperation.completed_time:type_name -> google.protobuf.Timestamp + 2, // 87: openshell.v1.ProviderMutationReceipt.kind:type_name -> openshell.v1.ProviderMutationKind + 73, // 88: openshell.v1.ProviderMutationReceipt.desired:type_name -> openshell.v1.ProviderDesiredIdentity + 271, // 89: openshell.v1.ProviderMutationReceipt.persisted_time:type_name -> google.protobuf.Timestamp + 4, // 90: openshell.v1.ProviderReadinessObservation.reason:type_name -> openshell.v1.ProviderReadinessReason + 77, // 91: openshell.v1.ProviderReadinessStatus.receipt:type_name -> openshell.v1.ProviderMutationReceipt + 3, // 92: openshell.v1.ProviderReadinessStatus.state:type_name -> openshell.v1.ProviderReadinessState + 4, // 93: openshell.v1.ProviderReadinessStatus.reason:type_name -> openshell.v1.ProviderReadinessReason + 78, // 94: openshell.v1.ProviderReadinessStatus.observed:type_name -> openshell.v1.ProviderReadinessObservation + 271, // 95: openshell.v1.ProviderReadinessStatus.observed_time:type_name -> google.protobuf.Timestamp + 271, // 96: openshell.v1.ProviderReadinessStatus.evaluated_time:type_name -> google.protobuf.Timestamp + 76, // 97: openshell.v1.ProviderReadinessStatus.operation:type_name -> openshell.v1.ConfigUpdateOperation + 276, // 98: openshell.v1.GetSandboxProviderStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 79, // 99: openshell.v1.GetSandboxProviderStatusResponse.status:type_name -> openshell.v1.ProviderReadinessStatus + 78, // 100: openshell.v1.ReportProviderReadinessRequest.observation:type_name -> openshell.v1.ProviderReadinessObservation + 275, // 101: openshell.v1.ReportProviderReadinessResponse.report_interval:type_name -> google.protobuf.Duration + 275, // 102: openshell.v1.ReportProviderReadinessResponse.observation_ttl:type_name -> google.protobuf.Duration + 16, // 103: openshell.v1.DeleteSandboxResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 276, // 104: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 271, // 105: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp + 276, // 106: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 107: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 108: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 94, // 109: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 276, // 110: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 16, // 111: openshell.v1.DeleteServiceResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 272, // 112: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 93, // 113: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 16, // 114: openshell.v1.RevokeSshSessionResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 276, // 115: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 258, // 116: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 275, // 117: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration + 98, // 118: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 99, // 119: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 100, // 120: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 193, // 121: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 194, // 122: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 102, // 123: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 97, // 124: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 105, // 125: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 272, // 126: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 271, // 127: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp + 276, // 128: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 271, // 129: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp + 35, // 130: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 109, // 131: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 49, // 132: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 110, // 133: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 206, // 134: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 271, // 135: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp + 259, // 136: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 276, // 137: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 277, // 138: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 276, // 139: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 140: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 141: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 277, // 142: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 260, // 143: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + 276, // 144: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 277, // 145: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 77, // 146: openshell.v1.ProviderResponse.target_receipts:type_name -> openshell.v1.ProviderMutationReceipt + 277, // 147: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 276, // 148: openshell.v1.ListProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 149: openshell.v1.GetProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 139, // 150: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 275, // 151: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration + 122, // 152: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 8, // 153: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 123, // 154: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 128, // 155: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 124, // 156: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 9, // 157: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 275, // 158: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration + 275, // 159: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration + 126, // 160: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 127, // 161: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 9, // 162: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 271, // 163: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp + 271, // 164: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp + 271, // 165: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp + 15, // 166: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 271, // 167: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp + 276, // 168: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 129, // 169: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 276, // 170: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 9, // 171: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 261, // 172: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 271, // 173: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp + 129, // 174: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 276, // 175: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 129, // 176: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 276, // 177: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 16, // 178: openshell.v1.DeleteProviderRefreshResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 10, // 179: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 125, // 180: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 279, // 181: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 280, // 182: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 130, // 183: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 262, // 184: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 139, // 185: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 139, // 186: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 276, // 187: openshell.v1.ImportProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 120, // 188: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 121, // 189: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 139, // 190: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 276, // 191: openshell.v1.UpdateProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 120, // 192: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 121, // 193: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 139, // 194: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 276, // 195: openshell.v1.LintProviderProfilesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 120, // 196: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 121, // 197: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 16, // 198: openshell.v1.DeleteProviderResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 276, // 199: openshell.v1.DeleteProviderProfileRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 16, // 200: openshell.v1.DeleteProviderProfileResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 152, // 201: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 263, // 202: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 264, // 203: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + 265, // 204: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 266, // 205: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 4, // 206: openshell.v1.GetSandboxProviderEnvironmentResponse.readiness_reason:type_name -> openshell.v1.ProviderReadinessReason + 275, // 207: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration + 276, // 208: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 273, // 209: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 281, // 210: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 158, // 211: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 267, // 212: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 159, // 213: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 160, // 214: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 161, // 215: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 163, // 216: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 164, // 217: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 165, // 218: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 282, // 219: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 280, // 220: openshell.v1.L7RuleTarget.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 283, // 221: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 162, // 222: openshell.v1.AddDenyRules.target:type_name -> openshell.v1.L7RuleTarget + 284, // 223: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 162, // 224: openshell.v1.AddAllowRules.target:type_name -> openshell.v1.L7RuleTarget + 268, // 225: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 276, // 226: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 176, // 227: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 276, // 228: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 176, // 229: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 12, // 230: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 11, // 231: openshell.v1.SandboxConfigurationAdmission.state:type_name -> openshell.v1.ConfigurationAdmissionState + 173, // 232: openshell.v1.ReportSandboxConfigurationRequest.admission:type_name -> openshell.v1.SandboxConfigurationAdmission + 12, // 233: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 271, // 234: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp + 271, // 235: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp + 273, // 236: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 269, // 237: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 276, // 238: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 271, // 239: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp + 109, // 240: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 109, // 241: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 183, // 242: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 186, // 243: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 199, // 244: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 200, // 245: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 184, // 246: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 185, // 247: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 187, // 248: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 192, // 249: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 200, // 250: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 275, // 251: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration + 193, // 252: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 194, // 253: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 195, // 254: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 192, // 255: openshell.v1.PeerRelayInit.relay_open:type_name -> openshell.v1.RelayOpen + 197, // 256: openshell.v1.PeerRelayFrame.init:type_name -> openshell.v1.PeerRelayInit + 271, // 257: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp + 271, // 258: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp + 201, // 259: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 203, // 260: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 282, // 261: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 271, // 262: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp + 271, // 263: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp + 271, // 264: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp + 271, // 265: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp + 273, // 266: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 273, // 267: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 276, // 268: openshell.v1.SubmitPolicyAnalysisRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 202, // 269: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 205, // 270: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 204, // 271: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 276, // 272: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 205, // 273: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 271, // 274: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp + 276, // 275: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 276: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 277: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 215, // 278: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 276, // 279: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 282, // 280: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 276, // 281: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 282: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 276, // 283: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 271, // 284: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp + 225, // 285: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 270, // 286: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 285, // 287: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 285, // 288: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 285, // 289: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 16, // 290: openshell.v1.DeleteWorkspaceResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 272, // 291: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 14, // 292: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 276, // 293: openshell.v1.AddWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 14, // 294: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 235, // 295: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 276, // 296: openshell.v1.RemoveWorkspaceMemberRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 16, // 297: openshell.v1.RemoveWorkspaceMemberResponse.outcome:type_name -> openshell.v1.DeletionOutcome + 276, // 298: openshell.v1.ListWorkspaceMembersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 235, // 299: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 271, // 300: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp + 17, // 301: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult + 243, // 302: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation + 17, // 303: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult + 271, // 304: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp + 271, // 305: openshell.v1.SandboxProvisioning.configuration_change_time:type_name -> google.protobuf.Timestamp + 271, // 306: openshell.v1.SandboxProvisioning.first_rejection_time:type_name -> google.protobuf.Timestamp + 271, // 307: openshell.v1.SandboxProvisioning.deadline:type_name -> google.protobuf.Timestamp + 271, // 308: openshell.v1.SandboxProvisioning.timeout_time:type_name -> google.protobuf.Timestamp + 271, // 309: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp + 271, // 310: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp + 271, // 311: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp + 271, // 312: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 271, // 313: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 125, // 314: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 153, // 315: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 22, // 316: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 24, // 317: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 26, // 318: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 50, // 319: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 58, // 320: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 60, // 321: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 61, // 322: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 51, // 323: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 52, // 324: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 53, // 325: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 54, // 326: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 62, // 327: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 63, // 328: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 64, // 329: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 80, // 330: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest + 65, // 331: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 66, // 332: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 67, // 333: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 85, // 334: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 87, // 335: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 88, // 336: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 89, // 337: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 91, // 338: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 95, // 339: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 97, // 340: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 103, // 341: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 104, // 342: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 111, // 343: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 112, // 344: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 113, // 345: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 118, // 346: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 119, // 347: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 142, // 348: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 144, // 349: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 146, // 350: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 114, // 351: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 131, // 352: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 133, // 353: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 135, // 354: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 137, // 355: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 115, // 356: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 149, // 357: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 286, // 358: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 287, // 359: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 157, // 360: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 167, // 361: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 169, // 362: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 171, // 363: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 244, // 364: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 82, // 365: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest + 174, // 366: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest + 151, // 367: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 155, // 368: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 177, // 369: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 178, // 370: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 181, // 371: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 188, // 372: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 190, // 373: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 196, // 374: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 198, // 375: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame + 82, // 376: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest + 244, // 377: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 80, // 378: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest + 107, // 379: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 207, // 380: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 209, // 381: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 211, // 382: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 213, // 383: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 216, // 384: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 218, // 385: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 220, // 386: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 222, // 387: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 224, // 388: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 18, // 389: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 20, // 390: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 227, // 391: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 229, // 392: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 231, // 393: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 233, // 394: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 236, // 395: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 238, // 396: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 240, // 397: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 23, // 398: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 25, // 399: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 27, // 400: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 68, // 401: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 59, // 402: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 68, // 403: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 69, // 404: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 55, // 405: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 55, // 406: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 56, // 407: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 57, // 408: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 70, // 409: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 71, // 410: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 72, // 411: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 81, // 412: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse + 84, // 413: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 68, // 414: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 68, // 415: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 86, // 416: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 94, // 417: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 94, // 418: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 90, // 419: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 92, // 420: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 96, // 421: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 101, // 422: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 103, // 423: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 101, // 424: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 116, // 425: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 116, // 426: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 117, // 427: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 141, // 428: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 140, // 429: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 143, // 430: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 145, // 431: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 147, // 432: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 116, // 433: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 132, // 434: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 134, // 435: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 136, // 436: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 138, // 437: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 148, // 438: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 150, // 439: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 288, // 440: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 289, // 441: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 166, // 442: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 168, // 443: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 170, // 444: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 172, // 445: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 245, // 446: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 83, // 447: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse + 175, // 448: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse + 154, // 449: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 156, // 450: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 180, // 451: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 179, // 452: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 182, // 453: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 189, // 454: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 191, // 455: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 196, // 456: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 198, // 457: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame + 83, // 458: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse + 245, // 459: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 81, // 460: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse + 108, // 461: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 208, // 462: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 210, // 463: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 212, // 464: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 214, // 465: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 217, // 466: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 219, // 467: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 221, // 468: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 223, // 469: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 226, // 470: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 19, // 471: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 21, // 472: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 228, // 473: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 230, // 474: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 232, // 475: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 234, // 476: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 237, // 477: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 239, // 478: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 241, // 479: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 398, // [398:480] is the sub-list for method output_type + 316, // [316:398] is the sub-list for method input_type + 316, // [316:316] is the sub-list for extension type_name + 316, // [316:316] is the sub-list for extension extendee + 0, // [0:316] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -19419,40 +19893,40 @@ 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[55].OneofWrappers = []any{ + file_openshell_proto_msgTypes[21].OneofWrappers = []any{} + file_openshell_proto_msgTypes[29].OneofWrappers = []any{} + file_openshell_proto_msgTypes[56].OneofWrappers = []any{ (*ConfigSnapshotRevision_SandboxConfig)(nil), (*ConfigSnapshotRevision_ProviderEnvironment)(nil), (*ConfigSnapshotRevision_ProviderTarget)(nil), } - file_openshell_proto_msgTypes[82].OneofWrappers = []any{ + file_openshell_proto_msgTypes[83].OneofWrappers = []any{ (*ExecSandboxEvent_Stdout)(nil), (*ExecSandboxEvent_Stderr)(nil), (*ExecSandboxEvent_Exit)(nil), } - file_openshell_proto_msgTypes[83].OneofWrappers = []any{ + file_openshell_proto_msgTypes[84].OneofWrappers = []any{ (*TcpForwardInit_Ssh)(nil), (*TcpForwardInit_Tcp)(nil), } - file_openshell_proto_msgTypes[84].OneofWrappers = []any{ + file_openshell_proto_msgTypes[85].OneofWrappers = []any{ (*TcpForwardFrame_Init)(nil), (*TcpForwardFrame_Data)(nil), } - file_openshell_proto_msgTypes[85].OneofWrappers = []any{ + file_openshell_proto_msgTypes[86].OneofWrappers = []any{ (*ExecSandboxInput_Start)(nil), (*ExecSandboxInput_Stdin)(nil), (*ExecSandboxInput_Resize)(nil), } - file_openshell_proto_msgTypes[89].OneofWrappers = []any{ + file_openshell_proto_msgTypes[90].OneofWrappers = []any{ (*SandboxStreamEvent_Sandbox)(nil), (*SandboxStreamEvent_Log)(nil), (*SandboxStreamEvent_Event)(nil), (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[139].OneofWrappers = []any{ + file_openshell_proto_msgTypes[140].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), (*PolicyMergeOperation_RemoveRule)(nil), @@ -19460,35 +19934,39 @@ func file_openshell_proto_init() { (*PolicyMergeOperation_AddAllowRules)(nil), (*PolicyMergeOperation_RemoveBinary)(nil), } - file_openshell_proto_msgTypes[143].OneofWrappers = []any{} - file_openshell_proto_msgTypes[162].OneofWrappers = []any{ + file_openshell_proto_msgTypes[144].OneofWrappers = []any{} + file_openshell_proto_msgTypes[163].OneofWrappers = []any{ (*SupervisorMessage_Hello)(nil), (*SupervisorMessage_Heartbeat)(nil), (*SupervisorMessage_RelayOpenResult)(nil), (*SupervisorMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[163].OneofWrappers = []any{ + file_openshell_proto_msgTypes[164].OneofWrappers = []any{ (*GatewayMessage_SessionAccepted)(nil), (*GatewayMessage_SessionRejected)(nil), (*GatewayMessage_Heartbeat)(nil), (*GatewayMessage_RelayOpen)(nil), (*GatewayMessage_RelayClose)(nil), } - file_openshell_proto_msgTypes[173].OneofWrappers = []any{ + file_openshell_proto_msgTypes[174].OneofWrappers = []any{ (*RelayOpen_Ssh)(nil), (*RelayOpen_Tcp)(nil), } - file_openshell_proto_msgTypes[177].OneofWrappers = []any{ + file_openshell_proto_msgTypes[178].OneofWrappers = []any{ (*RelayFrame_Init)(nil), (*RelayFrame_Data)(nil), } + file_openshell_proto_msgTypes[180].OneofWrappers = []any{ + (*PeerRelayFrame_Init)(nil), + (*PeerRelayFrame_Data)(nil), + } 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: 17, - NumMessages: 248, + NumEnums: 18, + NumMessages: 253, 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 0609450c8..d185e2428 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -82,6 +82,10 @@ const ( OpenShell_ReportMainProcessExit_FullMethodName = "/openshell.v1.OpenShell/ReportMainProcessExit" OpenShell_FinalizeMainProcessExit_FullMethodName = "/openshell.v1.OpenShell/FinalizeMainProcessExit" OpenShell_RelayStream_FullMethodName = "/openshell.v1.OpenShell/RelayStream" + OpenShell_PeerRelay_FullMethodName = "/openshell.v1.OpenShell/PeerRelay" + OpenShell_PeerReportProviderReadiness_FullMethodName = "/openshell.v1.OpenShell/PeerReportProviderReadiness" + OpenShell_PeerReportEndpointStatus_FullMethodName = "/openshell.v1.OpenShell/PeerReportEndpointStatus" + OpenShell_PeerGetSandboxProviderStatus_FullMethodName = "/openshell.v1.OpenShell/PeerGetSandboxProviderStatus" OpenShell_WatchSandbox_FullMethodName = "/openshell.v1.OpenShell/WatchSandbox" OpenShell_SubmitPolicyAnalysis_FullMethodName = "/openshell.v1.OpenShell/SubmitPolicyAnalysis" OpenShell_GetDraftPolicy_FullMethodName = "/openshell.v1.OpenShell/GetDraftPolicy" @@ -267,6 +271,18 @@ type OpenShellClient interface { // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — // no new TLS handshake, no reverse HTTP CONNECT. RelayStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[RelayFrame, RelayFrame], error) + // Internal gateway-to-gateway relay forwarding. + // + // A gateway replica that receives a user request for a sandbox whose + // supervisor session is owned by a different replica opens this stream to the + // owner. The first frame carries PeerRelayInit; subsequent frames carry raw + // bytes in either direction. This RPC is authenticated as a gateway peer, not + // as a user or sandbox supervisor. + PeerRelay(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[PeerRelayFrame, PeerRelayFrame], error) + // Internal gateway-to-owner forwarding for supervisor-bound state. + PeerReportProviderReadiness(ctx context.Context, in *ReportProviderReadinessRequest, opts ...grpc.CallOption) (*ReportProviderReadinessResponse, error) + PeerReportEndpointStatus(ctx context.Context, in *ReportEndpointStatusRequest, opts ...grpc.CallOption) (*ReportEndpointStatusResponse, error) + PeerGetSandboxProviderStatus(ctx context.Context, in *GetSandboxProviderStatusRequest, opts ...grpc.CallOption) (*GetSandboxProviderStatusResponse, error) // Watch a sandbox and stream updates. // // This stream can include: @@ -943,9 +959,52 @@ func (c *openShellClient) RelayStream(ctx context.Context, opts ...grpc.CallOpti // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type OpenShell_RelayStreamClient = grpc.BidiStreamingClient[RelayFrame, RelayFrame] +func (c *openShellClient) PeerRelay(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[PeerRelayFrame, PeerRelayFrame], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[6], OpenShell_PeerRelay_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[PeerRelayFrame, PeerRelayFrame]{ClientStream: stream} + 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_PeerRelayClient = grpc.BidiStreamingClient[PeerRelayFrame, PeerRelayFrame] + +func (c *openShellClient) PeerReportProviderReadiness(ctx context.Context, in *ReportProviderReadinessRequest, opts ...grpc.CallOption) (*ReportProviderReadinessResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportProviderReadinessResponse) + err := c.cc.Invoke(ctx, OpenShell_PeerReportProviderReadiness_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) PeerReportEndpointStatus(ctx context.Context, in *ReportEndpointStatusRequest, opts ...grpc.CallOption) (*ReportEndpointStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportEndpointStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_PeerReportEndpointStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) PeerGetSandboxProviderStatus(ctx context.Context, in *GetSandboxProviderStatusRequest, opts ...grpc.CallOption) (*GetSandboxProviderStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxProviderStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_PeerGetSandboxProviderStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) WatchSandbox(ctx context.Context, in *WatchSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxStreamEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[6], OpenShell_WatchSandbox_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[7], OpenShell_WatchSandbox_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -1306,6 +1365,18 @@ type OpenShellServer interface { // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — // no new TLS handshake, no reverse HTTP CONNECT. RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error + // Internal gateway-to-gateway relay forwarding. + // + // A gateway replica that receives a user request for a sandbox whose + // supervisor session is owned by a different replica opens this stream to the + // owner. The first frame carries PeerRelayInit; subsequent frames carry raw + // bytes in either direction. This RPC is authenticated as a gateway peer, not + // as a user or sandbox supervisor. + PeerRelay(grpc.BidiStreamingServer[PeerRelayFrame, PeerRelayFrame]) error + // Internal gateway-to-owner forwarding for supervisor-bound state. + PeerReportProviderReadiness(context.Context, *ReportProviderReadinessRequest) (*ReportProviderReadinessResponse, error) + PeerReportEndpointStatus(context.Context, *ReportEndpointStatusRequest) (*ReportEndpointStatusResponse, error) + PeerGetSandboxProviderStatus(context.Context, *GetSandboxProviderStatusRequest) (*GetSandboxProviderStatusResponse, error) // Watch a sandbox and stream updates. // // This stream can include: @@ -1545,6 +1616,18 @@ func (UnimplementedOpenShellServer) FinalizeMainProcessExit(context.Context, *Fi func (UnimplementedOpenShellServer) RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error { return status.Error(codes.Unimplemented, "method RelayStream not implemented") } +func (UnimplementedOpenShellServer) PeerRelay(grpc.BidiStreamingServer[PeerRelayFrame, PeerRelayFrame]) error { + return status.Error(codes.Unimplemented, "method PeerRelay not implemented") +} +func (UnimplementedOpenShellServer) PeerReportProviderReadiness(context.Context, *ReportProviderReadinessRequest) (*ReportProviderReadinessResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PeerReportProviderReadiness not implemented") +} +func (UnimplementedOpenShellServer) PeerReportEndpointStatus(context.Context, *ReportEndpointStatusRequest) (*ReportEndpointStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PeerReportEndpointStatus not implemented") +} +func (UnimplementedOpenShellServer) PeerGetSandboxProviderStatus(context.Context, *GetSandboxProviderStatusRequest) (*GetSandboxProviderStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PeerGetSandboxProviderStatus not implemented") +} func (UnimplementedOpenShellServer) WatchSandbox(*WatchSandboxRequest, grpc.ServerStreamingServer[SandboxStreamEvent]) error { return status.Error(codes.Unimplemented, "method WatchSandbox not implemented") } @@ -2623,6 +2706,67 @@ func _OpenShell_RelayStream_Handler(srv interface{}, stream grpc.ServerStream) e // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type OpenShell_RelayStreamServer = grpc.BidiStreamingServer[RelayFrame, RelayFrame] +func _OpenShell_PeerRelay_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).PeerRelay(&grpc.GenericServerStream[PeerRelayFrame, PeerRelayFrame]{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_PeerRelayServer = grpc.BidiStreamingServer[PeerRelayFrame, PeerRelayFrame] + +func _OpenShell_PeerReportProviderReadiness_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportProviderReadinessRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).PeerReportProviderReadiness(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_PeerReportProviderReadiness_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).PeerReportProviderReadiness(ctx, req.(*ReportProviderReadinessRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_PeerReportEndpointStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportEndpointStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).PeerReportEndpointStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_PeerReportEndpointStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).PeerReportEndpointStatus(ctx, req.(*ReportEndpointStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_PeerGetSandboxProviderStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxProviderStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).PeerGetSandboxProviderStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_PeerGetSandboxProviderStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).PeerGetSandboxProviderStatus(ctx, req.(*GetSandboxProviderStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_WatchSandbox_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(WatchSandboxRequest) if err := stream.RecvMsg(m); err != nil { @@ -3177,6 +3321,18 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "FinalizeMainProcessExit", Handler: _OpenShell_FinalizeMainProcessExit_Handler, }, + { + MethodName: "PeerReportProviderReadiness", + Handler: _OpenShell_PeerReportProviderReadiness_Handler, + }, + { + MethodName: "PeerReportEndpointStatus", + Handler: _OpenShell_PeerReportEndpointStatus_Handler, + }, + { + MethodName: "PeerGetSandboxProviderStatus", + Handler: _OpenShell_PeerGetSandboxProviderStatus_Handler, + }, { MethodName: "SubmitPolicyAnalysis", Handler: _OpenShell_SubmitPolicyAnalysis_Handler, @@ -3285,6 +3441,12 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, ClientStreams: true, }, + { + StreamName: "PeerRelay", + Handler: _OpenShell_PeerRelay_Handler, + ServerStreams: true, + ClientStreams: true, + }, { StreamName: "WatchSandbox", Handler: _OpenShell_WatchSandbox_Handler, diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 26ac0738a..e56348577 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -35,7 +35,9 @@ const client = await OpenShellClient.connect({ const sandbox = await client.sandbox.create({ image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + serviceExposures: [{ targetPort: 8080 }], }) +console.log(sandbox.serviceUrls['']) await client.sandbox.waitReady(sandbox.name, 120) const result = await client.sandbox.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 356ce0d59..f5f3dcf53 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -274,6 +274,36 @@ describe('exec / execStream', () => { }); describe('create', () => { + it('sends create-time service exposures', async () => { + let created: { serviceExposures?: Array<{ service?: string; targetPort?: number }> } = {}; + const sandbox = client({ + createSandbox: (req) => { + created = req; + return { + ...readySandbox('sb', 'sb-id'), + serviceUrls: { + '': 'https://sb.example.test/', + metrics: 'https://metrics.sb.example.test/', + }, + }; + }, + }); + + const result = await sandbox.create({ + image: 'img', + serviceExposures: [{ targetPort: 4500 }, { service: 'metrics', targetPort: 9090 }], + }); + + expect(created.serviceExposures?.map(({ service, targetPort }) => ({ service, targetPort }))).toEqual([ + { service: '', targetPort: 4500 }, + { service: 'metrics', targetPort: 9090 }, + ]); + expect(result.serviceUrls).toEqual({ + '': 'https://sb.example.test/', + metrics: 'https://metrics.sb.example.test/', + }); + }); + it('sends the curated policy through spec.policy', async () => { let created: { spec?: { policy?: { version?: number } } } = {}; const sandbox = client({ diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 546f151e4..549a5e602 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -139,6 +139,8 @@ export interface SandboxSpec { command?: string[]; /** Allocate a retained pseudo-terminal for the canonical command. */ tty?: boolean; + /** Loopback HTTP services to expose when the sandbox is created. */ + serviceExposures?: ServiceExposure[]; /** * Create-time sandbox policy (the safety boundary). Sandbox-scoped * `setPolicy` cannot introduce static fields later, so express filesystem, @@ -155,6 +157,13 @@ export interface SandboxSpec { rawSpec?: MessageInitShape; } +export interface ServiceExposure { + /** Service name. Empty or omitted selects the unnamed endpoint. */ + service?: string; + /** Loopback TCP port inside the sandbox. */ + targetPort: number; +} + export interface SandboxFromTemplateSpec { name?: string; /** Workspace name. Omit for `default`; empty strings are invalid. */ @@ -166,6 +175,8 @@ export interface SandboxFromTemplateSpec { command?: string[]; /** Allocate a retained pseudo-terminal for the canonical command. */ tty?: boolean; + /** Loopback HTTP services to expose when the sandbox is created. */ + serviceExposures?: ServiceExposure[]; /** * Create-time sandbox policy (the safety boundary). The named workload * template supplies runtime workload fields. @@ -184,6 +195,8 @@ export interface SandboxRef { mainProcessInstanceId?: string; exitCode?: number; createdFromWorkloadTemplate?: SandboxWorkloadTemplateProvenance; + /** Service URLs returned by creation, keyed by service name. */ + serviceUrls: Record; } export interface SandboxWorkloadTemplateProvenance { @@ -449,7 +462,7 @@ function policySourceName(s: PolicySource): PolicySourceName { return POLICY_SOURCE_NAMES[s] ?? 'unspecified'; } -function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { +function sandboxRef(sandbox: Sandbox | undefined, serviceUrls: Record = {}): SandboxRef { if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response'); const meta = sandbox.metadata; if (!meta?.id || !meta.name) { @@ -470,6 +483,7 @@ function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { resourceVersion: sandbox.createdFromWorkloadTemplate.resourceVersion, } : undefined, + serviceUrls, }; } @@ -877,8 +891,13 @@ export class SandboxClient { name: spec.name ?? '', labels: spec.labels ?? {}, spec: specInit, + serviceExposures: + spec.serviceExposures?.map((exposure) => ({ + service: exposure.service ?? '', + targetPort: exposure.targetPort, + })) ?? [], }); - return sandboxRef(resp.sandbox); + return sandboxRef(resp.sandbox, resp.serviceUrls); } catch (e) { throw fromConnect(e); } @@ -898,8 +917,13 @@ export class SandboxClient { policy: spec.policy, }, workloadTemplate: spec.workloadTemplate, + serviceExposures: + spec.serviceExposures?.map((exposure) => ({ + service: exposure.service ?? '', + targetPort: exposure.targetPort, + })) ?? [], }); - return sandboxRef(resp.sandbox); + return sandboxRef(resp.sandbox, resp.serviceUrls); } catch (e) { throw fromConnect(e); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index d858cd75b..3902f9135 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -41,6 +41,7 @@ export type { SandboxWorkloadTemplate, SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, + ServiceExposure, SetPolicyOptions, SettingScopeName, SettingValue, diff --git a/skills/debug-inference/SKILL.md b/skills/debug-inference/SKILL.md index e8df8cd10..dfeb0ca8c 100644 --- a/skills/debug-inference/SKILL.md +++ b/skills/debug-inference/SKILL.md @@ -34,7 +34,7 @@ container; bind it to an address reachable from the gateway runtime. ```bash openshell provider get -openshell provider profile export -o yaml +openshell profile export -o yaml ``` Check that the profile: @@ -49,8 +49,8 @@ endpoint-bearing profile. A base URL stored only in provider configuration does not authorize a new endpoint. ```bash -openshell provider profile lint -f ./provider-profile.yaml -openshell provider profile import -f ./provider-profile.yaml +openshell profile lint -f ./provider-profile.yaml +openshell profile import -f ./provider-profile.yaml openshell provider create --name --type ``` diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 8fccf3779..f81416c5a 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -117,7 +117,7 @@ injects it only into the selected local driver. TLS-enabled Docker, Podman, and VM drivers fail startup when neither those paths nor the package-managed local bundle is available; Kubernetes projects its bundle through a Secret. -Custom names use `[openshell.drivers.].socket_path`. A launch-time `--compute-driver-socket` override may also use `docker`, `podman`, `kubernetes`, or `vm`; the endpoint then takes precedence over built-in construction. First-party standalone drivers require the socket parent directory to be owned by the driver's effective UID, force its mode to `0700`, create the socket with mode `0600`, and accept only peers with that same UID. Check the parent and socket separately with `stat`; a gateway running under a different UID cannot connect even when filesystem permissions or group membership would otherwise allow it. Operator-supplied drivers must provide equivalent access control appropriate to their implementation. Check gateway logs for connection errors, `GetCapabilities` failures, or an unexpected advertised driver name. The advertised name is diagnostic metadata; negotiated features control optional behavior. The gateway does not create or supervise operator-supplied driver processes or sockets. +Custom names use `[openshell.drivers.].socket_path`. A launch-time `--compute-driver-socket` override may also use `docker`, `podman`, `kubernetes`, or `vm`; the endpoint then takes precedence over built-in construction. First-party standalone drivers require the socket parent directory to be owned by the driver's effective UID, force its mode to `0700`, create the socket with mode `0600`, and accept only peers with that same UID. Check the parent and socket separately with `stat`; a gateway running under a different UID cannot connect even when filesystem permissions or group membership would otherwise allow it. Operator-supplied drivers must provide equivalent access control appropriate to their implementation. Check gateway logs for connection errors, `GetCapabilities` failures, missing peer metadata, protocol-major mismatch, unmet required capabilities, or an unexpected advertised driver name. `openshell gateway info` reports successful startup negotiations. The advertised name is diagnostic metadata; negotiated features control optional behavior. The gateway does not create or supervise operator-supplied driver processes or sockets. For a configured Vault credential driver, inspect its endpoint and trust bundle before debugging provider resolution. Non-loopback addresses must use HTTPS, @@ -148,7 +148,7 @@ journalctl -u --no-pager --lines=200 journalctl -u openshell-gateway --no-pager --lines=200 ``` -The gateway calls each interceptor's `Describe` RPC and validates its manifest at startup. Check for unreachable endpoints, invalid RPC/phase bindings, strict `allowlist` or `exact` mismatches, and `post_commit` bindings that resolve to `fail_closed`. If gateway JWT signing is enabled, authenticated network interceptors require HTTPS and a valid bearer token; check the private CA path, endpoint hostname, expected audience, issuer, `kid`, and interceptor logs for token rejection. `allow_insecure_transport = true` explicitly preserves unauthenticated plaintext behavior. If `provider_profile_sources` names an interceptor, that interceptor must advertise provider-profile capability and return a valid, duplicate-free catalog. A selected interceptor-only source is authoritative; include a `user` source explicitly when composition is intended. The `builtin` source type was removed: a config that still names it is rejected at startup. +The gateway calls each interceptor's `Describe` RPC and validates its manifest at startup. Check for missing peer metadata, protocol-major mismatch, unmet required capabilities, unreachable endpoints, invalid RPC/phase bindings, strict `allowlist` or `exact` mismatches, and `post_commit` bindings that resolve to `fail_closed`. If gateway JWT signing is enabled, authenticated network interceptors require HTTPS and a valid bearer token; check the private CA path, endpoint hostname, expected audience, issuer, `kid`, and interceptor logs for token rejection. `allow_insecure_transport = true` explicitly preserves unauthenticated plaintext behavior. If `provider_profile_sources` names an interceptor, that interceptor must advertise provider-profile capability and return a valid, duplicate-free catalog. A selected interceptor-only source is authoritative; include a `user` source explicitly when composition is intended. The `builtin` source type was removed: a config that still names it is rejected at startup. If the deployment uses supervisor middleware, follow the [supervisor middleware troubleshooting reference](references/supervisor-middleware.md) @@ -260,7 +260,7 @@ Common findings: gateway's primary endpoint is reachable from a host-networked container. - Sandbox runtime image exits before printing `openshell-sandbox --version`: verify the configured image contains a static executable at `/openshell-sandbox`. - A sandbox with explicit `protocol: tcp` endpoints fails before workload readiness: confirm the selected isolation backend advertises TCP mediation, then inspect the sandbox and supervisor logs for protected-channel setup or listener failures. A driver that cannot supply the required outer egress fence and authenticated runtime channel must reject the policy before starting the agent. -- Supervisor runtime validation fails: verify `supervisor_image` contains a static `/openshell-supervisor` executable from the same release as the sandbox runtime. +- Supervisor runtime validation fails: verify `supervisor_image` contains an `/openshell-supervisor` executable from the same release as the sandbox runtime, and that the dynamic loader and shared libraries it links against are available inside that image. `docker run --rm --network none --entrypoint /openshell-supervisor --version` should print that release; a `no such file or directory` error for a binary that exists means the loader or a library is missing. The supervisor runs from its own image and does not need to be static; only `/openshell-sandbox` must be. - The sandbox fails its enforcement probe: inspect the sandbox log for the exact nested seccomp user-notification, task-memory, Landlock, loopback DNS, or socket-injection check that failed. Do not add capabilities or switch to an unconfined seccomp profile; use a runtime whose default profile permits the unprivileged probe. - A GPU sandbox fails because Docker reports no discovered NVIDIA CDI devices: verify `.DiscoveredDevices` contains entries such as `nvidia.com/gpu=all`, verify `/etc/cdi` or `/var/run/cdi` contains a generated NVIDIA spec, and check that `nvidia-cdi-refresh.service` and `nvidia-cdi-refresh.path` from NVIDIA Container Toolkit are enabled and healthy. The service is a one-shot unit, so `inactive (dead)` can be normal after a successful run; use `systemctl status` and `journalctl` to distinguish success from a skipped or failed refresh. Restart `nvidia-cdi-refresh.service` to regenerate missing or stale CDI specs, then restart or reload Docker and re-check `docker info`. @@ -331,14 +331,13 @@ When `userns` is configured (e.g. `userns = "auto"` or `userns = "keep-id"`): helm -n openshell status openshell helm -n openshell get values openshell kubectl -n openshell get deployment,statefulset,pod,svc,pvc -kubectl -n openshell logs deployment/openshell -c openshell-gateway --tail=200 -kubectl -n openshell logs statefulset/openshell -c openshell-gateway --tail=200 -kubectl -n openshell rollout status deployment/openshell -kubectl -n openshell rollout status statefulset/openshell +GATEWAY_DEPLOYMENT="$(kubectl -n openshell get deployment openshell >/dev/null 2>&1 && echo deployment/openshell || echo statefulset/openshell)" +kubectl -n openshell logs "${GATEWAY_DEPLOYMENT}" -c openshell-gateway --tail=200 +kubectl -n openshell rollout status "${GATEWAY_DEPLOYMENT}" ``` -Use the log and rollout commands for the workload kind that exists in the -release. Look for failed installs, unexpected values, missing namespace, wrong +Use the log and rollout commands for the gateway resource kind that exists in +the release. Look for failed installs, unexpected values, missing namespace, wrong image tag, TLS settings that do not match the registered endpoint, and scheduling failures. @@ -378,6 +377,54 @@ kubectl -n get deployment,service,pod -l app.kubernetes.io/name= logs deployment/ --tail=200 ``` +Multi-replica gateways serialize cross-object sandbox and provider mutations +with a PostgreSQL advisory lock. If those RPCs stall while ordinary reads and +health checks remain responsive, inspect long-running database sessions and +advisory-lock waiters. Do not print the database URI or Secret contents into +logs: + +```sql +SELECT pid, granted, waitstart +FROM pg_locks +WHERE locktype = 'advisory'; +``` + +For multi-replica gateway installs, supervisor and client session traffic may +be served by a non-owner gateway replica and relayed to the current supervisor +owner over the internal `PeerRelay` RPC. Check the headless peer Service, +projected peer ServiceAccount token volume, and TokenReview RBAC: + +```bash +kubectl -n openshell get svc openshell-peer -o wide +kubectl -n openshell get endpoints openshell-peer +kubectl -n openshell get pod -l app.kubernetes.io/instance=openshell \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{.spec.volumes[?(@.name=="gateway-peer-token")]}{"\n"}{.spec.volumes[?(@.name=="peer-client-tls")]}{"\n"}{.spec.containers[0].env[?(@.name=="OPENSHELL_PEER_SERVICE_ACCOUNT_TOKEN_FILE")]}{"\n"}{.spec.containers[0].env[?(@.name=="OPENSHELL_PEER_ENDPOINT")]}{"\n"}{.spec.containers[0].env[?(@.name=="OPENSHELL_PEER_TLS_SERVER_NAME")]}{"\n"}{end}' +kubectl auth can-i create tokenreviews.authentication.k8s.io \ + --as=system:serviceaccount:openshell:openshell +kubectl auth can-i get pods -n openshell \ + --as=system:serviceaccount:openshell:openshell +kubectl -n openshell logs "${GATEWAY_DEPLOYMENT}" --tail=200 | grep -E 'gateway peer|PeerRelay|supervisor owner|owner relay' +``` + +Expected gateway startup logs include +`gateway peer ServiceAccount TokenReview authentication enabled`. If peer relay +calls fail with `Unauthenticated`, verify the `gateway-peer-token` projected +volume has audience `openshell-gateway-peer` and that the receiving gateway can +create TokenReviews. If they fail with `PermissionDenied`, verify the gateway +ServiceAccount name, release namespace, pod UID, and Helm selector labels match +the live gateway pods. Deployment-backed gateway pods should also publish +`OPENSHELL_PEER_ENDPOINT` from their pod IP. The +`OPENSHELL_PEER_SERVICE_ACCOUNT_TOKEN_FILE` name follows the existing +token-file convention used by `OPENSHELL_SANDBOX_TOKEN_FILE` and +`OPENSHELL_K8S_SA_TOKEN_FILE`. + +For TLS-enabled gateways, peer clients verify the stable gateway Service DNS +name and load the chart CA plus client identity from +`OPENSHELL_PEER_TLS_CA_FILE`, `OPENSHELL_PEER_TLS_CERT_FILE`, and +`OPENSHELL_PEER_TLS_KEY_FILE`. If peer calls fail during TLS negotiation, verify +the `peer-client-tls` volume exists, those files are readable, and the server +certificate includes the name in `OPENSHELL_PEER_TLS_SERVER_NAME`. + Check required Helm deployment secrets: ```bash @@ -508,8 +555,8 @@ certificate CA — verify both are from the same issuer. Check the image references currently used by the gateway deployment: ```bash -kubectl -n openshell get deployment openshell -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}" -kubectl -n openshell get statefulset openshell -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}" +GATEWAY_DEPLOYMENT="$(kubectl -n openshell get deployment openshell >/dev/null 2>&1 && echo deployment/openshell || echo statefulset/openshell)" +kubectl -n openshell get "${GATEWAY_DEPLOYMENT}" -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}" helm -n openshell get values openshell | grep -E 'repository|tag|supervisorImage|workload' ``` @@ -563,8 +610,8 @@ If the gateway is healthy but sandbox creation fails: ```bash kubectl -n openshell get pods kubectl -n openshell get events --sort-by=.lastTimestamp | tail -n 50 -kubectl -n openshell logs deployment/openshell -c openshell-gateway --tail=200 -kubectl -n openshell logs statefulset/openshell -c openshell-gateway --tail=200 +GATEWAY_DEPLOYMENT="$(kubectl -n openshell get deployment openshell >/dev/null 2>&1 && echo deployment/openshell || echo statefulset/openshell)" +kubectl -n openshell logs "${GATEWAY_DEPLOYMENT}" -c openshell-gateway --tail=200 ``` Check the configured sandbox namespace: @@ -807,6 +854,7 @@ credential failures. | Sandbox create or provider attach fails naming a missing profile | The provider's profile was never imported, was deleted, or lives at another scope | Import it at the scope the provider uses; the error names the profile ID and the command | | Gateway fails after registering supervisor middleware | Service unavailable, invalid manifest, duplicate binding, reserved name, or invalid payload/timeout limit | Middleware service and gateway logs; `[[openshell.supervisor.middleware]]`; `Describe` response | | Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields | +| Gateway or extension rejects its peer before serving health | Missing peer metadata, incompatible protocol major, or unmet `required_capabilities` | Gateway and extension startup logs; compare `PeerMetadata`; legacy-to-current migration requires a coordinated gateway and extension outage | | Policy mutation returns `FAILED_PRECONDITION` for endpoint ambiguity | Equally specific effective endpoint selectors disagree on connection or request-processing metadata | CLI error, base and provider-composed policy, affected profile attachments; confirm no new revision was stored | | Supervisor enters policy quarantine | A runtime candidate failed validation while `policy_validation_failure_mode = "fail_closed"` | Sandbox OCSF config/finding events, validation rationale, active generation, `previous_policy_active` | | Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or selected name does not match its endpoint/config key | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs | diff --git a/skills/debug-openshell-cluster/references/supervisor-middleware.md b/skills/debug-openshell-cluster/references/supervisor-middleware.md index a1d752466..6a71f9591 100644 --- a/skills/debug-openshell-cluster/references/supervisor-middleware.md +++ b/skills/debug-openshell-cluster/references/supervisor-middleware.md @@ -20,7 +20,7 @@ openshell logs --tail --source sandbox ## Startup and authentication -The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. +The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, peer metadata is missing, the protocol major is incompatible, a required capability is absent, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Current services receive `MiddlewareDescribeRequest` instead of an empty request; regenerate bindings when upgrading a legacy service. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. @@ -44,6 +44,7 @@ WebSocket message sequences are allocated session-wide; each stage receives a st |---|---|---| | Authenticated middleware rejects gateway calls | Private CA or hostname mismatch, expected audience or issuer mismatch, stale/unknown `kid`, or malformed extension token | `tls_ca_cert_path`, registration `audience`, service verifier config and logs; fetch well-known metadata only through the already-trusted gateway TLS endpoint | | Gateway fails after registering supervisor middleware | Service unavailable, invalid manifest, duplicate binding, reserved name, or invalid payload/timeout limit | Middleware service and gateway logs; `[[openshell.supervisor.middleware]]`; `Describe` response | +| Gateway or middleware rejects its peer before serving health | Missing peer metadata, incompatible protocol major, or unmet `required_capabilities` | Gateway and middleware startup logs; compare `PeerMetadata`; legacy-to-current migration requires a coordinated gateway and middleware outage | | Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields | | HTTP request returns `middleware_failed` or `middleware_denied`, or WebSocket closes with `1008` | Selected stage failed or explicitly denied admitted traffic | Sandbox OCSF logs; policy-local middleware config; service availability; binding operation; `on_error` | | HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `whole_body_accumulation_timeout`; service stream lifecycle | diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 708c85608..ad58c9a67 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: openshell-cli -description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration and refresh, policy iteration, settings, service exposure, BYOC workflows, and attached-provider inference. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox exec, sandbox connect, logs, provider create, provider profile, provider refresh, policy set, policy get, settings, service expose, forward, port forward, BYOC, bring your own container, inference, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select. +description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration and refresh, profile management, policy iteration, settings, service exposure, BYOC workflows, and attached-provider inference. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox exec, sandbox connect, logs, provider create, profile list, profile describe, provider refresh, policy set, policy get, settings, service expose, forward, port forward, BYOC, bring your own container, inference, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select. --- # OpenShell CLI @@ -37,6 +37,7 @@ Use `openshell --help` and nested `--help` output as the authority for the insta - [Manage gateways](https://docs.nvidia.com/openshell/latest/sandboxes/manage-gateways.md) - [Manage sandboxes](https://docs.nvidia.com/openshell/latest/sandboxes/manage-sandboxes.md) - [Manage providers](https://docs.nvidia.com/openshell/latest/sandboxes/manage-providers.md) +- [Profiles](https://docs.nvidia.com/openshell/latest/providers/profiles.md) - [Sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) - [Inference routing](https://docs.nvidia.com/openshell/latest/sandboxes/inference-routing.md) @@ -116,9 +117,9 @@ openshell sandbox delete Providers supply credentials and provider-specific configuration to sandboxes. Provider profiles are import-only: a gateway serves exactly what an operator imported, and a new gateway serves an empty catalog. Never rely on a hard-coded type list or on a legacy alias such as `gh` or `claude` — `--type` matches a profile ID exactly. Discover the profiles available on the selected gateway: -```bash -openshell provider list-profiles -openshell provider list-profiles --output json +```shell +openshell profile list +openshell profile list --type provider --output json ``` ### Create a provider from local credentials @@ -141,7 +142,7 @@ Bare `KEY` reads the value from the environment variable of that name and avoids Other credential sources are `--from-gcloud-adc` for compatible profiles and `--runtime-credentials` when the gateway or sandbox resolves the required credentials at runtime. Static provider credentials resolve only for hosts, ports, and paths declared by -the provider profile. Use `provider profile export` to inspect that boundary +the provider profile. Use `profile export` to inspect that boundary when a placeholder is present but requests receive `credential_endpoint_mismatch`. A profileless static provider fails closed because the gateway cannot construct a binding. @@ -159,12 +160,17 @@ enforced. ### Inspect and manage provider profiles -```bash -openshell provider profile export github --output yaml -openshell provider profile lint --file ./my-profile.yaml -openshell provider profile import --file ./my-profile.yaml +```shell +openshell profile describe github +openshell profile export github --output yaml +openshell profile lint --file ./my-profile.yaml +openshell profile import --file ./my-profile.yaml ``` +Use `profile describe` to inspect a definition's credential metadata, endpoints, TLS handling, MCP access settings, rule counts, binaries, source, and scope before creating a provider. Check for `tls: skip` and the uninspected-credential opt-in before relying on displayed L7 rules. List and describe accept table, JSON, and YAML output; use structured output for complete rule definitions, `--workspace` for a workspace catalog, or `--global` for platform scope. Use `profile export` when preparing an editable definition, `profile update --file ` to replace an existing custom profile with its current resource version, and `profile delete ...` to remove custom profiles. Provider instances remain under `provider`. + +Existing scripts can continue using `provider list-profiles` and `provider profile export/import/update/lint/delete`. These commands share the top-level handlers and preserve their arguments, output options, and workspace/global flags. Prefer `profile` when writing new commands. + ### List, inspect, update, delete Use `openshell sandbox provider status --help` and the attach, detach, and update help to find the installed version's wait options. Add `--wait` when the next step depends on a provider change taking effect. Without it, a successful command only confirms that the gateway saved the change. Save the returned `receipt_id` to check that same change later, and inspect the result for every selected sandbox. Credential refresh status confirms that OpenShell obtained credentials; provider status confirms that the sandbox applied them, activated the policy, and updated the environment for new processes. If the status is `superseded`, explain that a later change replaced the request and inspect that change separately. @@ -252,6 +258,7 @@ openshell sandbox create \ ``` Key flags: + - `--provider`: Attach configured credential providers for API keys, tokens, and other secrets (repeatable) - `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var) - `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count @@ -508,6 +515,7 @@ openshell logs dev --tail --source sandbox ``` Look for log lines with `action: deny` -- these indicate blocked network requests. The logs include: + - **Destination host and port** (what was blocked) - **Binary path** (which process attempted the connection) - **Deny reason** (why it was blocked) @@ -523,6 +531,7 @@ The `--full` flag includes the effective policy, including provider-composed ent ### Step 4: Modify the policy Edit `current-policy.yaml` to allow the blocked actions. **For policy content authoring, delegate to the `generate-sandbox-policy` skill.** That skill handles: + - Network endpoint rule structure - L4 vs REST, WebSocket, JSON-RPC, MCP, and SQL L7 policy decisions - Access presets (`read-only`, `read-write`, `full`) @@ -556,6 +565,7 @@ endpoint, or an explicit binding to an endpointless AWS profile. Fix the conflicting endpoint selectors or credential source and submit again. The `--wait` flag blocks until the sandbox confirms the policy is loaded (polls every second). Exit codes: + - **0**: Policy loaded successfully - **1**: Policy load failed - **124**: Timeout (default 60 seconds) @@ -763,6 +773,8 @@ openshell gateway info --name production openshell status ``` +`openshell gateway info` reports the immutable startup snapshot for compute drivers, credential drivers, gateway interceptors, and supervisor middleware. Use each entry's protocol version, implementation version, supported capabilities, and gateway requirements when diagnosing extension skew; implementation versions do not identify underlying Docker, Kubernetes, or credential backends. + Register or remove gateways: ```bash @@ -822,6 +834,14 @@ openshell forward start 127.0.0.1:8080 my-app -d # gRPC relay to a loopback TCP service, with an optional dynamic local port. openshell forward service my-app --target-port 8000 --local 127.0.0.1:0 +# Create a sandbox with its unnamed HTTP or WebSocket service exposed. +openshell sandbox create \ + --name my-app \ + --from my-app:latest \ + --expose 8080 \ + --detach \ + -- ./start-server.sh + # Expose and manage an HTTP service through the gateway. openshell service expose my-app 8080 web openshell service list my-app @@ -833,6 +853,11 @@ openshell service delete my-app web Use `openshell service list --all-workspaces` for a Platform Admin view across workspaces. A sandbox name and `--all-workspaces` are mutually exclusive. +`sandbox create --expose PORT` registers the unnamed endpoint in the create +request and keeps the sandbox running. Add `--output json` for automation; the +result contains a `service_urls` map whose empty key is the unnamed endpoint. +Use `openshell service expose` after creation to add or update named endpoints. + Prefer loopback binds unless the user explicitly needs LAN-visible local access. --- diff --git a/tasks/scripts/gateway-vm.sh b/tasks/scripts/gateway-vm.sh index ff3f1e7df..c8fda4221 100755 --- a/tasks/scripts/gateway-vm.sh +++ b/tasks/scripts/gateway-vm.sh @@ -39,7 +39,7 @@ PORT="${OPENSHELL_SERVER_PORT:-18081}" GATEWAY_NAME="${OPENSHELL_VM_GATEWAY_NAME:-vm-dev}" STATE_DIR="${OPENSHELL_VM_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-vm}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-vm-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" VM_BOOTSTRAP_IMAGE="${OPENSHELL_VM_BOOTSTRAP_IMAGE:-}" SANDBOX_IMAGE_PULL_POLICY="${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}" # VM currently has no image-pull-policy setting in its driver configuration; unlike diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index ca03b4261..312cec7a9 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -28,9 +28,7 @@ target_triple() { local suffix case "$libc" in musl) suffix=musl ;; - # gnu-static builds the GNU target with +crt-static, so it shares the - # gnu triple. - gnu|gnu-static) suffix=gnu ;; + gnu) suffix=gnu ;; *) echo "unsupported libc: $libc" >&2 exit 1 diff --git a/tasks/scripts/test-packaging-assets.sh b/tasks/scripts/test-packaging-assets.sh index 02ecdb007..fb6424ff0 100755 --- a/tasks/scripts/test-packaging-assets.sh +++ b/tasks/scripts/test-packaging-assets.sh @@ -38,11 +38,25 @@ assert_file_exists() { } service="${ROOT}/deploy/deb/openshell-gateway.service" +control="${ROOT}/deploy/deb/control.in" spec="${ROOT}/openshell.spec" assert_file_exists "$service" +assert_file_exists "$control" assert_file_exists "$spec" +# Debian control files are RFC822-style metadata. Older dpkg-deb releases +# reject comment lines as malformed fields, so keep SPDX metadata in the +# adjacent .license sidecar instead of emitting it into DEBIAN/control. +if grep -Eq '^[[:space:]]*#' "$control"; then + echo "FAIL: Debian control template contains a comment field" >&2 + exit 1 +fi +if [[ $(sed -n '/[^[:space:]]/ { p; q; }' "$control") != "Package: openshell" ]]; then + echo "FAIL: Debian control template must begin with the Package field" >&2 + exit 1 +fi + assert_contains \ "$service" \ 'Environment=OPENSHELL_LOCAL_TLS_DIR=%h/.local/state/openshell/tls' diff --git a/tasks/scripts/verify-static-binary.sh b/tasks/scripts/verify-static-binary.sh index 006158ca6..070af27c5 100755 --- a/tasks/scripts/verify-static-binary.sh +++ b/tasks/scripts/verify-static-binary.sh @@ -6,11 +6,12 @@ set -euo pipefail # Verify a binary is a genuine, complete, fully static executable. # -# The supervisor is executed from inside arbitrary sandbox images (Docker -# extraction, Podman image volumes, the Kubernetes copy-self path), so any -# dynamic linkage breaks it on musl-based images and on images whose glibc is -# older than the build host's. Both supported supervisor libc variants (musl -# and glibc-static) must therefore produce a static binary. +# Callers pass binaries that must run without a dynamic loader, such as the +# musl sandbox runtime (openshell-sandbox). It is executed from inside +# arbitrary workload images (Docker extraction, Podman image volumes, the +# Kubernetes copy-self path), so any dynamic linkage breaks it on musl-based +# images and on images whose glibc is older than the build host's. Other +# callers include the release prover and e2e fixtures. # # This check exists because the failure is silent: `zig cc` accepts `-static` # for `*-linux-gnu` targets and emits a dynamically linked binary anyway, so a @@ -57,7 +58,7 @@ if [[ -z $READELF ]]; then host_os="" command -v uname >/dev/null 2>&1 && host_os=$(uname -s 2>/dev/null || true) # Skip only on a host positively identified as non-Linux — e.g. a macOS dev - # cross-building the Linux supervisor via cargo-zigbuild, where mise installs + # cross-building a Linux musl binary via cargo-zigbuild, where mise installs # no binutils. Linux (including CI), or any host whose OS cannot be determined, # fails closed so a missing inspector never silently passes. Static linkage is # still enforced in CI, which runs on Linux. diff --git a/tasks/scripts/vm/build-libkrun.sh b/tasks/scripts/vm/build-libkrun.sh index bd40f2f1d..8058f3605 100755 --- a/tasks/scripts/vm/build-libkrun.sh +++ b/tasks/scripts/vm/build-libkrun.sh @@ -216,15 +216,25 @@ if [ -f openshell.kconfig ]; then CONFIG_SECURITY \ CONFIG_SECURITY_LANDLOCK \ CONFIG_SECCOMP \ - CONFIG_SECCOMP_FILTER; do - val="$(grep "^${opt}=" "${KERNEL_SOURCES}/.config" 2>/dev/null || true)" - if [ -n "$val" ]; then - echo " ${opt}: ${val#*=}" + CONFIG_SECCOMP_FILTER \ + CONFIG_HARDENED_USERCOPY \ + CONFIG_INIT_STACK_ALL_ZERO \ + CONFIG_INIT_ON_ALLOC_DEFAULT_ON \ + CONFIG_SLAB_FREELIST_RANDOM; do + val="$(grep -E "^(# )?${opt}(=| )" "${KERNEL_SOURCES}/.config" 2>/dev/null || true)" + if [ "$val" = "${opt}=y" ]; then + echo " ${opt}: y" else - echo " WARNING: ${opt} not set after merge!" >&2 + echo " WARNING: ${opt} is not enabled after merge: ${val:-unset}" >&2 all_ok=false fi done + if grep -qx '# CONFIG_INIT_ON_FREE_DEFAULT_ON is not set' "${KERNEL_SOURCES}/.config"; then + echo " CONFIG_INIT_ON_FREE_DEFAULT_ON: disabled" + else + echo " WARNING: CONFIG_INIT_ON_FREE_DEFAULT_ON is not explicitly disabled after merge!" >&2 + all_ok=false + fi lsm_order="$(grep '^CONFIG_LSM=' "${KERNEL_SOURCES}/.config" 2>/dev/null || true)" if [[ "$lsm_order" == *landlock* ]]; then echo " CONFIG_LSM: ${lsm_order#*=}" diff --git a/tasks/test.toml b/tasks/test.toml index a8b1ffcd1..94ca45c7f 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -202,6 +202,12 @@ env = { OPENSHELL_E2E_KUBE_DB_SCENARIOS = "1" } depends = ["e2e:conformance:build"] run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +["e2e:kubernetes:ha-rebalancing"] +description = "Run the Kubernetes HA rebalancing suite through Envoy against two gateway replicas and external PostgreSQL" +env = { OPENSHELL_E2E_KUBE_EXTERNAL_POSTGRES_SECRET = "openshell-ha-pg", OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-high-availability.yaml", OPENSHELL_E2E_KUBE_TEST = "kubernetes_ha_rebalancing", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-host-gateway,e2e-kubernetes,e2e-kubernetes-ha", OPENSHELL_E2E_KUBE_USE_ENVOY = "1" } +depends = ["e2e:conformance:build"] +run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" + ["e2e:kubernetes:credential-drivers"] description = "Run Kubernetes e2e for provider credential storage backed by Kubernetes Secrets and Vault" env = { OPENSHELL_E2E_CREDENTIAL_DRIVERS = "1", OPENSHELL_E2E_KUBE_TEST = "credential_drivers", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-credential-drivers" } diff --git a/tests/ansible/playbooks/conformance/cli.yaml b/tests/ansible/playbooks/conformance/cli.yaml index 4adf9aa95..759cc53fc 100644 --- a/tests/ansible/playbooks/conformance/cli.yaml +++ b/tests/ansible/playbooks/conformance/cli.yaml @@ -39,6 +39,15 @@ - conformance_archive.stat.isreg | default(false) fail_msg: OpenShell conformance test bundle did not contain tests.tar.zst + - name: Resolve installed OpenShell CLI + ansible.builtin.command: + argv: + - /bin/sh + - -c + - command -v openshell + register: openshell_cli + changed_when: false + - name: Run OpenShell conformance archive ansible.builtin.command: argv: @@ -51,7 +60,7 @@ - /var/lib/openshell-conformance/tests - --no-capture environment: - OPENSHELL_BIN: /usr/local/bin/openshell + OPENSHELL_BIN: "{{ openshell_cli.stdout }}" register: conformance_result changed_when: false failed_when: false diff --git a/tests/ansible/playbooks/features/provider-refresh/keycloak.yaml b/tests/ansible/playbooks/features/provider-refresh/keycloak.yaml index 81bcba5ed..72ee9ba0f 100644 --- a/tests/ansible/playbooks/features/provider-refresh/keycloak.yaml +++ b/tests/ansible/playbooks/features/provider-refresh/keycloak.yaml @@ -49,6 +49,15 @@ - provider_refresh_keycloak_test_archive.stat.isreg | default(false) fail_msg: Keycloak provider refresh test bundle did not contain tests.tar.zst + - name: Resolve installed OpenShell CLI + ansible.builtin.command: + argv: + - /bin/sh + - -c + - command -v openshell + register: openshell_cli + changed_when: false + - name: Run Keycloak provider refresh archive ansible.builtin.command: argv: @@ -61,7 +70,7 @@ - /var/lib/openshell-provider-refresh/tests - --no-capture environment: - OPENSHELL_BIN: /usr/local/bin/openshell + OPENSHELL_BIN: "{{ openshell_cli.stdout }}" OPENSHELL_E2E_OIDC_ISSUER: http://127.0.0.1:8180/realms/openshell OPENSHELL_E2E_OIDC_USERNAME: admin@test OPENSHELL_E2E_OIDC_PASSWORD: admin diff --git a/tests/ansible/playbooks/openshell-deb.yaml b/tests/ansible/playbooks/openshell-deb.yaml new file mode 100644 index 000000000..f1589374d --- /dev/null +++ b/tests/ansible/playbooks/openshell-deb.yaml @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Install OpenShell Debian package + hosts: all + gather_facts: false + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Copy OpenShell Debian package + become: true + ansible.builtin.copy: + src: "{{ openshell_deb }}" + dest: /var/tmp/openshell.deb + mode: "0644" + + - name: Install OpenShell Debian package + become: true + ansible.builtin.apt: + deb: /var/tmp/openshell.deb + + - name: Copy OpenShell runtime images + become: true + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "/var/tmp/{{ item.name }}.tar" + mode: "0644" + loop: + - name: openshell-sandbox + src: "{{ openshell_sandbox_image }}" + - name: openshell-supervisor + src: "{{ openshell_supervisor_image }}" + + - name: Load OpenShell runtime images + become: true + ansible.builtin.command: + argv: + - docker + - load + - --input + - "/var/tmp/{{ item }}.tar" + loop: + - openshell-sandbox + - openshell-supervisor + + # The package normally starts from its built-in runtime-image defaults. + # Qualification instead pins the candidate images staged by tmachine, so + # keep that override separate from the operator-owned gateway.toml. + - name: Create OpenShell qualification configuration directory + become: true + ansible.builtin.file: + path: /var/lib/openshell-qualification + state: directory + owner: root + group: root + mode: "0755" + + - name: Configure candidate OpenShell runtime images for qualification + become: true + ansible.builtin.copy: + dest: /var/lib/openshell-qualification/gateway.toml + owner: root + group: root + mode: "0644" + content: | + [openshell] + version = 2 + + [openshell.drivers.docker] + sandbox_runtime_image = "docker.io/openshell/sandbox:tmachine" + supervisor_image = "docker.io/openshell/supervisor:tmachine" + + - name: Create OpenShell environment directory + ansible.builtin.file: + path: /home/tmachine/.config/openshell + state: directory + mode: "0700" + + - name: Select qualification gateway configuration + ansible.builtin.copy: + dest: /home/tmachine/.config/openshell/gateway.env + mode: "0600" + content: | + OPENSHELL_GATEWAY_CONFIG=/var/lib/openshell-qualification/gateway.toml + + - name: Start tmachine user manager + ansible.builtin.include_role: + name: tmachine_user_manager + + - name: Start packaged OpenShell gateway service + ansible.builtin.systemd_service: + name: openshell-gateway.service + scope: user + daemon_reload: true + enabled: true + state: started + environment: + XDG_RUNTIME_DIR: /run/user/1000 + DBUS_SESSION_BUS_ADDRESS: unix:path=/run/user/1000/bus + + - name: Wait for OpenShell gateway + ansible.builtin.wait_for: + host: 127.0.0.1 + port: 17670 + timeout: 60 + + - name: Register packaged OpenShell gateway + ansible.builtin.include_role: + name: openshell_client + vars: + openshell_client_gateway_endpoint: https://127.0.0.1:17670 + openshell_client_gateway_name: openshell diff --git a/tests/ansible/playbooks/podman-rootless.yaml b/tests/ansible/playbooks/podman-rootless.yaml index 15d30ebc6..816def359 100644 --- a/tests/ansible/playbooks/podman-rootless.yaml +++ b/tests/ansible/playbooks/podman-rootless.yaml @@ -9,29 +9,9 @@ - name: Wait for SSH ansible.builtin.wait_for_connection: - - name: Enable lingering for tmachine - become: true - ansible.builtin.command: - argv: - - loginctl - - enable-linger - - tmachine - changed_when: false - - - name: Resolve tmachine UID - ansible.builtin.command: - argv: - - id - - -u - - tmachine - changed_when: false - register: tmachine_uid - - name: Start tmachine user manager - become: true - ansible.builtin.systemd_service: - name: "user@{{ tmachine_uid.stdout }}.service" - state: started + ansible.builtin.include_role: + name: tmachine_user_manager - name: Enable rootless Podman socket become: true @@ -43,5 +23,5 @@ state: started environment: HOME: /home/tmachine - XDG_RUNTIME_DIR: "/run/user/{{ tmachine_uid.stdout }}" - DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ tmachine_uid.stdout }}/bus" + XDG_RUNTIME_DIR: /run/user/1000 + DBUS_SESSION_BUS_ADDRESS: unix:path=/run/user/1000/bus diff --git a/tests/ansible/roles/openshell_client/tasks/main.yaml b/tests/ansible/roles/openshell_client/tasks/main.yaml index 25f41fb86..6586f23ac 100644 --- a/tests/ansible/roles/openshell_client/tasks/main.yaml +++ b/tests/ansible/roles/openshell_client/tasks/main.yaml @@ -2,13 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 --- -- name: Register OpenShell gateway for tmachine test client +- name: Register OpenShell gateway for test client ansible.builtin.command: argv: - - /usr/local/bin/openshell + - openshell - gateway - add - - http://127.0.0.1:17670 + - "{{ openshell_client_gateway_endpoint | default('http://127.0.0.1:17670') }}" - --local - --name - - tmachine + - "{{ openshell_client_gateway_name | default('tmachine') }}" diff --git a/tests/ansible/roles/openshell_gateway/tasks/main.yaml b/tests/ansible/roles/openshell_gateway/tasks/main.yaml index c065354b4..c8089f5ae 100644 --- a/tests/ansible/roles/openshell_gateway/tasks/main.yaml +++ b/tests/ansible/roles/openshell_gateway/tasks/main.yaml @@ -43,7 +43,7 @@ become_user: "{{ openshell_gateway_user }}" ansible.builtin.command: argv: - - /usr/local/bin/openshell-gateway + - openshell-gateway - generate-certs - --output-dir - /var/lib/openshell/pki diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway.service.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway.service.j2 index 9e589803c..6966e4b07 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway.service.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway.service.j2 @@ -11,7 +11,7 @@ User={{ openshell_gateway_user }} Group={{ openshell_gateway_user }} Environment=HOME={{ openshell_gateway_home }} {% block service %}{% endblock %} -ExecStart=/usr/local/bin/openshell-gateway --config /etc/openshell/gateway.toml +ExecStart=openshell-gateway --config /etc/openshell/gateway.toml Restart=on-failure [Install] diff --git a/tests/ansible/roles/tmachine_user_manager/tasks/main.yaml b/tests/ansible/roles/tmachine_user_manager/tasks/main.yaml new file mode 100644 index 000000000..1b2f2232a --- /dev/null +++ b/tests/ansible/roles/tmachine_user_manager/tasks/main.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Enable lingering for tmachine + become: true + ansible.builtin.command: + argv: + - loginctl + - enable-linger + - tmachine + changed_when: false + +- name: Start tmachine user manager + become: true + ansible.builtin.systemd_service: + name: user@1000.service + state: started diff --git a/tests/config.nix b/tests/config.nix index 461d3ddfb..b542af661 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -84,6 +84,18 @@ let openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; }; } + { + name = "deb"; + use_galaxy = false; + playbooks = [ + "ansible/playbooks/openshell-deb.yaml" + ]; + inputs = { + openshell_deb = "../artifacts/packages/openshell.deb"; + openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar"; + openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; + }; + } ]; testsuites = [ diff --git a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs index cddc5b7bb..5bc12cf8b 100644 --- a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs +++ b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs @@ -197,7 +197,7 @@ binaries: async fn delete_provider_resources() { let _ = run_cli(&["provider", "delete", PROVIDER_NAME], &[]).await; - let _ = run_cli(&["provider", "profile", "delete", PROFILE_ID], &[]).await; + let _ = run_cli(&["profile", "delete", PROFILE_ID], &[]).await; } #[tokio::test] @@ -216,7 +216,7 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str delete_provider_resources().await; let result = async { run_cli_success( - &["provider", "profile", "import", "--file", &profile_path], + &["profile", "import", "--file", &profile_path], &[], ) .await?;